first attempt at stream sidebar, tasks, and events
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { deleteField } from 'firebase/firestore';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import type { ChecklistItem, Particle } from '@/api/types';
|
||||
import {
|
||||
particlePath,
|
||||
parseParticlePath,
|
||||
toFirestoreDocPath,
|
||||
type ParticlePath,
|
||||
} from '@/lib/particle-path';
|
||||
import {
|
||||
updateParticle,
|
||||
updateParticleProperties,
|
||||
} from '@/lib/firestore-particles';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
|
||||
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
|
||||
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||
|
||||
interface TaskParticleViewProps {
|
||||
particle: TaskParticle;
|
||||
containerPath: ParticlePath;
|
||||
paused: boolean;
|
||||
onEnded: () => void;
|
||||
onProgress?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
const DWELL_DURATION_S = 8;
|
||||
const UNASSIGNED = 'unassigned';
|
||||
|
||||
function useParticleDocPath(
|
||||
containerPath: ParticlePath,
|
||||
particleId: string,
|
||||
): string {
|
||||
const { networkId, segments } = parseParticlePath(containerPath);
|
||||
return toFirestoreDocPath(particlePath(networkId, [...segments, particleId]));
|
||||
}
|
||||
|
||||
export function TaskParticleView({
|
||||
particle,
|
||||
containerPath,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
}: TaskParticleViewProps) {
|
||||
const { networkId } = parseParticlePath(containerPath);
|
||||
const network = useNetwork(networkId);
|
||||
const docPath = useParticleDocPath(containerPath, particle.id);
|
||||
|
||||
const {
|
||||
title,
|
||||
notes,
|
||||
checklist = [],
|
||||
assigned_to,
|
||||
done,
|
||||
} = particle.properties;
|
||||
|
||||
// Suspend playback while any field inside the card has focus so typing
|
||||
// doesn't race the dwell timer or get eaten by global key handlers.
|
||||
const [editing, setEditing] = useState(false);
|
||||
useSuspendPlayback(editing, `task-edit-${particle.id}`);
|
||||
|
||||
useFixedDwell({
|
||||
id: particle.id,
|
||||
durationS: DWELL_DURATION_S,
|
||||
paused,
|
||||
onEnded,
|
||||
onProgress,
|
||||
});
|
||||
|
||||
const titleField = useLiveDraftField({
|
||||
remoteValue: title,
|
||||
commit: (value) =>
|
||||
updateParticleProperties<'task'>(docPath, { title: value }),
|
||||
});
|
||||
const notesField = useLiveDraftField({
|
||||
remoteValue: notes ?? '',
|
||||
commit: (value) =>
|
||||
updateParticleProperties<'task'>(docPath, { notes: value }),
|
||||
});
|
||||
|
||||
// Checklist writes replace the whole array (merged against the latest live
|
||||
// value); concurrent edits to the same checklist are last-write-wins.
|
||||
const checklistRef = useRef(checklist);
|
||||
useEffect(() => {
|
||||
checklistRef.current = checklist;
|
||||
}, [checklist]);
|
||||
|
||||
const writeChecklist = useCallback(
|
||||
(items: ChecklistItem[]) =>
|
||||
updateParticleProperties<'task'>(docPath, { checklist: items }),
|
||||
[docPath],
|
||||
);
|
||||
|
||||
const handleToggleDone = useCallback(
|
||||
(checked: boolean) =>
|
||||
updateParticleProperties<'task'>(docPath, { done: checked }),
|
||||
[docPath],
|
||||
);
|
||||
|
||||
const handleToggleItem = useCallback(
|
||||
(index: number, checked: boolean) => {
|
||||
const items = checklistRef.current.map((item, i) =>
|
||||
i === index ? { ...item, done: checked } : item,
|
||||
);
|
||||
void writeChecklist(items);
|
||||
},
|
||||
[writeChecklist],
|
||||
);
|
||||
|
||||
const handleCommitItemText = useCallback(
|
||||
(index: number, text: string) => {
|
||||
const items = checklistRef.current.map((item, i) =>
|
||||
i === index ? { ...item, text } : item,
|
||||
);
|
||||
void writeChecklist(items);
|
||||
},
|
||||
[writeChecklist],
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
void writeChecklist(checklistRef.current.filter((_, i) => i !== index));
|
||||
},
|
||||
[writeChecklist],
|
||||
);
|
||||
|
||||
const handleAddItem = useCallback(
|
||||
(text: string) => {
|
||||
void writeChecklist([...checklistRef.current, { text, done: false }]);
|
||||
},
|
||||
[writeChecklist],
|
||||
);
|
||||
|
||||
const handleAssign = useCallback(
|
||||
(value: string) => {
|
||||
if (value === UNASSIGNED) {
|
||||
void updateParticle(docPath, 'properties.assigned_to', deleteField());
|
||||
} else {
|
||||
void updateParticleProperties<'task'>(docPath, { assigned_to: value });
|
||||
}
|
||||
},
|
||||
[docPath],
|
||||
);
|
||||
|
||||
const assignee = resolveHumanDisplay(assigned_to, network?.humans);
|
||||
const doneCount = checklist.filter((item) => item.done).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
|
||||
<div
|
||||
className="scrollbar-card flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"
|
||||
onFocusCapture={() => setEditing(true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={done}
|
||||
onCheckedChange={(checked) => handleToggleDone(checked === true)}
|
||||
className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
||||
aria-label={done ? 'Mark task as not done' : 'Mark task as done'}
|
||||
/>
|
||||
<input
|
||||
value={titleField.value}
|
||||
onChange={(e) => titleField.onChange(e.target.value)}
|
||||
onFocus={titleField.onFocus}
|
||||
onBlur={titleField.onBlur}
|
||||
placeholder="Task title"
|
||||
className={cn(
|
||||
'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30',
|
||||
done && 'text-white/50 line-through',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={notesField.value}
|
||||
onChange={(e) => notesField.onChange(e.target.value)}
|
||||
onFocus={notesField.onFocus}
|
||||
onBlur={notesField.onBlur}
|
||||
placeholder="Add notes…"
|
||||
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{checklist.length > 0 && (
|
||||
<span className="text-xs text-white/40">
|
||||
{doneCount} / {checklist.length} done
|
||||
</span>
|
||||
)}
|
||||
{checklist.map((item, index) => (
|
||||
<ChecklistItemRow
|
||||
// Index keys + whole-array writes are an accepted tradeoff:
|
||||
// concurrent removal while someone types can shift focus.
|
||||
key={index}
|
||||
item={item}
|
||||
onToggle={(checked) => handleToggleItem(index, checked)}
|
||||
onCommitText={(text) => handleCommitItemText(index, text)}
|
||||
onRemove={() => handleRemoveItem(index)}
|
||||
/>
|
||||
))}
|
||||
<AddChecklistItemRow onAdd={handleAddItem} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={assigned_to ?? UNASSIGNED}
|
||||
onValueChange={handleAssign}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-fit gap-2 border-white/15 bg-white/5 text-white/80"
|
||||
>
|
||||
{assigned_to && assignee.exists && (
|
||||
<HumanAvatar
|
||||
size="sm"
|
||||
initials={assignee.initials}
|
||||
avatarObjectId={assignee.avatarObjectId}
|
||||
/>
|
||||
)}
|
||||
<SelectValue placeholder="Unassigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
|
||||
{network?.humans?.map((human) => (
|
||||
<SelectItem key={human.id} value={human.id}>
|
||||
{human.email_prefix}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistItemRow({
|
||||
item,
|
||||
onToggle,
|
||||
onCommitText,
|
||||
onRemove,
|
||||
}: {
|
||||
item: ChecklistItem;
|
||||
onToggle: (checked: boolean) => void;
|
||||
onCommitText: (text: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const textField = useLiveDraftField({
|
||||
remoteValue: item.text,
|
||||
commit: onCommitText,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="group/item flex items-center gap-2.5">
|
||||
<Checkbox
|
||||
checked={item.done}
|
||||
onCheckedChange={(checked) => onToggle(checked === true)}
|
||||
className="border-white/30 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500"
|
||||
aria-label={
|
||||
item.done ? 'Mark subtask as not done' : 'Mark subtask as done'
|
||||
}
|
||||
/>
|
||||
<input
|
||||
value={textField.value}
|
||||
onChange={(e) => textField.onChange(e.target.value)}
|
||||
onFocus={textField.onFocus}
|
||||
onBlur={textField.onBlur}
|
||||
placeholder="Subtask"
|
||||
className={cn(
|
||||
'w-full bg-transparent text-sm text-white/90 outline-none placeholder:text-white/30',
|
||||
item.done && 'text-white/40 line-through',
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="text-white/30 opacity-0 transition-opacity hover:text-white/70 group-hover/item:opacity-100"
|
||||
aria-label="Remove subtask"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddChecklistItemRow({ onAdd }: { onAdd: (text: string) => void }) {
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const submit = () => {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
onAdd(text);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Plus className="size-4 text-white/30" />
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
onBlur={submit}
|
||||
placeholder="Add subtask…"
|
||||
className="w-full bg-transparent text-sm text-white/70 outline-none placeholder:text-white/30"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user