687 lines
38 KiB
TypeScript
687 lines
38 KiB
TypeScript
import { type Component, createEffect, createSignal, For } from "solid-js";
|
|
import { Sheet, SheetContent } from "@/components/ui/sheet";
|
|
import { type Task, removeTask, restoreTask, updateTask, saveTaskAsTemplate, shareTask, revokeShare, splitTask } from "@/store";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { ArrowUpCircle, Clock, Calendar, Type, Trash2, X, Copy, MoreHorizontal, Gauge, Share2, UserMinus, GitBranch, Tag, Settings } from "lucide-solid";
|
|
import { calculateDateFromUrgency, calculateUrgencyFromDate } from "@/store";
|
|
import { Button } from "./ui/button";
|
|
import { TagPicker } from "./TagPicker";
|
|
import { Badge } from "./ui/badge";
|
|
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
|
|
import { store } from "@/store";
|
|
import { cn } from "@/lib/utils";
|
|
import { toast } from "solid-sonner";
|
|
import { lazy, Suspense } from "solid-js";
|
|
import { useTheme } from "./ThemeProvider";
|
|
import { getThemeAdjustedColor } from "@/lib/colors";
|
|
import { pb } from "@/lib/pocketbase";
|
|
|
|
const TaskEditor = lazy(() => import("./TaskEditor").then(m => ({ default: m.TaskEditor })));
|
|
|
|
interface TaskDetailProps {
|
|
task: Task;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export const TaskDetail: Component<TaskDetailProps> = (props) => {
|
|
const { resolvedTheme } = useTheme();
|
|
// Local state for immediate feedback, synced with props
|
|
const [title, setTitle] = createSignal(props.task.title);
|
|
const [editorInstance, setEditorInstance] = createSignal<any>(null);
|
|
|
|
// Sync input string for datetime-local
|
|
const [dateString, setDateString] = createSignal<string>("");
|
|
|
|
createEffect(() => {
|
|
setTitle(props.task.title);
|
|
// Format for datetime-local: yyyy-MM-ddThh:mm (local time)
|
|
const dateObj = new Date(props.task.dueDate);
|
|
const localIso = new Date(dateObj.getTime() - (dateObj.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
|
|
setDateString(localIso);
|
|
});
|
|
|
|
let debounceTimer: number | undefined;
|
|
let pendingContent: string | null = null;
|
|
|
|
const updateTaskContent = (html: string) => {
|
|
pendingContent = html;
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
if (pendingContent !== null) {
|
|
updateTask(props.task.id, { content: pendingContent });
|
|
pendingContent = null;
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
// Removed onCleanup to avoid lockup
|
|
|
|
|
|
const updateTitle = (val: string) => {
|
|
setTitle(val);
|
|
updateTask(props.task.id, { title: val });
|
|
};
|
|
|
|
const updatePriority = (val: any) => {
|
|
const value = typeof val === 'object' ? val.value : val;
|
|
const p = parseInt(value);
|
|
if (!isNaN(p)) {
|
|
updateTask(props.task.id, { priority: p });
|
|
}
|
|
};
|
|
|
|
const updateUrgency = (val: any) => {
|
|
const value = typeof val === 'object' ? val.value : val;
|
|
const u = parseInt(value);
|
|
if (!isNaN(u)) {
|
|
const newDate = calculateDateFromUrgency(u);
|
|
updateTask(props.task.id, { dueDate: newDate });
|
|
}
|
|
};
|
|
|
|
const updateTags = (tags: string[]) => {
|
|
updateTask(props.task.id, { tags });
|
|
};
|
|
|
|
const onDateInputChange = (e: Event) => {
|
|
const val = (e.currentTarget as HTMLInputElement).value;
|
|
if (val) {
|
|
const iso = new Date(val).toISOString();
|
|
updateTask(props.task.id, { dueDate: iso });
|
|
}
|
|
};
|
|
|
|
// Calculate current urgency level for display
|
|
const currentUrgency = () => calculateUrgencyFromDate(props.task.dueDate).toString();
|
|
|
|
return (
|
|
<Sheet open={props.isOpen} onOpenChange={(open) => {
|
|
if (!open) {
|
|
// Flash save before closing to avoid lockup
|
|
if (pendingContent !== null) {
|
|
updateTask(props.task.id, { content: pendingContent });
|
|
pendingContent = null;
|
|
}
|
|
props.onClose();
|
|
}
|
|
}}>
|
|
<SheetContent
|
|
hideCloseButton
|
|
onOpenAutoFocus={(e) => {
|
|
// Prevent auto-focus on mobile to avoid keyboard popup
|
|
if (window.innerWidth < 768) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
class="w-full sm:max-w-2xl px-0 sm:px-0 flex flex-col h-full bg-background border-l border-border shadow-2xl"
|
|
>
|
|
{/* Header Area */}
|
|
<div class="px-6 pt-6 pb-2 shrink-0">
|
|
<textarea
|
|
id="task-detail-title"
|
|
name="task-title"
|
|
value={title()}
|
|
onInput={(e) => {
|
|
updateTitle(e.currentTarget.value);
|
|
// Auto-resize
|
|
e.currentTarget.style.height = 'auto';
|
|
e.currentTarget.style.height = e.currentTarget.scrollHeight + 'px';
|
|
}}
|
|
ref={(el) => {
|
|
setTimeout(() => {
|
|
el.style.height = 'auto';
|
|
el.style.height = el.scrollHeight + 'px';
|
|
}, 0);
|
|
}}
|
|
rows={1}
|
|
class="text-xl sm:text-2xl font-bold bg-transparent border-none focus:outline-none focus:ring-0 w-full placeholder:text-muted-foreground/50 resize-none overflow-hidden leading-tight"
|
|
placeholder="Untitled Task"
|
|
/>
|
|
|
|
{/* Properties Bar */}
|
|
<div class="flex flex-wrap items-center gap-2 sm:gap-4 mt-2 text-sm">
|
|
{/* Priority */}
|
|
<div class="flex items-center gap-1 group shrink-0">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Priority</span>
|
|
<Select<any>
|
|
value={props.task.priority.toString()}
|
|
onChange={(val: string | null) => val && updatePriority(val)}
|
|
options={PRIORITY_OPTIONS}
|
|
optionValue="value"
|
|
optionTextValue="label"
|
|
placeholder="Priority"
|
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
|
>
|
|
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
|
|
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
|
|
<ArrowUpCircle size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
<span class="text-xs sm:text-sm truncate">
|
|
{PRIORITY_OPTIONS.find(o => o.value === props.task.priority.toString())?.label || props.task.priority}
|
|
</span>
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Urgency */}
|
|
<div class="flex items-center gap-1 group shrink-0">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Time</span>
|
|
<Select<any>
|
|
value={currentUrgency()}
|
|
onChange={(val: string | null) => val && updateUrgency(val)}
|
|
options={URGENCY_OPTIONS}
|
|
optionValue="value"
|
|
optionTextValue="label"
|
|
placeholder="Urgency"
|
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
|
>
|
|
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
|
|
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
|
|
<Clock size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
<span class="text-xs sm:text-sm truncate">
|
|
{URGENCY_OPTIONS.find(o => o.value === currentUrgency())?.label || currentUrgency()}
|
|
</span>
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Size */}
|
|
<div class="flex items-center gap-1 group shrink-0">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/70 hidden sm:inline">Size</span>
|
|
<Select<any>
|
|
value={(props.task.size ?? 5).toString()}
|
|
onChange={(val: any) => {
|
|
const value = typeof val === 'object' ? val?.value : val;
|
|
if (value) {
|
|
const s = parseInt(value);
|
|
if (!isNaN(s)) updateTask(props.task.id, { size: s });
|
|
}
|
|
}}
|
|
options={SIZE_OPTIONS}
|
|
optionValue="value"
|
|
optionTextValue="label"
|
|
placeholder="Size"
|
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
|
>
|
|
<SelectTrigger class="h-8 min-w-[40px] max-w-[180px] bg-transparent border-transparent hover:bg-muted/50 px-1 sm:px-2 shadow-none focus:ring-0 shrink-0 overflow-hidden">
|
|
<div class="flex items-center gap-1 sm:gap-1.5 text-foreground font-medium truncate">
|
|
<Gauge size={14} class="text-primary opacity-70 group-hover:opacity-100 transition-opacity shrink-0" />
|
|
<span class="text-xs sm:text-sm truncate">
|
|
{SIZE_OPTIONS.find(o => o.value === (props.task.size ?? 5).toString())?.label || props.task.size}
|
|
</span>
|
|
</div>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Commands Shortcut */}
|
|
<div class="flex items-center gap-1 shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-8 px-1 sm:px-2 text-[10px] font-bold uppercase tracking-wider hover:bg-muted/50 flex items-center gap-1 sm:gap-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => {
|
|
const instance = editorInstance();
|
|
if (instance) {
|
|
instance.chain().focus().insertContent("/").run();
|
|
}
|
|
}}
|
|
>
|
|
<Type size={14} class="opacity-70" />
|
|
<span class="hidden sm:inline">Commands</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metadata Row (Dates/Timestamps) */}
|
|
<div class="flex flex-wrap items-center gap-x-6 gap-y-2 mt-4 px-1">
|
|
<MetadataItem
|
|
label="Due"
|
|
date={new Date(props.task.dueDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
time={new Date(props.task.dueDate).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
|
|
icon={<Calendar size={12} />}
|
|
editable={true}
|
|
onClick={() => (document.getElementById('task-detail-date') as HTMLInputElement)?.showPicker?.()}
|
|
>
|
|
<input
|
|
id="task-detail-date"
|
|
name="due-date"
|
|
type="datetime-local"
|
|
value={dateString()}
|
|
onInput={onDateInputChange}
|
|
class="absolute inset-0 opacity-0 cursor-pointer w-full h-full"
|
|
/>
|
|
</MetadataItem>
|
|
|
|
<MetadataItem
|
|
label="Created"
|
|
date={new Date(props.task.created).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
time={new Date(props.task.created).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
|
|
/>
|
|
|
|
<MetadataItem
|
|
label="Updated"
|
|
date={new Date(props.task.updated).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
|
time={new Date(props.task.updated).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}
|
|
/>
|
|
</div>
|
|
|
|
<div class="h-px w-full bg-border mt-5" />
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div class="flex-1 overflow-y-auto px-6 py-4 pb-20 flex flex-col gap-6">
|
|
<Suspense fallback={<div class="h-32 animate-pulse bg-muted/20 rounded-lg" />}>
|
|
<TaskEditor
|
|
content={props.task.content}
|
|
onUpdate={updateTaskContent}
|
|
onEditorReady={setEditorInstance}
|
|
/>
|
|
</Suspense>
|
|
|
|
{/* Tags at the bottom */}
|
|
<div class="mt-auto pt-6 border-t border-border/50">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<span class="text-[10px] uppercase font-bold tracking-wider text-muted-foreground/60 shrink-0">Tags</span>
|
|
<div class="flex flex-wrap items-center gap-1.5 min-w-0 flex-1">
|
|
<TagPicker
|
|
selectedTags={props.task.tags || []}
|
|
onTagsChange={updateTags}
|
|
/>
|
|
<div class="flex flex-wrap items-center gap-1.5">
|
|
<For each={props.task.tags || []}>
|
|
{(tag) => (
|
|
<Badge
|
|
variant="secondary"
|
|
class="h-7 px-2 text-xs gap-1.5 bg-muted/30 border-border/50 hover:bg-destructive/10 hover:border-destructive/30 cursor-pointer group/tag transition-all whitespace-nowrap 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";
|
|
})()
|
|
}}
|
|
onClick={() => updateTags((props.task.tags || []).filter(t => t !== tag))}
|
|
>
|
|
<div
|
|
class="w-1.5 h-1.5 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}
|
|
<X size={12} class="opacity-0 group-hover/tag:opacity-50 transition-opacity" />
|
|
</Badge>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sticky Footer Actions */}
|
|
<div class="px-6 py-4 border-t border-border/50 flex items-center justify-between shrink-0 bg-background/80 backdrop-blur-sm">
|
|
{/* Delete button (persistent) */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="h-9 px-3 gap-2 hover:bg-destructive/10 hover:text-destructive text-muted-foreground transition-all rounded-xl border border-border/40"
|
|
onClick={() => {
|
|
const taskId = props.task.id;
|
|
removeTask(taskId);
|
|
props.onClose();
|
|
toast.info("Task moved to trash", {
|
|
action: {
|
|
label: "Undo",
|
|
onClick: () => restoreTask(taskId)
|
|
}
|
|
});
|
|
}}
|
|
>
|
|
<Trash2 size={16} />
|
|
<span class="text-xs font-bold uppercase tracking-wider">Delete</span>
|
|
</Button>
|
|
|
|
<div class="flex items-center gap-2">
|
|
{/* More Options Menu */}
|
|
<Popover modal={true}>
|
|
<PopoverTrigger as={Button} variant="ghost" size="sm" class="h-9 w-9 p-0 rounded-xl border border-border/40 hover:bg-muted text-muted-foreground hover:text-foreground">
|
|
<MoreHorizontal size={18} />
|
|
</PopoverTrigger>
|
|
<PopoverContent class="w-72 p-0 overflow-hidden" align="end">
|
|
<div class="flex flex-col">
|
|
<div class="p-2 border-b border-border/50">
|
|
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1">Recurrence</div>
|
|
<div class="flex flex-col gap-2 p-2 pt-0">
|
|
<Select
|
|
value={props.task.recurrence?.type || "none"}
|
|
onChange={(val: string | null) => {
|
|
if (!val || val === "none") {
|
|
updateTask(props.task.id, { recurrence: null as any });
|
|
} else {
|
|
updateTask(props.task.id, {
|
|
recurrence: {
|
|
type: val as any,
|
|
days: val === 'weekly' ? [] : undefined,
|
|
dayOfMonth: val === 'monthly' ? 1 : undefined
|
|
}
|
|
});
|
|
}
|
|
}}
|
|
options={["none", "daily", "weekly", "monthly"]}
|
|
placeholder="None"
|
|
itemComponent={(props: any) => (
|
|
<SelectItem item={props.item} class="capitalize">
|
|
{props.item.rawValue}
|
|
</SelectItem>
|
|
)}
|
|
>
|
|
<SelectTrigger class="h-8 text-xs capitalize">
|
|
<SelectValue<string>>{state => state.selectedOption() || "None"}</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
|
|
{/* Weekly Options */}
|
|
{props.task.recurrence?.type === 'weekly' && (
|
|
<div class="flex flex-wrap gap-1 mt-1 justify-between">
|
|
<For each={['S', 'M', 'T', 'W', 'T', 'F', 'S']}>
|
|
{(day, index) => (
|
|
<button
|
|
class={cn(
|
|
"w-7 h-7 rounded-full text-[10px] font-bold flex items-center justify-center transition-colors shadow-sm",
|
|
props.task.recurrence?.days?.includes(index())
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
|
)}
|
|
onClick={() => {
|
|
const currentDays = props.task.recurrence?.days || [];
|
|
const newDays = currentDays.includes(index())
|
|
? currentDays.filter(d => d !== index())
|
|
: [...currentDays, index()].sort();
|
|
|
|
updateTask(props.task.id, {
|
|
recurrence: { ...props.task.recurrence!, days: newDays }
|
|
});
|
|
}}
|
|
>
|
|
{day}
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)}
|
|
|
|
{/* Monthly Options */}
|
|
{props.task.recurrence?.type === 'monthly' && (
|
|
<div class="flex items-center gap-2 mt-1">
|
|
<span class="text-xs text-muted-foreground">Day of month:</span>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="31"
|
|
class="h-7 w-16 rounded-md border border-input bg-background px-2 text-xs"
|
|
value={props.task.recurrence?.dayOfMonth || 1}
|
|
onInput={(e) => {
|
|
const val = parseInt(e.currentTarget.value);
|
|
if (!isNaN(val) && val >= 1 && val <= 31) {
|
|
updateTask(props.task.id, {
|
|
recurrence: { ...props.task.recurrence!, dayOfMonth: val }
|
|
});
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="p-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="w-full justify-start h-8 px-2 gap-2 text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
|
|
onClick={async () => {
|
|
const name = prompt("Enter a name for this template:");
|
|
if (name) {
|
|
await saveTaskAsTemplate(props.task.id, name);
|
|
toast.success(`Template "${name}" created`);
|
|
}
|
|
}}
|
|
>
|
|
<Copy size={14} />
|
|
<span class="text-xs">Create Template from Task</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Sharing Section */}
|
|
<div class="p-2 border-t border-border/50">
|
|
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/70 px-2 py-1 mb-1">Sharing</div>
|
|
<UserSharePicker
|
|
taskId={props.task.id}
|
|
sharedWith={props.task.sharedWith || []}
|
|
tags={props.task.tags || []}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* Close button (Mobile only) */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
class="md:hidden h-9 px-3 gap-2 hover:bg-muted text-muted-foreground transition-all rounded-xl border border-border/40"
|
|
onClick={() => props.onClose()}
|
|
>
|
|
<X size={16} />
|
|
<span class="text-xs font-bold uppercase tracking-wider">Close</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</SheetContent>
|
|
</Sheet>
|
|
);
|
|
};
|
|
|
|
// Reusable Metadata Item with slide transition
|
|
const MetadataItem: Component<{
|
|
label: string,
|
|
date: string,
|
|
time: string,
|
|
icon?: any,
|
|
editable?: boolean,
|
|
urgency?: string,
|
|
children?: any,
|
|
onClick?: () => void
|
|
}> = (props) => {
|
|
const [isToggled, setIsToggled] = createSignal(false);
|
|
|
|
return (
|
|
<div
|
|
class="group relative flex flex-col gap-0.5 cursor-pointer select-none"
|
|
onMouseEnter={() => setIsToggled(true)}
|
|
onMouseLeave={() => setIsToggled(false)}
|
|
onClick={() => {
|
|
setIsToggled(!isToggled());
|
|
props.onClick?.();
|
|
}}
|
|
>
|
|
<span class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/60 transition-colors group-hover:text-primary/70">
|
|
{props.label}
|
|
</span>
|
|
<div class="h-4 overflow-hidden relative min-w-[100px]">
|
|
<div class={cn(
|
|
"flex flex-col transition-transform duration-500 cubic-bezier(0.4, 0, 0.2, 1)",
|
|
isToggled() ? "-translate-y-4" : "translate-y-0"
|
|
)}>
|
|
{/* Layer 1: Date */}
|
|
<div class="h-4 flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground/80 whitespace-nowrap">
|
|
{props.icon}
|
|
{props.date}
|
|
</div>
|
|
{/* Layer 2: Time */}
|
|
<div class="h-4 flex items-center gap-2 text-[11px] font-bold text-foreground whitespace-nowrap">
|
|
<Clock size={11} class="text-primary/60 shrink-0" />
|
|
<span>{props.time}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{props.children}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// User Share Picker Component
|
|
const UserSharePicker: Component<{
|
|
taskId: string;
|
|
sharedWith: Array<{ userId: string; access: 'view' | 'edit' }>;
|
|
tags: string[];
|
|
}> = (props) => {
|
|
const [users, setUsers] = createSignal<Array<{ id: string; name: string }>>([]);
|
|
const [selectedUserId, setSelectedUserId] = createSignal<string>("");
|
|
const [loading, setLoading] = createSignal(true);
|
|
|
|
// Get matching ShareRules for this task
|
|
const matchingRules = () => {
|
|
const currentUserId = pb.authStore.model?.id;
|
|
return store.shareRules.filter(rule => {
|
|
if (rule.ownerId !== currentUserId) return false;
|
|
if (rule.type === 'all') return true;
|
|
if (rule.type === 'tag' && props.tags.includes(rule.tagName!)) return true;
|
|
return false;
|
|
});
|
|
};
|
|
|
|
const getUserName = (userId: string) => {
|
|
return users().find(u => u.id === userId)?.name || userId;
|
|
};
|
|
|
|
// Fetch users on mount
|
|
createEffect(async () => {
|
|
try {
|
|
const records = await pb.collection('users').getFullList({
|
|
fields: 'id,name'
|
|
});
|
|
setUsers(records.map(r => ({ id: r.id, name: r.name || r.email || r.id })));
|
|
} catch (err) {
|
|
console.error("Failed to load users:", err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
});
|
|
|
|
|
|
const availableUsers = () => {
|
|
const sharedUserIds = props.sharedWith.map(s => s.userId);
|
|
const currentUserId = pb.authStore.model?.id;
|
|
return users().filter(u => !sharedUserIds.includes(u.id) && u.id !== currentUserId);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div class="flex gap-1 mb-2">
|
|
<Select<{ id: string; name: string }>
|
|
value={users().find(u => u.id === selectedUserId()) || null}
|
|
onChange={(user) => setSelectedUserId(user?.id || "")}
|
|
options={availableUsers()}
|
|
optionValue="id"
|
|
optionTextValue="name"
|
|
placeholder={loading() ? "Loading..." : "Select user..."}
|
|
itemComponent={(itemProps: any) => (
|
|
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.name}</SelectItem>
|
|
)}
|
|
>
|
|
<SelectTrigger class="flex-1 h-8 text-xs">
|
|
<SelectValue<{ id: string; name: string }>>
|
|
{(state) => state.selectedOption()?.name || "Select user..."}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-8 w-8"
|
|
disabled={!selectedUserId()}
|
|
onClick={async () => {
|
|
if (selectedUserId()) {
|
|
await shareTask(props.taskId, selectedUserId(), 'edit');
|
|
setSelectedUserId("");
|
|
}
|
|
}}
|
|
>
|
|
<Share2 size={14} />
|
|
</Button>
|
|
</div>
|
|
<For each={props.sharedWith}>
|
|
{(share) => (
|
|
<div class="flex items-center justify-between py-1 px-2 rounded-lg bg-muted/30 mb-1">
|
|
<span class="text-xs truncate flex-1">{getUserName(share.userId)}</span>
|
|
<div class="flex gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-6 w-6 hover:bg-amber-500/10 hover:text-amber-600"
|
|
title="Split - Give them an independent copy"
|
|
onClick={() => splitTask(props.taskId, share.userId)}
|
|
>
|
|
<GitBranch size={12} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-6 w-6 hover:bg-destructive/10 hover:text-destructive"
|
|
title="Revoke access"
|
|
onClick={() => revokeShare(props.taskId, share.userId)}
|
|
>
|
|
<UserMinus size={12} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
|
|
{/* ShareRule-based sharing */}
|
|
{matchingRules().length > 0 && (
|
|
<div class="mt-3 pt-3 border-t border-border/30">
|
|
<div class="text-[9px] font-bold uppercase tracking-widest text-muted-foreground/40 mb-2 px-1">Automatic Rule Shares</div>
|
|
<For each={matchingRules()}>
|
|
{(rule) => (
|
|
<div class="flex items-center gap-2 py-1.5 px-2 rounded-lg bg-muted/10 mb-1 border border-border/5">
|
|
<div class="shrink-0 text-muted-foreground/40">
|
|
{rule.type === 'tag' ? <Tag size={10} /> : <Settings size={10} />}
|
|
</div>
|
|
<div class="flex flex-col min-w-0">
|
|
<span class="text-[11px] font-medium leading-none truncate">
|
|
{getUserName(rule.targetUserId)}
|
|
</span>
|
|
<span class="text-[9px] text-muted-foreground/50 mt-0.5 uppercase tracking-tighter">
|
|
{rule.type === 'all' ? 'All Tasks Rule' : `Matched Tag: ${rule.tagName}`}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|