2026-02-23 14:41:21 -06:00
|
|
|
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)
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-23 14:43:35 -06:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-02-23 14:41:21 -06:00
|
|
|
// 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>
|
|
|
|
|
);
|
|
|
|
|
}
|