The orderedIds state was initialized at mount time when companies was still an empty array (React Query hadn't resolved yet). When companies data arrived, orderedIds remained empty so the sidebar fell back to API-returned order, ignoring the user's saved drag order. Add a useEffect that re-derives orderedIds from localStorage whenever the companies array changes, ensuring the user's custom sort order persists across data refetches and live update invalidations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
276 lines
8.1 KiB
TypeScript
276 lines
8.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Paperclip, Plus } from "lucide-react";
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from "@dnd-kit/core";
|
|
import {
|
|
SortableContext,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
arrayMove,
|
|
} from "@dnd-kit/sortable";
|
|
import { CSS } from "@dnd-kit/utilities";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useDialog } from "../context/DialogContext";
|
|
import { cn } from "../lib/utils";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import type { Company } from "@paperclip/shared";
|
|
|
|
const COMPANY_COLORS = [
|
|
"#6366f1", // indigo
|
|
"#8b5cf6", // violet
|
|
"#ec4899", // pink
|
|
"#f43f5e", // rose
|
|
"#f97316", // orange
|
|
"#eab308", // yellow
|
|
"#22c55e", // green
|
|
"#14b8a6", // teal
|
|
"#06b6d4", // cyan
|
|
"#3b82f6", // blue
|
|
];
|
|
|
|
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
|
|
|
|
function companyColor(name: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
return COMPANY_COLORS[Math.abs(hash) % COMPANY_COLORS.length]!;
|
|
}
|
|
|
|
function getStoredOrder(): string[] {
|
|
try {
|
|
const raw = localStorage.getItem(ORDER_STORAGE_KEY);
|
|
if (raw) return JSON.parse(raw);
|
|
} catch { /* ignore */ }
|
|
return [];
|
|
}
|
|
|
|
function saveOrder(ids: string[]) {
|
|
localStorage.setItem(ORDER_STORAGE_KEY, JSON.stringify(ids));
|
|
}
|
|
|
|
/** Sort companies by stored order, appending any new ones at the end. */
|
|
function sortByStoredOrder(companies: Company[]): Company[] {
|
|
const order = getStoredOrder();
|
|
if (order.length === 0) return companies;
|
|
|
|
const byId = new Map(companies.map((c) => [c.id, c]));
|
|
const sorted: Company[] = [];
|
|
|
|
for (const id of order) {
|
|
const c = byId.get(id);
|
|
if (c) {
|
|
sorted.push(c);
|
|
byId.delete(id);
|
|
}
|
|
}
|
|
// Append any companies not in stored order
|
|
for (const c of byId.values()) {
|
|
sorted.push(c);
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
function SortableCompanyItem({
|
|
company,
|
|
isSelected,
|
|
onSelect,
|
|
}: {
|
|
company: Company;
|
|
isSelected: boolean;
|
|
onSelect: () => void;
|
|
}) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: company.id });
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
zIndex: isDragging ? 10 : undefined,
|
|
opacity: isDragging ? 0.8 : 1,
|
|
};
|
|
|
|
const color = companyColor(company.name);
|
|
const initial = company.name.charAt(0).toUpperCase();
|
|
|
|
return (
|
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={onSelect}
|
|
className="relative flex items-center justify-center group"
|
|
>
|
|
{/* Selection indicator pill */}
|
|
<div
|
|
className={cn(
|
|
"absolute left-[-14px] w-1 rounded-r-full bg-foreground transition-all duration-200",
|
|
isSelected
|
|
? "h-5"
|
|
: "h-0 group-hover:h-2"
|
|
)}
|
|
/>
|
|
<div
|
|
className={cn(
|
|
"flex items-center justify-center w-11 h-11 text-base font-semibold text-white transition-all duration-200",
|
|
isSelected
|
|
? "rounded-xl"
|
|
: "rounded-[22px] group-hover:rounded-xl",
|
|
isDragging && "shadow-lg scale-105"
|
|
)}
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{initial}
|
|
</div>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
<p>{company.name}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CompanyRail() {
|
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
|
const { openOnboarding } = useDialog();
|
|
|
|
// Maintain sorted order in local state, synced from companies + localStorage
|
|
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
|
sortByStoredOrder(companies).map((c) => c.id)
|
|
);
|
|
|
|
// Re-sync orderedIds from localStorage whenever companies changes.
|
|
// Handles initial data load (companies starts as [] before query resolves)
|
|
// and subsequent refetches triggered by live updates.
|
|
useEffect(() => {
|
|
if (companies.length === 0) return;
|
|
setOrderedIds(sortByStoredOrder(companies).map((c) => c.id));
|
|
}, [companies]);
|
|
|
|
// Sync order across tabs via the native storage event
|
|
useEffect(() => {
|
|
const handleStorage = (e: StorageEvent) => {
|
|
if (e.key !== ORDER_STORAGE_KEY) return;
|
|
try {
|
|
const ids: string[] = e.newValue ? JSON.parse(e.newValue) : [];
|
|
setOrderedIds(ids);
|
|
} catch { /* ignore malformed data */ }
|
|
};
|
|
window.addEventListener("storage", handleStorage);
|
|
return () => window.removeEventListener("storage", handleStorage);
|
|
}, []);
|
|
|
|
// Re-derive when companies change (new company added/removed)
|
|
const orderedCompanies = useMemo(() => {
|
|
const byId = new Map(companies.map((c) => [c.id, c]));
|
|
const result: Company[] = [];
|
|
for (const id of orderedIds) {
|
|
const c = byId.get(id);
|
|
if (c) {
|
|
result.push(c);
|
|
byId.delete(id);
|
|
}
|
|
}
|
|
// Append any new companies not yet in our order
|
|
for (const c of byId.values()) {
|
|
result.push(c);
|
|
}
|
|
return result;
|
|
}, [companies, orderedIds]);
|
|
|
|
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: { distance: 8 },
|
|
})
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
const ids = orderedCompanies.map((c) => c.id);
|
|
const oldIndex = ids.indexOf(active.id as string);
|
|
const newIndex = ids.indexOf(over.id as string);
|
|
if (oldIndex === -1 || newIndex === -1) return;
|
|
|
|
const newIds = arrayMove(ids, oldIndex, newIndex);
|
|
setOrderedIds(newIds);
|
|
saveOrder(newIds);
|
|
},
|
|
[orderedCompanies]
|
|
);
|
|
|
|
return (
|
|
<div className="flex flex-col items-center w-[72px] shrink-0 h-full bg-background border-r border-border">
|
|
{/* Paperclip icon - aligned with top sections (implied line, no visible border) */}
|
|
<div className="flex items-center justify-center h-12 w-full shrink-0">
|
|
<Paperclip className="h-5 w-5 text-foreground" />
|
|
</div>
|
|
|
|
{/* Company list */}
|
|
<div className="flex-1 flex flex-col items-center gap-2 py-2 overflow-y-auto scrollbar-none">
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext
|
|
items={orderedCompanies.map((c) => c.id)}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
{orderedCompanies.map((company) => (
|
|
<SortableCompanyItem
|
|
key={company.id}
|
|
company={company}
|
|
isSelected={company.id === selectedCompanyId}
|
|
onSelect={() => setSelectedCompanyId(company.id)}
|
|
/>
|
|
))}
|
|
</SortableContext>
|
|
</DndContext>
|
|
</div>
|
|
|
|
{/* Separator before add button */}
|
|
<div className="w-8 h-px bg-border mx-auto shrink-0" />
|
|
|
|
{/* Add company button */}
|
|
<div className="flex items-center justify-center py-2 shrink-0">
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => openOnboarding()}
|
|
className="flex items-center justify-center w-11 h-11 rounded-[22px] hover:rounded-xl border-2 border-dashed border-border text-muted-foreground hover:border-foreground/30 hover:text-foreground transition-all duration-200"
|
|
>
|
|
<Plus className="h-5 w-5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="right" sideOffset={8}>
|
|
<p>Add company</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|