mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
feat: add pin to sidebar for issues and projects (#653)
* feat: add pin to sidebar for issues and projects
Add per-user pinning of issues and projects to the sidebar for quick access.
- New `pinned_item` table with per-user, per-workspace scoping
- REST API: GET/POST /api/pins, DELETE /api/pins/{type}/{id}, PUT /api/pins/reorder
- Sidebar "Pinned" section between Personal and Workspace nav (hidden when empty)
- Pin/unpin actions in issue and project detail dropdown menus
- Optimistic mutations with WebSocket invalidation for real-time sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add drag-and-drop reordering and visible pin buttons
- Sidebar pinned items now support drag-and-drop reordering via @dnd-kit
- Add visible pin/unpin icon button in issue and project detail headers
- Add useReorderPins mutation with optimistic updates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove drag handle and fix page refresh after reorder
- Remove GripVertical drag handle — whole item is now draggable, aligning
with other sidebar elements
- Prevent link navigation after drag using wasDragged ref
- Remove onSettled invalidation from reorder mutation to prevent
unnecessary refetch after optimistic update
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,10 @@ import type {
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
ListProjectsResponse,
|
||||
PinnedItem,
|
||||
CreatePinRequest,
|
||||
PinnedItemType,
|
||||
ReorderPinsRequest,
|
||||
} from "../types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
@@ -693,4 +697,27 @@ export class ApiClient {
|
||||
async deleteProject(id: string): Promise<void> {
|
||||
await this.fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Pins
|
||||
async listPins(): Promise<PinnedItem[]> {
|
||||
return this.fetch("/api/pins");
|
||||
}
|
||||
|
||||
async createPin(data: CreatePinRequest): Promise<PinnedItem> {
|
||||
return this.fetch("/api/pins", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deletePin(itemType: PinnedItemType, itemId: string): Promise<void> {
|
||||
await this.fetch(`/api/pins/${itemType}/${itemId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async reorderPins(data: ReorderPinsRequest): Promise<void> {
|
||||
await this.fetch("/api/pins/reorder", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
"./projects/mutations": "./projects/mutations.ts",
|
||||
"./projects/config": "./projects/config.ts",
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
"./navigation": "./navigation/index.ts",
|
||||
"./modals": "./modals/index.ts",
|
||||
|
||||
2
packages/core/pins/index.ts
Normal file
2
packages/core/pins/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { pinKeys, pinListOptions } from "./queries";
|
||||
export { useCreatePin, useDeletePin, useReorderPins } from "./mutations";
|
||||
65
packages/core/pins/mutations.ts
Normal file
65
packages/core/pins/mutations.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { pinKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { PinnedItem, PinnedItemType } from "../types";
|
||||
|
||||
export function useCreatePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (data: { item_type: PinnedItemType; item_id: string }) =>
|
||||
api.createPin(data),
|
||||
onSuccess: (newPin) => {
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
old ? [...old, newPin] : [newPin],
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePin() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ itemType, itemId }: { itemType: PinnedItemType; itemId: string }) =>
|
||||
api.deletePin(itemType, itemId),
|
||||
onMutate: async ({ itemType, itemId }) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), (old) =>
|
||||
old ? old.filter((p) => !(p.item_type === itemType && p.item_id === itemId)) : old,
|
||||
);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: pinKeys.list(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderPins() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: (reorderedPins: PinnedItem[]) => {
|
||||
const items = reorderedPins.map((p, i) => ({ id: p.id, position: i + 1 }));
|
||||
return api.reorderPins({ items });
|
||||
},
|
||||
onMutate: async (reorderedPins) => {
|
||||
await qc.cancelQueries({ queryKey: pinKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<PinnedItem[]>(pinKeys.list(wsId));
|
||||
qc.setQueryData<PinnedItem[]>(pinKeys.list(wsId), reorderedPins);
|
||||
return { prev };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev) qc.setQueryData(pinKeys.list(wsId), ctx.prev);
|
||||
},
|
||||
});
|
||||
}
|
||||
14
packages/core/pins/queries.ts
Normal file
14
packages/core/pins/queries.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const pinKeys = {
|
||||
all: (wsId: string) => ["pins", wsId] as const,
|
||||
list: (wsId: string) => [...pinKeys.all(wsId), "list"] as const,
|
||||
};
|
||||
|
||||
export function pinListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: pinKeys.list(wsId),
|
||||
queryFn: () => api.listPins(),
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { clearWorkspaceStorage } from "../platform/storage-cleanup";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { projectKeys } from "../projects/queries";
|
||||
import { pinKeys } from "../pins/queries";
|
||||
import { runtimeKeys } from "../runtimes/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
@@ -99,6 +100,10 @@ export function useRealtimeSync(
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
pin: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: pinKeys.all(wsId) });
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = workspaceStore.getState().workspace?.id;
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
|
||||
@@ -50,7 +50,9 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted";
|
||||
| "project:deleted"
|
||||
| "pin:created"
|
||||
| "pin:deleted";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
|
||||
@@ -33,3 +33,4 @@ export type { Attachment } from "./attachment";
|
||||
export type { ChatSession, ChatMessage, SendChatMessageResponse } from "./chat";
|
||||
export type { StorageAdapter } from "./storage";
|
||||
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
|
||||
24
packages/core/types/pin.ts
Normal file
24
packages/core/types/pin.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type PinnedItemType = "issue" | "project";
|
||||
|
||||
export interface PinnedItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
user_id: string;
|
||||
item_type: PinnedItemType;
|
||||
item_id: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
title: string;
|
||||
identifier?: string;
|
||||
icon?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreatePinRequest {
|
||||
item_type: PinnedItemType;
|
||||
item_id: string;
|
||||
}
|
||||
|
||||
export interface ReorderPinsRequest {
|
||||
items: { id: string; position: number }[];
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
PanelRight,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
@@ -77,6 +79,8 @@ import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { pinListOptions } from "@multica/core/pins";
|
||||
import { useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
|
||||
import { ProgressRing } from "./progress-ring";
|
||||
|
||||
@@ -245,6 +249,12 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
// Token usage
|
||||
const { data: usage } = useQuery(issueUsageOptions(id));
|
||||
|
||||
// Pinned state
|
||||
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
|
||||
const isPinned = pinnedItems.some((p) => p.item_type === "issue" && p.item_id === id);
|
||||
const createPin = useCreatePin();
|
||||
const deletePin = useDeletePin();
|
||||
|
||||
// Sub-issue queries
|
||||
const parentIssueId = issue?.parent_issue_id;
|
||||
const { data: parentIssue = null } = useQuery({
|
||||
@@ -462,6 +472,27 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn("text-muted-foreground", isPinned && "text-foreground")}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
deletePin.mutate({ itemType: "issue", itemId: issue.id });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "issue", item_id: issue.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
@@ -596,6 +627,18 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
Create sub-issue
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Pin / Unpin */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
if (isPinned) {
|
||||
deletePin.mutate({ itemType: "issue", itemId: issue.id });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "issue", item_id: issue.id });
|
||||
}
|
||||
}}>
|
||||
{isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Copy link */}
|
||||
<DropdownMenuItem onClick={() => {
|
||||
const url = router.getShareableUrl
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useCallback, useEffect, useRef } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { AppLink, useNavigation } from "../navigation";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Inbox,
|
||||
ListTodo,
|
||||
@@ -18,6 +28,7 @@ import {
|
||||
CircleUser,
|
||||
FolderKanban,
|
||||
Ellipsis,
|
||||
PinOff,
|
||||
} from "lucide-react";
|
||||
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
|
||||
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
|
||||
@@ -52,6 +63,9 @@ import { inboxKeys, deduplicateInboxItems } from "@multica/core/inbox/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useModalStore } from "@multica/core/modals";
|
||||
import { useMyRuntimesNeedUpdate } from "@multica/core/runtimes/hooks";
|
||||
import { pinKeys } from "@multica/core/pins/queries";
|
||||
import { useDeletePin, useReorderPins } from "@multica/core/pins/mutations";
|
||||
import type { PinnedItem } from "@multica/core/types";
|
||||
|
||||
const personalNav = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
@@ -76,6 +90,60 @@ function DraftDot() {
|
||||
return <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />;
|
||||
}
|
||||
|
||||
function SortablePinItem({ pin, pathname, onUnpin }: { pin: PinnedItem; pathname: string; onUnpin: () => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pin.id });
|
||||
const wasDragged = useRef(false);
|
||||
const { push } = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) wasDragged.current = true;
|
||||
}, [isDragging]);
|
||||
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const href = pin.item_type === "issue" ? `/issues/${pin.item_id}` : `/projects/${pin.item_id}`;
|
||||
const isActive = pathname === href;
|
||||
const label = pin.item_type === "issue" && pin.identifier ? `${pin.identifier} ${pin.title}` : pin.title;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn("group/pin", isDragging && "opacity-30")}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => {
|
||||
if (wasDragged.current) {
|
||||
wasDragged.current = false;
|
||||
return;
|
||||
}
|
||||
push(href);
|
||||
}}
|
||||
className="text-muted-foreground hover:not-data-active:bg-sidebar-accent/70 data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground"
|
||||
>
|
||||
{pin.item_type === "issue" ? (
|
||||
<ListTodo className="size-4 shrink-0" />
|
||||
) : (
|
||||
<FolderKanban className="size-4 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{label}</span>
|
||||
<button
|
||||
className="ml-auto opacity-0 group-hover/pin:opacity-100 transition-opacity p-0.5 rounded hover:bg-accent shrink-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onUnpin();
|
||||
}}
|
||||
>
|
||||
<PinOff className="size-3 text-muted-foreground" />
|
||||
</button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
/** Rendered above SidebarHeader (e.g. desktop traffic light spacer) */
|
||||
topSlot?: React.ReactNode;
|
||||
@@ -106,6 +174,26 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
[inboxItems],
|
||||
);
|
||||
const hasRuntimeUpdates = useMyRuntimesNeedUpdate(wsId);
|
||||
const { data: pinnedItems = [] } = useQuery<PinnedItem[]>({
|
||||
queryKey: wsId ? pinKeys.list(wsId) : ["pins", "disabled"],
|
||||
queryFn: () => api.listPins(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
const deletePin = useDeletePin();
|
||||
const reorderPins = useReorderPins();
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = pinnedItems.findIndex((p) => p.id === active.id);
|
||||
const newIndex = pinnedItems.findIndex((p) => p.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const reordered = arrayMove(pinnedItems, oldIndex, newIndex);
|
||||
reorderPins.mutate(reordered);
|
||||
},
|
||||
[pinnedItems, reorderPins],
|
||||
);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const logout = () => {
|
||||
@@ -263,6 +351,28 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{pinnedItems.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Pinned</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={pinnedItems.map((p) => p.id)} strategy={verticalListSortingStrategy}>
|
||||
<SidebarMenu className="gap-0.5">
|
||||
{pinnedItems.map((pin: PinnedItem) => (
|
||||
<SortablePinItem
|
||||
key={pin.id}
|
||||
pin={pin}
|
||||
pathname={pathname}
|
||||
onUnpin={() => deletePin.mutate({ itemType: pin.item_type, itemId: pin.item_id })}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Workspace</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, useRef } from "react";
|
||||
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Trash2, UserMinus } from "lucide-react";
|
||||
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Pin, PinOff, Trash2, UserMinus } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
|
||||
import { projectDetailOptions } from "@multica/core/projects/queries";
|
||||
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
|
||||
import { pinListOptions } from "@multica/core/pins";
|
||||
import { useCreatePin, useDeletePin } from "@multica/core/pins";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
@@ -184,6 +186,10 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
const { getActorName } = useActorName();
|
||||
const updateProject = useUpdateProject();
|
||||
const deleteProject = useDeleteProject();
|
||||
const { data: pinnedItems = [] } = useQuery(pinListOptions(wsId));
|
||||
const isPinned = pinnedItems.some((p) => p.item_type === "project" && p.item_id === projectId);
|
||||
const createPin = useCreatePin();
|
||||
const deletePinMut = useDeletePin();
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false);
|
||||
@@ -256,6 +262,16 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => {
|
||||
if (isPinned) {
|
||||
deletePinMut.mutate({ itemType: "project", itemId: projectId });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "project", item_id: projectId });
|
||||
}
|
||||
}}>
|
||||
{isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||
{isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success("Link copied");
|
||||
@@ -275,6 +291,21 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className={cn("text-muted-foreground", isPinned && "text-foreground")}
|
||||
title={isPinned ? "Unpin from sidebar" : "Pin to sidebar"}
|
||||
onClick={() => {
|
||||
if (isPinned) {
|
||||
deletePinMut.mutate({ itemType: "project", itemId: projectId });
|
||||
} else {
|
||||
createPin.mutate({ item_type: "project", item_id: projectId });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PinOff className="h-4 w-4" /> : <Pin className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
|
||||
@@ -205,6 +205,14 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
|
||||
})
|
||||
})
|
||||
|
||||
// Pins
|
||||
r.Route("/api/pins", func(r chi.Router) {
|
||||
r.Get("/", h.ListPins)
|
||||
r.Post("/", h.CreatePin)
|
||||
r.Put("/reorder", h.ReorderPins)
|
||||
r.Delete("/{itemType}/{itemId}", h.DeletePin)
|
||||
})
|
||||
|
||||
// Attachments
|
||||
r.Get("/api/attachments/{id}", h.GetAttachmentByID)
|
||||
r.Delete("/api/attachments/{id}", h.DeleteAttachment)
|
||||
|
||||
230
server/internal/handler/pin.go
Normal file
230
server/internal/handler/pin.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
)
|
||||
|
||||
type PinnedItemResponse struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID string `json:"item_id"`
|
||||
Position float64 `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// Enriched fields (set by list endpoint)
|
||||
Title string `json:"title"`
|
||||
Identifier *string `json:"identifier,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func pinnedItemToResponse(p db.PinnedItem) PinnedItemResponse {
|
||||
return PinnedItemResponse{
|
||||
ID: uuidToString(p.ID),
|
||||
WorkspaceID: uuidToString(p.WorkspaceID),
|
||||
UserID: uuidToString(p.UserID),
|
||||
ItemType: p.ItemType,
|
||||
ItemID: uuidToString(p.ItemID),
|
||||
Position: p.Position,
|
||||
CreatedAt: timestampToString(p.CreatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
type CreatePinRequest struct {
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID string `json:"item_id"`
|
||||
}
|
||||
|
||||
type ReorderPinsRequest struct {
|
||||
Items []ReorderItem `json:"items"`
|
||||
}
|
||||
|
||||
type ReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
Position float64 `json:"position"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListPins(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
|
||||
pins, err := h.Queries.ListPinnedItems(r.Context(), db.ListPinnedItemsParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list pins")
|
||||
return
|
||||
}
|
||||
|
||||
// Enrich with item details
|
||||
resp := make([]PinnedItemResponse, 0, len(pins))
|
||||
for _, p := range pins {
|
||||
pr := pinnedItemToResponse(p)
|
||||
switch p.ItemType {
|
||||
case "issue":
|
||||
issue, err := h.Queries.GetIssue(r.Context(), p.ItemID)
|
||||
if err != nil {
|
||||
continue // Skip deleted items
|
||||
}
|
||||
pr.Title = issue.Title
|
||||
prefix := h.getIssuePrefix(r.Context(), issue.WorkspaceID)
|
||||
identifier := formatIdentifier(prefix, issue.Number)
|
||||
pr.Identifier = &identifier
|
||||
pr.Status = issue.Status
|
||||
case "project":
|
||||
project, err := h.Queries.GetProject(r.Context(), p.ItemID)
|
||||
if err != nil {
|
||||
continue // Skip deleted items
|
||||
}
|
||||
pr.Title = project.Title
|
||||
pr.Icon = textToPtr(project.Icon)
|
||||
pr.Status = project.Status
|
||||
}
|
||||
resp = append(resp, pr)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) CreatePin(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
|
||||
var req CreatePinRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.ItemType != "issue" && req.ItemType != "project" {
|
||||
writeError(w, http.StatusBadRequest, "item_type must be 'issue' or 'project'")
|
||||
return
|
||||
}
|
||||
if req.ItemID == "" {
|
||||
writeError(w, http.StatusBadRequest, "item_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the item exists in this workspace
|
||||
switch req.ItemType {
|
||||
case "issue":
|
||||
if _, err := h.Queries.GetIssueInWorkspace(r.Context(), db.GetIssueInWorkspaceParams{
|
||||
ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusNotFound, "issue not found")
|
||||
return
|
||||
}
|
||||
case "project":
|
||||
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
|
||||
ID: parseUUID(req.ItemID), WorkspaceID: parseUUID(workspaceID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusNotFound, "project not found")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Get max position to append at end
|
||||
maxPos, err := h.Queries.GetMaxPinnedItemPosition(r.Context(), db.GetMaxPinnedItemPositionParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to get position")
|
||||
return
|
||||
}
|
||||
|
||||
pin, err := h.Queries.CreatePinnedItem(r.Context(), db.CreatePinnedItemParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
ItemType: req.ItemType,
|
||||
ItemID: parseUUID(req.ItemID),
|
||||
Position: maxPos + 1,
|
||||
})
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
writeError(w, http.StatusConflict, "item already pinned")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "failed to create pin")
|
||||
return
|
||||
}
|
||||
|
||||
resp := pinnedItemToResponse(pin)
|
||||
h.publish(protocol.EventPinCreated, workspaceID, "member", userID, map[string]any{"pin": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeletePin(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
itemType := chi.URLParam(r, "itemType")
|
||||
itemID := chi.URLParam(r, "itemId")
|
||||
|
||||
err := h.Queries.DeletePinnedItem(r.Context(), db.DeletePinnedItemParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
ItemType: itemType,
|
||||
ItemID: parseUUID(itemID),
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete pin")
|
||||
return
|
||||
}
|
||||
|
||||
h.publish(protocol.EventPinDeleted, workspaceID, "member", userID, map[string]any{
|
||||
"item_type": itemType,
|
||||
"item_id": itemID,
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) ReorderPins(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := requireUserID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
workspaceID := resolveWorkspaceID(r)
|
||||
|
||||
var req ReorderPinsRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range req.Items {
|
||||
if err := h.Queries.UpdatePinnedItemPosition(r.Context(), db.UpdatePinnedItemPositionParams{
|
||||
Position: item.Position,
|
||||
ID: parseUUID(item.ID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
UserID: parseUUID(userID),
|
||||
}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to reorder pins")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func formatIdentifier(prefix string, number int32) string {
|
||||
if prefix == "" {
|
||||
prefix = "ISS"
|
||||
}
|
||||
return prefix + "-" + strconv.Itoa(int(number))
|
||||
}
|
||||
1
server/migrations/038_pinned_items.down.sql
Normal file
1
server/migrations/038_pinned_items.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS pinned_item;
|
||||
13
server/migrations/038_pinned_items.up.sql
Normal file
13
server/migrations/038_pinned_items.up.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Pinned items: per-user quick-access items in the sidebar
|
||||
CREATE TABLE pinned_item (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL CHECK (item_type IN ('issue', 'project')),
|
||||
item_id UUID NOT NULL,
|
||||
position FLOAT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (workspace_id, user_id, item_type, item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pinned_item_user_ws ON pinned_item (workspace_id, user_id, position);
|
||||
@@ -257,6 +257,16 @@ type PersonalAccessToken struct {
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type PinnedItem struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID pgtype.UUID `json:"item_id"`
|
||||
Position float64 `json:"position"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
|
||||
163
server/pkg/db/generated/pinned_item.sql.go
Normal file
163
server/pkg/db/generated/pinned_item.sql.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: pinned_item.sql
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const createPinnedItem = `-- name: CreatePinnedItem :one
|
||||
INSERT INTO pinned_item (workspace_id, user_id, item_type, item_id, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, workspace_id, user_id, item_type, item_id, position, created_at
|
||||
`
|
||||
|
||||
type CreatePinnedItemParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID pgtype.UUID `json:"item_id"`
|
||||
Position float64 `json:"position"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreatePinnedItem(ctx context.Context, arg CreatePinnedItemParams) (PinnedItem, error) {
|
||||
row := q.db.QueryRow(ctx, createPinnedItem,
|
||||
arg.WorkspaceID,
|
||||
arg.UserID,
|
||||
arg.ItemType,
|
||||
arg.ItemID,
|
||||
arg.Position,
|
||||
)
|
||||
var i PinnedItem
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.ItemType,
|
||||
&i.ItemID,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deletePinnedItem = `-- name: DeletePinnedItem :exec
|
||||
DELETE FROM pinned_item
|
||||
WHERE workspace_id = $1 AND user_id = $2 AND item_type = $3 AND item_id = $4
|
||||
`
|
||||
|
||||
type DeletePinnedItemParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID pgtype.UUID `json:"item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeletePinnedItem(ctx context.Context, arg DeletePinnedItemParams) error {
|
||||
_, err := q.db.Exec(ctx, deletePinnedItem,
|
||||
arg.WorkspaceID,
|
||||
arg.UserID,
|
||||
arg.ItemType,
|
||||
arg.ItemID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deletePinnedItemsByItem = `-- name: DeletePinnedItemsByItem :exec
|
||||
DELETE FROM pinned_item
|
||||
WHERE item_type = $1 AND item_id = $2
|
||||
`
|
||||
|
||||
type DeletePinnedItemsByItemParams struct {
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID pgtype.UUID `json:"item_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeletePinnedItemsByItem(ctx context.Context, arg DeletePinnedItemsByItemParams) error {
|
||||
_, err := q.db.Exec(ctx, deletePinnedItemsByItem, arg.ItemType, arg.ItemID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getMaxPinnedItemPosition = `-- name: GetMaxPinnedItemPosition :one
|
||||
SELECT COALESCE(MAX(position), 0)::float8 AS max_position
|
||||
FROM pinned_item
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetMaxPinnedItemPositionParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetMaxPinnedItemPosition(ctx context.Context, arg GetMaxPinnedItemPositionParams) (float64, error) {
|
||||
row := q.db.QueryRow(ctx, getMaxPinnedItemPosition, arg.WorkspaceID, arg.UserID)
|
||||
var max_position float64
|
||||
err := row.Scan(&max_position)
|
||||
return max_position, err
|
||||
}
|
||||
|
||||
const listPinnedItems = `-- name: ListPinnedItems :many
|
||||
SELECT id, workspace_id, user_id, item_type, item_id, position, created_at FROM pinned_item
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
ORDER BY position ASC, created_at ASC
|
||||
`
|
||||
|
||||
type ListPinnedItemsParams struct {
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListPinnedItems(ctx context.Context, arg ListPinnedItemsParams) ([]PinnedItem, error) {
|
||||
rows, err := q.db.Query(ctx, listPinnedItems, arg.WorkspaceID, arg.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []PinnedItem{}
|
||||
for rows.Next() {
|
||||
var i PinnedItem
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WorkspaceID,
|
||||
&i.UserID,
|
||||
&i.ItemType,
|
||||
&i.ItemID,
|
||||
&i.Position,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updatePinnedItemPosition = `-- name: UpdatePinnedItemPosition :exec
|
||||
UPDATE pinned_item SET position = $1
|
||||
WHERE id = $2 AND workspace_id = $3 AND user_id = $4
|
||||
`
|
||||
|
||||
type UpdatePinnedItemPositionParams struct {
|
||||
Position float64 `json:"position"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
UserID pgtype.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdatePinnedItemPosition(ctx context.Context, arg UpdatePinnedItemPositionParams) error {
|
||||
_, err := q.db.Exec(ctx, updatePinnedItemPosition,
|
||||
arg.Position,
|
||||
arg.ID,
|
||||
arg.WorkspaceID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
26
server/pkg/db/queries/pinned_item.sql
Normal file
26
server/pkg/db/queries/pinned_item.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- name: ListPinnedItems :many
|
||||
SELECT * FROM pinned_item
|
||||
WHERE workspace_id = $1 AND user_id = $2
|
||||
ORDER BY position ASC, created_at ASC;
|
||||
|
||||
-- name: CreatePinnedItem :one
|
||||
INSERT INTO pinned_item (workspace_id, user_id, item_type, item_id, position)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeletePinnedItem :exec
|
||||
DELETE FROM pinned_item
|
||||
WHERE workspace_id = $1 AND user_id = $2 AND item_type = $3 AND item_id = $4;
|
||||
|
||||
-- name: UpdatePinnedItemPosition :exec
|
||||
UPDATE pinned_item SET position = $1
|
||||
WHERE id = $2 AND workspace_id = $3 AND user_id = $4;
|
||||
|
||||
-- name: GetMaxPinnedItemPosition :one
|
||||
SELECT COALESCE(MAX(position), 0)::float8 AS max_position
|
||||
FROM pinned_item
|
||||
WHERE workspace_id = $1 AND user_id = $2;
|
||||
|
||||
-- name: DeletePinnedItemsByItem :exec
|
||||
DELETE FROM pinned_item
|
||||
WHERE item_type = $1 AND item_id = $2;
|
||||
@@ -67,6 +67,10 @@ const (
|
||||
EventProjectUpdated = "project:updated"
|
||||
EventProjectDeleted = "project:deleted"
|
||||
|
||||
// Pin events
|
||||
EventPinCreated = "pin:created"
|
||||
EventPinDeleted = "pin:deleted"
|
||||
|
||||
// Daemon events
|
||||
EventDaemonHeartbeat = "daemon:heartbeat"
|
||||
EventDaemonRegister = "daemon:register"
|
||||
|
||||
Reference in New Issue
Block a user