Files
paperclip/ui/src/components/CompanyRail.tsx
Forgotten b05ac91aa7 fix: re-sync company sidebar order from localStorage on data load
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>
2026-02-23 14:43:35 -06:00

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>
);
}