mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Persist sidebar order preferences
This commit is contained in:
1 parent
84f0ffb9dd
commit
4694a15217
26 files changed
+14004
-145
No files matched your search
@@ -15,5 +15,6 @@ export { dashboardApi } from "./dashboard";
|
||||
export { heartbeatsApi } from "./heartbeats";
|
||||
export { instanceSettingsApi } from "./instanceSettings";
|
||||
export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { sidebarPreferencesApi } from "./sidebarPreferences";
|
||||
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { SidebarOrderPreference, UpsertSidebarOrderPreference } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const sidebarPreferencesApi = {
|
||||
getCompanyOrder: () => api.get<SidebarOrderPreference>("/sidebar-preferences/me"),
|
||||
updateCompanyOrder: (data: UpsertSidebarOrderPreference) =>
|
||||
api.put<SidebarOrderPreference>("/sidebar-preferences/me", data),
|
||||
getProjectOrder: (companyId: string) =>
|
||||
api.get<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`),
|
||||
updateProjectOrder: (companyId: string, data: UpsertSidebarOrderPreference) =>
|
||||
api.put<SidebarOrderPreference>(`/companies/${companyId}/sidebar-preferences/me`, data),
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Paperclip, Plus } from "lucide-react";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -22,6 +22,8 @@ import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { authApi } from "../api/auth";
|
||||
import { useCompanyOrder } from "../hooks/useCompanyOrder";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -31,42 +33,6 @@ import {
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { CompanyPatternIcon } from "./CompanyPatternIcon";
|
||||
|
||||
const ORDER_STORAGE_KEY = "paperclip.companyOrder";
|
||||
|
||||
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,
|
||||
@@ -103,6 +69,10 @@ function SortableCompanyItem({
|
||||
<a
|
||||
href={`/${company.issuePrefix}/dashboard`}
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}}
|
||||
@@ -164,6 +134,11 @@ export function CompanyRail() {
|
||||
() => companies.filter((company) => company.status !== "archived"),
|
||||
[companies],
|
||||
);
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const companyIds = useMemo(() => sidebarCompanies.map((company) => company.id), [sidebarCompanies]);
|
||||
|
||||
const liveRunsQueries = useQueries({
|
||||
@@ -195,52 +170,10 @@ export function CompanyRail() {
|
||||
return result;
|
||||
}, [companyIds, sidebarBadgeQueries]);
|
||||
|
||||
// Maintain sorted order in local state, synced from companies + localStorage
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() =>
|
||||
sortByStoredOrder(sidebarCompanies).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 (sidebarCompanies.length === 0) {
|
||||
setOrderedIds([]);
|
||||
return;
|
||||
}
|
||||
setOrderedIds(sortByStoredOrder(sidebarCompanies).map((c) => c.id));
|
||||
}, [sidebarCompanies]);
|
||||
|
||||
// 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(sidebarCompanies.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;
|
||||
}, [sidebarCompanies, orderedIds]);
|
||||
const { orderedCompanies, persistOrder } = useCompanyOrder({
|
||||
companies: sidebarCompanies,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
// Require 8px of movement before starting a drag to avoid interfering with clicks
|
||||
const sensors = useSensors(
|
||||
@@ -260,11 +193,9 @@ export function CompanyRail() {
|
||||
const newIndex = ids.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
const newIds = arrayMove(ids, oldIndex, newIndex);
|
||||
setOrderedIds(newIds);
|
||||
saveOrder(newIds);
|
||||
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||
},
|
||||
[orderedCompanies]
|
||||
[orderedCompanies, persistOrder]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -76,7 +76,11 @@ function SortableProjectItem({
|
||||
<NavLink
|
||||
to={`/projects/${routeRef}/issues`}
|
||||
state={SIDEBAR_SCROLL_RESET_STATE}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
function areEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortCompaniesByOrder(companies: Company[], orderedIds: string[]): Company[] {
|
||||
if (companies.length === 0) return [];
|
||||
if (orderedIds.length === 0) return companies;
|
||||
|
||||
const byId = new Map(companies.map((company) => [company.id, company]));
|
||||
const sorted: Company[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const company = byId.get(id);
|
||||
if (!company) continue;
|
||||
sorted.push(company);
|
||||
byId.delete(id);
|
||||
}
|
||||
for (const company of byId.values()) {
|
||||
sorted.push(company);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function buildOrderIds(companies: Company[], orderedIds: string[]) {
|
||||
return sortCompaniesByOrder(companies, orderedIds).map((company) => company.id);
|
||||
}
|
||||
|
||||
type UseCompanyOrderParams = {
|
||||
companies: Company[];
|
||||
userId: string | null | undefined;
|
||||
};
|
||||
|
||||
export function useCompanyOrder({ companies, userId }: UseCompanyOrderParams) {
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(
|
||||
() => queryKeys.sidebarPreferences.companyOrder(userId ?? "__anon__"),
|
||||
[userId],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => sidebarPreferencesApi.getCompanyOrder(),
|
||||
enabled: Boolean(userId),
|
||||
});
|
||||
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => buildOrderIds(companies, []));
|
||||
|
||||
useEffect(() => {
|
||||
const nextIds = buildOrderIds(companies, data?.orderedIds ?? []);
|
||||
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||
}, [companies, data?.orderedIds]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateCompanyOrder({ orderedIds: nextIds }),
|
||||
onSuccess: (preference) => {
|
||||
queryClient.setQueryData(queryKey, preference);
|
||||
},
|
||||
});
|
||||
|
||||
const orderedCompanies = useMemo(
|
||||
() => sortCompaniesByOrder(companies, orderedIds),
|
||||
[companies, orderedIds],
|
||||
);
|
||||
|
||||
const persistOrder = useCallback(
|
||||
(ids: string[]) => {
|
||||
const idSet = new Set(companies.map((company) => company.id));
|
||||
const filtered = ids.filter((id) => idSet.has(id));
|
||||
for (const company of companies) {
|
||||
if (!filtered.includes(company.id)) filtered.push(company.id);
|
||||
}
|
||||
|
||||
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
|
||||
if (!userId) return;
|
||||
|
||||
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
|
||||
orderedIds: filtered,
|
||||
updatedAt: current?.updatedAt ?? null,
|
||||
}));
|
||||
mutation.mutate(filtered);
|
||||
},
|
||||
[companies, mutation, queryClient, queryKey, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
orderedCompanies,
|
||||
orderedIds,
|
||||
persistOrder,
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import {
|
||||
getProjectOrderStorageKey,
|
||||
PROJECT_ORDER_UPDATED_EVENT,
|
||||
readProjectOrder,
|
||||
sortProjectsByStoredOrder,
|
||||
writeProjectOrder,
|
||||
} from "../lib/project-order";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
import { sortProjectsByStoredOrder } from "../lib/project-order";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
type UseProjectOrderParams = {
|
||||
projects: Project[];
|
||||
@@ -14,11 +11,6 @@ type UseProjectOrderParams = {
|
||||
userId: string | null | undefined;
|
||||
};
|
||||
|
||||
type ProjectOrderUpdatedDetail = {
|
||||
storageKey: string;
|
||||
orderedIds: string[];
|
||||
};
|
||||
|
||||
function areEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
@@ -32,48 +24,33 @@ function buildOrderIds(projects: Project[], orderedIds: string[]) {
|
||||
}
|
||||
|
||||
export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) {
|
||||
const storageKey = useMemo(() => {
|
||||
if (!companyId) return null;
|
||||
return getProjectOrderStorageKey(companyId, userId);
|
||||
}, [companyId, userId]);
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = useMemo(
|
||||
() => queryKeys.sidebarPreferences.projectOrder(companyId ?? "__none__", userId ?? "__anon__"),
|
||||
[companyId, userId],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => sidebarPreferencesApi.getProjectOrder(companyId!),
|
||||
enabled: Boolean(companyId && userId),
|
||||
});
|
||||
|
||||
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||
if (!storageKey) return projects.map((project) => project.id);
|
||||
return buildOrderIds(projects, readProjectOrder(storageKey));
|
||||
return buildOrderIds(projects, []);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nextIds = storageKey
|
||||
? buildOrderIds(projects, readProjectOrder(storageKey))
|
||||
: projects.map((project) => project.id);
|
||||
const nextIds = buildOrderIds(projects, data?.orderedIds ?? []);
|
||||
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||
}, [projects, storageKey]);
|
||||
}, [data?.orderedIds, projects]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!storageKey) return;
|
||||
|
||||
const syncFromIds = (ids: string[]) => {
|
||||
const nextIds = buildOrderIds(projects, ids);
|
||||
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||
};
|
||||
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== storageKey) return;
|
||||
syncFromIds(readProjectOrder(storageKey));
|
||||
};
|
||||
const onCustomEvent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ProjectOrderUpdatedDetail>).detail;
|
||||
if (!detail || detail.storageKey !== storageKey) return;
|
||||
syncFromIds(detail.orderedIds);
|
||||
};
|
||||
|
||||
window.addEventListener("storage", onStorage);
|
||||
window.addEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||
return () => {
|
||||
window.removeEventListener("storage", onStorage);
|
||||
window.removeEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||
};
|
||||
}, [projects, storageKey]);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (nextIds: string[]) => sidebarPreferencesApi.updateProjectOrder(companyId!, { orderedIds: nextIds }),
|
||||
onSuccess: (preference) => {
|
||||
queryClient.setQueryData(queryKey, preference);
|
||||
},
|
||||
});
|
||||
|
||||
const orderedProjects = useMemo(
|
||||
() => sortProjectsByStoredOrder(projects, orderedIds),
|
||||
@@ -89,11 +66,15 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
|
||||
}
|
||||
|
||||
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
|
||||
if (storageKey) {
|
||||
writeProjectOrder(storageKey, filtered);
|
||||
}
|
||||
if (!companyId || !userId) return;
|
||||
|
||||
queryClient.setQueryData(queryKey, (current: { orderedIds?: string[]; updatedAt?: Date | null } | undefined) => ({
|
||||
orderedIds: filtered,
|
||||
updatedAt: current?.updatedAt ?? null,
|
||||
}));
|
||||
mutation.mutate(filtered);
|
||||
},
|
||||
[projects, storageKey],
|
||||
[companyId, mutation, projects, queryClient, queryKey, userId],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -102,4 +83,3 @@ export function useProjectOrder({ projects, companyId, userId }: UseProjectOrder
|
||||
persistOrder,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ export const queryKeys = {
|
||||
auth: {
|
||||
session: ["auth", "session"] as const,
|
||||
},
|
||||
sidebarPreferences: {
|
||||
companyOrder: (userId: string) => ["sidebar-preferences", "company-order", userId] as const,
|
||||
projectOrder: (companyId: string, userId: string) =>
|
||||
["sidebar-preferences", "project-order", companyId, userId] as const,
|
||||
},
|
||||
instance: {
|
||||
generalSettings: ["instance", "general-settings"] as const,
|
||||
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||
|
||||
@@ -13,9 +13,9 @@ import { useToast } from "../context/ToastContext";
|
||||
import { authApi } from "../api/auth";
|
||||
import { companiesApi } from "../api/companies";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { sidebarPreferencesApi } from "../api/sidebarPreferences";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { getAgentOrderStorageKey, writeAgentOrder } from "../lib/agent-order";
|
||||
import { getProjectOrderStorageKey, writeProjectOrder } from "../lib/project-order";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
@@ -346,7 +346,7 @@ function prefixedName(prefix: string | null, originalName: string): string {
|
||||
return `${prefix}-${originalName}`;
|
||||
}
|
||||
|
||||
function applyImportedSidebarOrder(
|
||||
async function applyImportedSidebarOrder(
|
||||
preview: CompanyPortabilityPreviewResult | null,
|
||||
result: {
|
||||
company: { id: string };
|
||||
@@ -381,7 +381,7 @@ function applyImportedSidebarOrder(
|
||||
writeAgentOrder(getAgentOrderStorageKey(result.company.id, userId), orderedAgentIds);
|
||||
}
|
||||
if (orderedProjectIds.length > 0) {
|
||||
writeProjectOrder(getProjectOrderStorageKey(result.company.id, userId), orderedProjectIds);
|
||||
await sidebarPreferencesApi.updateProjectOrder(result.company.id, { orderedIds: orderedProjectIds });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -859,7 +859,7 @@ export function CompanyImport() {
|
||||
?? refreshedSession?.user?.id
|
||||
?? refreshedSession?.session?.userId
|
||||
?? null;
|
||||
applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
|
||||
await applyImportedSidebarOrder(importPreview, result, sidebarOrderUserId);
|
||||
setSelectedCompanyId(importedCompany.id);
|
||||
pushToast({
|
||||
tone: "success",
|
||||
|
||||
Reference in new issue
Block a user