mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add user-defined channel sections to sidebar (#789)
This commit is contained in:
@@ -21,6 +21,9 @@
|
||||
"tauri:build": "tauri build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
||||
@@ -39,14 +39,15 @@ const overrides = new Map([
|
||||
["src/features/channels/ui/ChannelManagementSheet.tsx", 800],
|
||||
["src/features/channels/ui/ChannelPane.tsx", 540], // composer/timeline/sidebar orchestration + anchored agent activity footers + imetaMedia threading on editTarget + thread follow props passthrough
|
||||
["src/features/channels/ui/ChannelScreen.tsx", 580], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + imetaMedia projection on editTarget + thread follow wiring from AppShell context
|
||||
["src/features/channels/useUnreadChannels.ts", 715], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks
|
||||
["src/features/channels/useUnreadChannels.ts", 717], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread)
|
||||
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
|
||||
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
|
||||
["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates
|
||||
["src/features/messages/lib/useRichTextEditor.ts", 560], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
|
||||
["src/features/messages/ui/MessageComposer.tsx", 820], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them
|
||||
["src/features/settings/ui/SettingsView.tsx", 600],
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
|
||||
["src/features/sidebar/ui/AppSidebar.tsx", 830], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + controlled create-channel dialog for ⌘⇧N shortcut
|
||||
["src/features/sidebar/ui/CustomChannelSection.tsx", 615], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read + DnD wrappers (SortableSectionShell, DraggableChannelRow, DroppableSectionBody, DroppableUngroupedBody) + draggable prop on ChannelGroupSection
|
||||
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
|
||||
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
|
||||
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
|
||||
|
||||
@@ -101,6 +101,7 @@ export class ReadStateManager {
|
||||
}
|
||||
|
||||
markContextRead(contextId: string, unixTimestamp: number): void {
|
||||
this.forcedContexts.delete(contextId);
|
||||
this.advanceContext(contextId, unixTimestamp, { publishable: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -296,7 +296,10 @@ export function useUnreadChannels(
|
||||
|
||||
const markChannelRead = React.useCallback(
|
||||
(channelId: string, readAt: string | null | undefined) => {
|
||||
const unixSeconds = toUnixSeconds(readAt);
|
||||
const unixSeconds =
|
||||
toUnixSeconds(readAt) ??
|
||||
latestByChannelRef.current.get(channelId) ??
|
||||
null;
|
||||
if (unixSeconds === null) return;
|
||||
// Reading clears any prior manual mark-unread.
|
||||
if (forcedUnreadRef.current.delete(channelId)) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
const STORAGE_KEY_PREFIX = "sprout-channel-sections.v1";
|
||||
|
||||
export type ChannelSection = {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type ChannelSectionStore = {
|
||||
version: 1;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
};
|
||||
|
||||
export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
|
||||
version: 1,
|
||||
sections: [],
|
||||
assignments: {},
|
||||
});
|
||||
|
||||
export function storageKey(pubkey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${pubkey}`;
|
||||
}
|
||||
|
||||
export function stripOrphanedAssignments(
|
||||
store: ChannelSectionStore,
|
||||
): ChannelSectionStore {
|
||||
const sectionIds = new Set(store.sections.map((s) => s.id));
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(store.assignments).filter(([, sid]) => sectionIds.has(sid)),
|
||||
);
|
||||
if (Object.keys(cleaned).length === Object.keys(store.assignments).length)
|
||||
return store;
|
||||
return { ...store, assignments: cleaned };
|
||||
}
|
||||
|
||||
export function parseChannelSectionPayload(
|
||||
json: unknown,
|
||||
): ChannelSectionStore | null {
|
||||
if (typeof json !== "object" || json === null) return null;
|
||||
const obj = json as Record<string, unknown>;
|
||||
const sections: ChannelSection[] = Array.isArray(obj.sections)
|
||||
? obj.sections.filter(
|
||||
(entry: unknown): entry is ChannelSection =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
typeof (entry as Record<string, unknown>).id === "string" &&
|
||||
typeof (entry as Record<string, unknown>).name === "string" &&
|
||||
typeof (entry as Record<string, unknown>).order === "number",
|
||||
)
|
||||
: [];
|
||||
const assignments: Record<string, string> =
|
||||
typeof obj.assignments === "object" &&
|
||||
obj.assignments !== null &&
|
||||
!Array.isArray(obj.assignments)
|
||||
? Object.fromEntries(
|
||||
Object.entries(obj.assignments as Record<string, unknown>).filter(
|
||||
(entry): entry is [string, string] => typeof entry[1] === "string",
|
||||
),
|
||||
)
|
||||
: {};
|
||||
return stripOrphanedAssignments({ version: 1, sections, assignments });
|
||||
}
|
||||
|
||||
export function readChannelSectionsStore(pubkey: string): ChannelSectionStore {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(pubkey));
|
||||
if (!raw) {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || parsed.version !== 1) {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
return parseChannelSectionPayload(parsed) ?? DEFAULT_STORE;
|
||||
} catch {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeChannelSectionsStore(
|
||||
pubkey: string,
|
||||
store: ChannelSectionStore,
|
||||
): boolean {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(pubkey), JSON.stringify(store));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_STORE,
|
||||
readChannelSectionsStore,
|
||||
storageKey,
|
||||
writeChannelSectionsStore,
|
||||
} from "./channelSectionsStorage";
|
||||
|
||||
export type { ChannelSection } from "./channelSectionsStorage";
|
||||
|
||||
import type {
|
||||
ChannelSection,
|
||||
ChannelSectionStore,
|
||||
} from "./channelSectionsStorage";
|
||||
|
||||
export function useChannelSections(pubkey: string | undefined): {
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
createSection: (name: string) => ChannelSection | null;
|
||||
renameSection: (sectionId: string, newName: string) => void;
|
||||
deleteSection: (sectionId: string) => void;
|
||||
moveSectionUp: (sectionId: string) => void;
|
||||
moveSectionDown: (sectionId: string) => void;
|
||||
reorderSections: (orderedIds: string[]) => void;
|
||||
assignChannel: (channelId: string, sectionId: string) => void;
|
||||
unassignChannel: (channelId: string) => void;
|
||||
} {
|
||||
const [store, setStore] = React.useState<ChannelSectionStore>(() => {
|
||||
if (!pubkey) {
|
||||
return DEFAULT_STORE;
|
||||
}
|
||||
return readChannelSectionsStore(pubkey);
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) {
|
||||
setStore(DEFAULT_STORE);
|
||||
return;
|
||||
}
|
||||
setStore(readChannelSectionsStore(pubkey));
|
||||
}, [pubkey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
const key = storageKey(pubkey);
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== key) {
|
||||
return;
|
||||
}
|
||||
setStore(readChannelSectionsStore(pubkey));
|
||||
};
|
||||
window.addEventListener("storage", handler);
|
||||
return () => {
|
||||
window.removeEventListener("storage", handler);
|
||||
};
|
||||
}, [pubkey]);
|
||||
|
||||
const sections = React.useMemo<ChannelSection[]>(
|
||||
() => store.sections.slice().sort((a, b) => a.order - b.order),
|
||||
[store.sections],
|
||||
);
|
||||
|
||||
const createSection = React.useCallback(
|
||||
(name: string): ChannelSection | null => {
|
||||
if (!pubkey) {
|
||||
return null;
|
||||
}
|
||||
let created: ChannelSection | null = null;
|
||||
setStore((prev) => {
|
||||
const maxOrder =
|
||||
prev.sections.length > 0
|
||||
? Math.max(...prev.sections.map((s) => s.order))
|
||||
: -1;
|
||||
const section: ChannelSection = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
order: maxOrder + 1,
|
||||
};
|
||||
const next: ChannelSectionStore = {
|
||||
...prev,
|
||||
sections: [...prev.sections, section],
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
created = section;
|
||||
return next;
|
||||
});
|
||||
return created;
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const renameSection = React.useCallback(
|
||||
(sectionId: string, newName: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const next: ChannelSectionStore = {
|
||||
...prev,
|
||||
sections: prev.sections.map((s) =>
|
||||
s.id === sectionId ? { ...s, name: newName } : s,
|
||||
),
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const deleteSection = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
for (const channelId of Object.keys(assignments)) {
|
||||
if (assignments[channelId] === sectionId) {
|
||||
delete assignments[channelId];
|
||||
}
|
||||
}
|
||||
const next: ChannelSectionStore = {
|
||||
...prev,
|
||||
sections: prev.sections.filter((s) => s.id !== sectionId),
|
||||
assignments,
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const moveSectionUp = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const target = prev.sections.find((s) => s.id === sectionId);
|
||||
if (!target) {
|
||||
return prev;
|
||||
}
|
||||
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((s) => s.id === sectionId);
|
||||
if (idx <= 0) {
|
||||
return prev;
|
||||
}
|
||||
const neighbor = sorted[idx - 1];
|
||||
const sections = prev.sections.map((s) => {
|
||||
if (s.id === target.id) {
|
||||
return { ...s, order: neighbor.order };
|
||||
}
|
||||
if (s.id === neighbor.id) {
|
||||
return { ...s, order: target.order };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const moveSectionDown = React.useCallback(
|
||||
(sectionId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const target = prev.sections.find((s) => s.id === sectionId);
|
||||
if (!target) {
|
||||
return prev;
|
||||
}
|
||||
const sorted = prev.sections.slice().sort((a, b) => a.order - b.order);
|
||||
const idx = sorted.findIndex((s) => s.id === sectionId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) {
|
||||
return prev;
|
||||
}
|
||||
const neighbor = sorted[idx + 1];
|
||||
const sections = prev.sections.map((s) => {
|
||||
if (s.id === target.id) {
|
||||
return { ...s, order: neighbor.order };
|
||||
}
|
||||
if (s.id === neighbor.id) {
|
||||
return { ...s, order: target.order };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const reorderSections = React.useCallback(
|
||||
(orderedIds: string[]) => {
|
||||
if (!pubkey) return;
|
||||
setStore((prev) => {
|
||||
const sections = prev.sections.map((s) => {
|
||||
const newOrder = orderedIds.indexOf(s.id);
|
||||
return newOrder === -1 ? s : { ...s, order: newOrder };
|
||||
});
|
||||
const next: ChannelSectionStore = { ...prev, sections };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) return prev;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const assignChannel = React.useCallback(
|
||||
(channelId: string, sectionId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const next: ChannelSectionStore = {
|
||||
...prev,
|
||||
assignments: { ...prev.assignments, [channelId]: sectionId },
|
||||
};
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
const unassignChannel = React.useCallback(
|
||||
(channelId: string) => {
|
||||
if (!pubkey) {
|
||||
return;
|
||||
}
|
||||
setStore((prev) => {
|
||||
const assignments = { ...prev.assignments };
|
||||
delete assignments[channelId];
|
||||
const next: ChannelSectionStore = { ...prev, assignments };
|
||||
if (!writeChannelSectionsStore(pubkey, next)) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
return {
|
||||
sections,
|
||||
assignments: store.assignments,
|
||||
createSection,
|
||||
renameSection,
|
||||
deleteSection,
|
||||
moveSectionUp,
|
||||
moveSectionDown,
|
||||
reorderSections,
|
||||
assignChannel,
|
||||
unassignChannel,
|
||||
};
|
||||
}
|
||||
@@ -4,18 +4,13 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Bot,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
FolderGit2,
|
||||
Home,
|
||||
PenSquare,
|
||||
Plus,
|
||||
Search,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
||||
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
@@ -26,14 +21,25 @@ import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
|
||||
import {
|
||||
useChannelSections,
|
||||
type ChannelSection,
|
||||
} from "@/features/sidebar/lib/useChannelSections";
|
||||
import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata";
|
||||
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
|
||||
import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow";
|
||||
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
|
||||
import {
|
||||
ChannelMenuButton,
|
||||
SidebarSection,
|
||||
} from "@/features/sidebar/ui/SidebarSection";
|
||||
CreateSectionDialog,
|
||||
DeleteSectionAlertDialog,
|
||||
RenameSectionDialog,
|
||||
} from "@/features/sidebar/ui/ChannelSectionDialogs";
|
||||
import { MoreUnreadButton } from "@/features/sidebar/ui/MoreUnreadButton";
|
||||
import { SidebarSection } from "@/features/sidebar/ui/SidebarSection";
|
||||
import {
|
||||
ChannelGroupSection,
|
||||
CustomChannelSection,
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
} from "@/features/sidebar/ui/CustomChannelSection";
|
||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||
import type {
|
||||
@@ -44,12 +50,6 @@ import type {
|
||||
UserStatus,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -66,19 +66,6 @@ import {
|
||||
SidebarMenuSkeleton,
|
||||
} from "@/shared/ui/sidebar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SECTION_ICON_BUTTON_CLASS =
|
||||
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
|
||||
const SECTION_ACTION_VISIBILITY_CLASS =
|
||||
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
|
||||
const SECTION_LABEL_BUTTON_CLASS =
|
||||
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
|
||||
const SECTION_LABEL_CHEVRON_CLASS =
|
||||
"h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
|
||||
|
||||
type CollapsibleSidebarGroup = "channels" | "forums" | "directMessages";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -165,202 +152,6 @@ type AppSidebarProps = {
|
||||
onCreateChannelOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SectionHeaderActions — browse + create icon buttons for section headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeaderActions({
|
||||
browseAriaLabel,
|
||||
browseTestId,
|
||||
className,
|
||||
createAriaLabel,
|
||||
hasUnread,
|
||||
onBrowse,
|
||||
onCreateClick,
|
||||
onMarkAllRead,
|
||||
}: {
|
||||
browseAriaLabel: string;
|
||||
browseTestId?: string;
|
||||
className?: string;
|
||||
createAriaLabel: string;
|
||||
hasUnread?: boolean;
|
||||
onBrowse: () => void;
|
||||
onCreateClick: () => void;
|
||||
onMarkAllRead?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasUnread && onMarkAllRead ? (
|
||||
<button
|
||||
aria-label="Mark all as read"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={onMarkAllRead}
|
||||
title="Mark all as read"
|
||||
type="button"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label={browseAriaLabel}
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
data-testid={browseTestId}
|
||||
onClick={onBrowse}
|
||||
type="button"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={createAriaLabel}
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={onCreateClick}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChannelGroupSection — unified Channels / Forums section (no inline form)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ChannelGroupSection({
|
||||
browseAriaLabel,
|
||||
browseTestId,
|
||||
createAriaLabel,
|
||||
groupClassName,
|
||||
hasUnread,
|
||||
isCollapsed,
|
||||
isActiveChannel,
|
||||
items,
|
||||
listTestId,
|
||||
onBrowse,
|
||||
onCreateClick,
|
||||
onMarkAllRead,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onSelectChannel,
|
||||
onToggleCollapsed,
|
||||
selectedChannelId,
|
||||
title,
|
||||
unreadChannelIds,
|
||||
}: {
|
||||
browseAriaLabel: string;
|
||||
browseTestId?: string;
|
||||
createAriaLabel: string;
|
||||
groupClassName?: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveChannel: boolean;
|
||||
items: Channel[];
|
||||
listTestId: string;
|
||||
onBrowse: () => void;
|
||||
onCreateClick: () => void;
|
||||
onMarkChannelRead: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkChannelUnread: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
selectedChannelId: string | null;
|
||||
title: string;
|
||||
unreadChannelIds: Set<string>;
|
||||
hasUnread?: boolean;
|
||||
onMarkAllRead?: () => void;
|
||||
}) {
|
||||
const contentId = `sidebar-${listTestId}`;
|
||||
|
||||
return (
|
||||
<SidebarGroup className={groupClassName}>
|
||||
<div className="group/sidebar-section relative">
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={SECTION_LABEL_BUTTON_CLASS}
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
<SectionHeaderActions
|
||||
browseAriaLabel={browseAriaLabel}
|
||||
browseTestId={browseTestId}
|
||||
className={SECTION_ACTION_VISIBILITY_CLASS}
|
||||
createAriaLabel={createAriaLabel}
|
||||
hasUnread={hasUnread}
|
||||
onBrowse={onBrowse}
|
||||
onCreateClick={onCreateClick}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
/>
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<SidebarGroupContent id={contentId}>
|
||||
{items.length > 0 ? (
|
||||
<SidebarMenu data-testid={listTestId}>
|
||||
{items.map((channel) => (
|
||||
<ContextMenu key={channel.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel && selectedChannelId === channel.id
|
||||
}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{unreadChannelIds.has(channel.id) ? (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
onMarkChannelRead(channel.id, channel.lastMessageAt)
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Mark as read
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
onClick={() =>
|
||||
onMarkChannelUnread(channel.id, channel.lastMessageAt)
|
||||
}
|
||||
>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Mark unread
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
) : null}
|
||||
</SidebarGroupContent>
|
||||
) : null}
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AppSidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -453,10 +244,88 @@ export function AppSidebar({
|
||||
[],
|
||||
);
|
||||
|
||||
const [collapsedSections, setCollapsedSections] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const toggleCollapsedSection = React.useCallback((sectionId: string) => {
|
||||
setCollapsedSections((current) => ({
|
||||
...current,
|
||||
[sectionId]: !current[sectionId],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const {
|
||||
sections: channelSections,
|
||||
assignments: channelAssignments,
|
||||
createSection,
|
||||
renameSection,
|
||||
deleteSection,
|
||||
moveSectionUp,
|
||||
moveSectionDown,
|
||||
reorderSections,
|
||||
assignChannel,
|
||||
unassignChannel,
|
||||
} = useChannelSections(currentPubkey);
|
||||
|
||||
const [createSectionState, setCreateSectionState] = React.useState<{
|
||||
open: boolean;
|
||||
pendingChannelId: string | null;
|
||||
}>({ open: false, pendingChannelId: null });
|
||||
const [renameSectionTarget, setRenameSectionTarget] =
|
||||
React.useState<ChannelSection | null>(null);
|
||||
const [deleteSectionTarget, setDeleteSectionTarget] =
|
||||
React.useState<ChannelSection | null>(null);
|
||||
|
||||
const sectionIds = React.useMemo(
|
||||
() => channelSections.map((s) => s.id),
|
||||
[channelSections],
|
||||
);
|
||||
|
||||
const streamChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "stream"),
|
||||
[channels],
|
||||
);
|
||||
|
||||
const sectionBuckets = React.useMemo(() => {
|
||||
const bySection: Record<string, Channel[]> = {};
|
||||
const unassigned: Channel[] = [];
|
||||
const sectionIds = new Set(channelSections.map((s) => s.id));
|
||||
|
||||
for (const channel of streamChannels) {
|
||||
const sectionId = channelAssignments[channel.id];
|
||||
if (sectionId && sectionIds.has(sectionId)) {
|
||||
if (!bySection[sectionId]) {
|
||||
bySection[sectionId] = [];
|
||||
}
|
||||
bySection[sectionId].push(channel);
|
||||
} else {
|
||||
unassigned.push(channel);
|
||||
}
|
||||
}
|
||||
return { bySection, unassigned };
|
||||
}, [streamChannels, channelSections, channelAssignments]);
|
||||
|
||||
const handleCreateSectionForChannel = React.useCallback(
|
||||
(channelId: string) => {
|
||||
setCreateSectionState({ open: true, pendingChannelId: channelId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreateSectionConfirm = React.useCallback(
|
||||
(name: string) => {
|
||||
const section = createSection(name);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
if (createSectionState.pendingChannelId) {
|
||||
assignChannel(createSectionState.pendingChannelId, section.id);
|
||||
}
|
||||
setCreateSectionState({ open: false, pendingChannelId: null });
|
||||
},
|
||||
[createSection, assignChannel, createSectionState.pendingChannelId],
|
||||
);
|
||||
|
||||
const forumChannels = React.useMemo(
|
||||
() => channels.filter((channel) => channel.channelType === "forum"),
|
||||
[channels],
|
||||
@@ -641,27 +510,82 @@ export function AppSidebar({
|
||||
|
||||
{!isLoading ? (
|
||||
<>
|
||||
<ChannelGroupSection
|
||||
browseAriaLabel="Browse channels"
|
||||
browseTestId="browse-channels"
|
||||
createAriaLabel="Create a channel"
|
||||
groupClassName="pt-1"
|
||||
hasUnread={unreadChannelIds.size > 0}
|
||||
isCollapsed={collapsedGroups.channels}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
items={streamChannels}
|
||||
listTestId="stream-list"
|
||||
onBrowse={onOpenBrowseChannels}
|
||||
onCreateClick={() => setCreateDialogKind("stream")}
|
||||
onMarkAllRead={onMarkAllChannelsRead}
|
||||
onMarkChannelRead={onMarkChannelRead}
|
||||
onMarkChannelUnread={onMarkChannelUnread}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Channels"
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
<SidebarDndContext
|
||||
channels={channels}
|
||||
sections={channelSections}
|
||||
sectionIds={sectionIds}
|
||||
onAssignChannel={assignChannel}
|
||||
onUnassignChannel={unassignChannel}
|
||||
onReorderSections={reorderSections}
|
||||
>
|
||||
{channelSections.map((section, idx) => (
|
||||
<CustomChannelSection
|
||||
key={section.id}
|
||||
section={section}
|
||||
channels={sectionBuckets.bySection[section.id] ?? []}
|
||||
hasUnread={
|
||||
sectionBuckets.bySection[section.id]?.some((c) =>
|
||||
unreadChannelIds.has(c.id),
|
||||
) ?? false
|
||||
}
|
||||
isCollapsed={collapsedSections[section.id] ?? false}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
selectedChannelId={selectedChannelId}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
sections={channelSections}
|
||||
assignments={channelAssignments}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === channelSections.length - 1}
|
||||
onToggleCollapsed={() => toggleCollapsedSection(section.id)}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onMarkChannelRead={onMarkChannelRead}
|
||||
onMarkChannelUnread={onMarkChannelUnread}
|
||||
onMarkSectionRead={() => {
|
||||
for (const channel of sectionBuckets.bySection[
|
||||
section.id
|
||||
] ?? []) {
|
||||
onMarkChannelRead(channel.id, channel.lastMessageAt);
|
||||
}
|
||||
}}
|
||||
onAssignChannel={assignChannel}
|
||||
onUnassignChannel={unassignChannel}
|
||||
onCreateSectionForChannel={handleCreateSectionForChannel}
|
||||
onRenameSection={() => setRenameSectionTarget(section)}
|
||||
onDeleteSection={() => setDeleteSectionTarget(section)}
|
||||
onMoveSectionUp={() => moveSectionUp(section.id)}
|
||||
onMoveSectionDown={() => moveSectionDown(section.id)}
|
||||
/>
|
||||
))}
|
||||
<ChannelGroupSection
|
||||
browseAriaLabel="Browse channels"
|
||||
browseTestId="browse-channels"
|
||||
createAriaLabel="Create a channel"
|
||||
draggable
|
||||
groupClassName={
|
||||
channelSections.length > 0 ? undefined : "pt-1"
|
||||
}
|
||||
hasUnread={unreadChannelIds.size > 0}
|
||||
isCollapsed={collapsedGroups.channels}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
items={sectionBuckets.unassigned}
|
||||
listTestId="stream-list"
|
||||
onBrowse={onOpenBrowseChannels}
|
||||
onCreateClick={() => setCreateDialogKind("stream")}
|
||||
onMarkAllRead={onMarkAllChannelsRead}
|
||||
onMarkChannelRead={onMarkChannelRead}
|
||||
onMarkChannelUnread={onMarkChannelUnread}
|
||||
onSelectChannel={onSelectChannel}
|
||||
onToggleCollapsed={() => toggleCollapsedGroup("channels")}
|
||||
selectedChannelId={selectedChannelId}
|
||||
title="Channels"
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
sections={channelSections}
|
||||
assignments={channelAssignments}
|
||||
onAssignChannel={assignChannel}
|
||||
onUnassignChannel={unassignChannel}
|
||||
onCreateSectionForChannel={handleCreateSectionForChannel}
|
||||
/>
|
||||
</SidebarDndContext>
|
||||
<ChannelGroupSection
|
||||
browseAriaLabel="Browse forums"
|
||||
browseTestId="browse-forums"
|
||||
@@ -848,6 +772,54 @@ export function AppSidebar({
|
||||
onSubmit={onAddWorkspace}
|
||||
open={isAddWorkspaceOpen ?? false}
|
||||
/>
|
||||
|
||||
<CreateSectionDialog
|
||||
open={createSectionState.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreateSectionState({ open: false, pendingChannelId: null });
|
||||
}
|
||||
}}
|
||||
onConfirm={handleCreateSectionConfirm}
|
||||
/>
|
||||
|
||||
<RenameSectionDialog
|
||||
open={renameSectionTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRenameSectionTarget(null);
|
||||
}}
|
||||
sectionName={renameSectionTarget?.name ?? ""}
|
||||
onConfirm={(newName) => {
|
||||
if (renameSectionTarget) {
|
||||
renameSection(renameSectionTarget.id, newName);
|
||||
}
|
||||
setRenameSectionTarget(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteSectionAlertDialog
|
||||
open={deleteSectionTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteSectionTarget(null);
|
||||
}}
|
||||
sectionName={deleteSectionTarget?.name ?? ""}
|
||||
channelCount={
|
||||
deleteSectionTarget
|
||||
? (sectionBuckets.bySection[deleteSectionTarget.id]?.length ?? 0)
|
||||
: 0
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (deleteSectionTarget) {
|
||||
deleteSection(deleteSectionTarget.id);
|
||||
setCollapsedSections((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[deleteSectionTarget.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setDeleteSectionTarget(null);
|
||||
}}
|
||||
/>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SectionNameDialog (internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SectionNameDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
initialValue: string;
|
||||
confirmLabel: string;
|
||||
isConfirmDisabled: (trimmed: string) => boolean;
|
||||
onConfirm: (name: string) => void;
|
||||
};
|
||||
|
||||
function SectionNameDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
initialValue,
|
||||
confirmLabel,
|
||||
isConfirmDisabled,
|
||||
onConfirm,
|
||||
}: SectionNameDialogProps) {
|
||||
const [name, setName] = React.useState(initialValue);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(initialValue);
|
||||
// Small delay to let dialog animation start before focusing
|
||||
const timerId = globalThis.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [open, initialValue]);
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (isConfirmDisabled(trimmed)) return;
|
||||
onConfirm(trimmed);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Section name"
|
||||
ref={inputRef}
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" type="button">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button type="submit" disabled={isConfirmDisabled(name.trim())}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateSectionDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CreateSectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (name: string) => void;
|
||||
};
|
||||
|
||||
export function CreateSectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: CreateSectionDialogProps) {
|
||||
return (
|
||||
<SectionNameDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Create section"
|
||||
description="Sections let you group related channels in the sidebar."
|
||||
initialValue=""
|
||||
confirmLabel="Create"
|
||||
isConfirmDisabled={(trimmed) => trimmed.length === 0}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RenameSectionDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RenameSectionDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sectionName: string;
|
||||
onConfirm: (newName: string) => void;
|
||||
};
|
||||
|
||||
export function RenameSectionDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sectionName,
|
||||
onConfirm,
|
||||
}: RenameSectionDialogProps) {
|
||||
return (
|
||||
<SectionNameDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Rename section"
|
||||
description="Enter a new name for this section."
|
||||
initialValue={sectionName}
|
||||
confirmLabel="Rename"
|
||||
isConfirmDisabled={(trimmed) =>
|
||||
trimmed.length === 0 || trimmed === sectionName
|
||||
}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteSectionAlertDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DeleteSectionAlertDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sectionName: string;
|
||||
channelCount: number;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function DeleteSectionAlertDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sectionName,
|
||||
channelCount,
|
||||
onConfirm,
|
||||
}: DeleteSectionAlertDialogProps) {
|
||||
const channelLabel =
|
||||
channelCount === 1 ? "1 channel" : `${channelCount} channels`;
|
||||
const description =
|
||||
channelCount === 0
|
||||
? `Delete section "${sectionName}"? It has no channels.`
|
||||
: `Delete section "${sectionName}"? Its ${channelLabel} will move back to the default Channels group.`;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete section</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Check,
|
||||
CheckCheck,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
CircleDot,
|
||||
GripVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuTrigger,
|
||||
} from "@/shared/ui/context-menu";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { ChannelMenuButton } from "@/features/sidebar/ui/SidebarSection";
|
||||
import {
|
||||
DraggableChannelRow,
|
||||
DroppableSectionBody,
|
||||
DroppableUngroupedBody,
|
||||
SortableSectionShell,
|
||||
} from "@/features/sidebar/ui/SidebarDnd";
|
||||
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared styles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SECTION_ICON_BUTTON_CLASS =
|
||||
"flex h-5 w-5 items-center justify-center rounded-md text-sidebar-foreground/50 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground";
|
||||
export const SECTION_ACTION_VISIBILITY_CLASS =
|
||||
"opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 group-focus-within/sidebar-section:opacity-100";
|
||||
const SECTION_LABEL_BUTTON_CLASS =
|
||||
"group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
|
||||
const SECTION_LABEL_CHEVRON_CLASS =
|
||||
"h-2.5 w-2.5 shrink-0 opacity-0 text-sidebar-foreground/45 transition-[color,opacity,transform] group-hover/section-label:opacity-100 group-hover/section-label:text-sidebar-foreground group-focus-visible/section-label:opacity-100 group-focus-visible/section-label:text-sidebar-foreground";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MoveToSectionSubmenu — internal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MoveToSectionSubmenu({
|
||||
channelId,
|
||||
sections,
|
||||
assignments,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
}: {
|
||||
channelId: string;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
onAssignChannel: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel: (channelId: string) => void;
|
||||
onCreateSectionForChannel: (channelId: string) => void;
|
||||
}) {
|
||||
const currentSectionId = assignments[channelId];
|
||||
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger>Move to section</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent>
|
||||
{sections.map((section) => (
|
||||
<ContextMenuItem
|
||||
key={section.id}
|
||||
onClick={() => onAssignChannel(channelId, section.id)}
|
||||
>
|
||||
{currentSectionId === section.id ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="h-4 w-4" />
|
||||
)}
|
||||
{section.name}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
{sections.length > 0 ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuItem onClick={() => onCreateSectionForChannel(channelId)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New section...
|
||||
</ContextMenuItem>
|
||||
{currentSectionId ? (
|
||||
<ContextMenuItem onClick={() => onUnassignChannel(channelId)}>
|
||||
Remove from section
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChannelContextMenuItems — shared context menu items for channel rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ChannelContextMenuItems({
|
||||
channel,
|
||||
hasUnread,
|
||||
sections,
|
||||
assignments,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
}: {
|
||||
channel: Channel;
|
||||
hasUnread: boolean;
|
||||
sections?: ChannelSection[];
|
||||
assignments?: Record<string, string>;
|
||||
onMarkChannelRead: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkChannelUnread: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onAssignChannel?: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel?: (channelId: string) => void;
|
||||
onCreateSectionForChannel?: (channelId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{hasUnread ? (
|
||||
<ContextMenuItem
|
||||
onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Mark as read
|
||||
</ContextMenuItem>
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
onClick={() => onMarkChannelUnread(channel.id, channel.lastMessageAt)}
|
||||
>
|
||||
<CircleDot className="h-4 w-4" />
|
||||
Mark unread
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{sections &&
|
||||
assignments &&
|
||||
onAssignChannel &&
|
||||
onUnassignChannel &&
|
||||
onCreateSectionForChannel ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<MoveToSectionSubmenu
|
||||
channelId={channel.id}
|
||||
sections={sections}
|
||||
assignments={assignments}
|
||||
onAssignChannel={onAssignChannel}
|
||||
onUnassignChannel={onUnassignChannel}
|
||||
onCreateSectionForChannel={onCreateSectionForChannel}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SectionHeaderActions — browse + create icon buttons for section headers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SectionHeaderActions({
|
||||
browseAriaLabel,
|
||||
browseTestId,
|
||||
className,
|
||||
createAriaLabel,
|
||||
hasUnread,
|
||||
onBrowse,
|
||||
onCreateClick,
|
||||
onMarkAllRead,
|
||||
}: {
|
||||
browseAriaLabel: string;
|
||||
browseTestId?: string;
|
||||
className?: string;
|
||||
createAriaLabel: string;
|
||||
hasUnread?: boolean;
|
||||
onBrowse: () => void;
|
||||
onCreateClick: () => void;
|
||||
onMarkAllRead?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasUnread && onMarkAllRead ? (
|
||||
<button
|
||||
aria-label="Mark all as read"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={onMarkAllRead}
|
||||
title="Mark all as read"
|
||||
type="button"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label={browseAriaLabel}
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
data-testid={browseTestId}
|
||||
onClick={onBrowse}
|
||||
type="button"
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label={createAriaLabel}
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={onCreateClick}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ChannelGroupSection — unified Channels / Forums section (no inline form)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ChannelGroupSection({
|
||||
browseAriaLabel,
|
||||
browseTestId,
|
||||
createAriaLabel,
|
||||
draggable,
|
||||
groupClassName,
|
||||
hasUnread,
|
||||
isCollapsed,
|
||||
isActiveChannel,
|
||||
items,
|
||||
listTestId,
|
||||
onBrowse,
|
||||
onCreateClick,
|
||||
onMarkAllRead,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onSelectChannel,
|
||||
onToggleCollapsed,
|
||||
selectedChannelId,
|
||||
title,
|
||||
unreadChannelIds,
|
||||
sections,
|
||||
assignments,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
}: {
|
||||
browseAriaLabel: string;
|
||||
browseTestId?: string;
|
||||
createAriaLabel: string;
|
||||
draggable?: boolean;
|
||||
groupClassName?: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveChannel: boolean;
|
||||
items: Channel[];
|
||||
listTestId: string;
|
||||
onBrowse: () => void;
|
||||
onCreateClick: () => void;
|
||||
onMarkChannelRead: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkChannelUnread: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onToggleCollapsed: () => void;
|
||||
selectedChannelId: string | null;
|
||||
title: string;
|
||||
unreadChannelIds: Set<string>;
|
||||
hasUnread?: boolean;
|
||||
onMarkAllRead?: () => void;
|
||||
sections?: ChannelSection[];
|
||||
assignments?: Record<string, string>;
|
||||
onAssignChannel?: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel?: (channelId: string) => void;
|
||||
onCreateSectionForChannel?: (channelId: string) => void;
|
||||
}) {
|
||||
const contentId = `sidebar-${listTestId}`;
|
||||
|
||||
const channelList =
|
||||
items.length > 0 ? (
|
||||
<SidebarMenu data-testid={listTestId}>
|
||||
{items.map((channel) => (
|
||||
<ContextMenu key={channel.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
{draggable ? (
|
||||
<DraggableChannelRow channelId={channel.id}>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel && selectedChannelId === channel.id
|
||||
}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
</DraggableChannelRow>
|
||||
) : (
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel && selectedChannelId === channel.id
|
||||
}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ChannelContextMenuItems
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
sections={sections}
|
||||
assignments={assignments}
|
||||
onMarkChannelRead={onMarkChannelRead}
|
||||
onMarkChannelUnread={onMarkChannelUnread}
|
||||
onAssignChannel={onAssignChannel}
|
||||
onUnassignChannel={onUnassignChannel}
|
||||
onCreateSectionForChannel={onCreateSectionForChannel}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
) : null;
|
||||
|
||||
const sectionContent = (
|
||||
<SidebarGroup className={groupClassName}>
|
||||
<div className="group/sidebar-section relative">
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={SECTION_LABEL_BUTTON_CLASS}
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<span>{title}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
<SectionHeaderActions
|
||||
browseAriaLabel={browseAriaLabel}
|
||||
browseTestId={browseTestId}
|
||||
className={SECTION_ACTION_VISIBILITY_CLASS}
|
||||
createAriaLabel={createAriaLabel}
|
||||
hasUnread={hasUnread}
|
||||
onBrowse={onBrowse}
|
||||
onCreateClick={onCreateClick}
|
||||
onMarkAllRead={onMarkAllRead}
|
||||
/>
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<SidebarGroupContent id={contentId}>{channelList}</SidebarGroupContent>
|
||||
) : null}
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
return draggable ? (
|
||||
<DroppableUngroupedBody>{sectionContent}</DroppableUngroupedBody>
|
||||
) : (
|
||||
sectionContent
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CustomChannelSection — user-defined channel section with management actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function CustomChannelSection({
|
||||
section,
|
||||
channels,
|
||||
hasUnread,
|
||||
isCollapsed,
|
||||
isActiveChannel,
|
||||
selectedChannelId,
|
||||
unreadChannelIds,
|
||||
sections,
|
||||
assignments,
|
||||
isFirst,
|
||||
isLast,
|
||||
onToggleCollapsed,
|
||||
onSelectChannel,
|
||||
onMarkChannelRead,
|
||||
onMarkChannelUnread,
|
||||
onMarkSectionRead,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onCreateSectionForChannel,
|
||||
onRenameSection,
|
||||
onDeleteSection,
|
||||
onMoveSectionUp,
|
||||
onMoveSectionDown,
|
||||
}: {
|
||||
section: ChannelSection;
|
||||
channels: Channel[];
|
||||
hasUnread: boolean;
|
||||
isCollapsed: boolean;
|
||||
isActiveChannel: boolean;
|
||||
selectedChannelId: string | null;
|
||||
unreadChannelIds: Set<string>;
|
||||
sections: ChannelSection[];
|
||||
assignments: Record<string, string>;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onMarkChannelRead: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkChannelUnread: (
|
||||
channelId: string,
|
||||
lastMessageAt: string | null | undefined,
|
||||
) => void;
|
||||
onMarkSectionRead: () => void;
|
||||
onAssignChannel: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel: (channelId: string) => void;
|
||||
onCreateSectionForChannel: (channelId: string) => void;
|
||||
onRenameSection: () => void;
|
||||
onDeleteSection: () => void;
|
||||
onMoveSectionUp: () => void;
|
||||
onMoveSectionDown: () => void;
|
||||
}) {
|
||||
const contentId = `sidebar-section-${section.id}`;
|
||||
|
||||
return (
|
||||
<SortableSectionShell sectionId={section.id}>
|
||||
{({ dragHandleProps, isDragging }) => (
|
||||
<DroppableSectionBody sectionId={section.id}>
|
||||
<SidebarGroup className={cn(isDragging && "opacity-30")}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className="group/sidebar-section relative"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<SidebarGroupLabel asChild>
|
||||
<button
|
||||
aria-controls={contentId}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={SECTION_LABEL_BUTTON_CLASS}
|
||||
onClick={onToggleCollapsed}
|
||||
type="button"
|
||||
>
|
||||
<GripVertical
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-sidebar-foreground/30",
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{section.name}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
SECTION_LABEL_CHEVRON_CLASS,
|
||||
isCollapsed ? "-rotate-90" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</SidebarGroupLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
)}
|
||||
>
|
||||
{hasUnread ? (
|
||||
<button
|
||||
aria-label="Mark all as read"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMarkSectionRead();
|
||||
}}
|
||||
title="Mark all as read"
|
||||
type="button"
|
||||
>
|
||||
<CheckCheck className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="Rename section"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRenameSection();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Delete section"
|
||||
className={SECTION_ICON_BUTTON_CLASS}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSection();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onRenameSection}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Rename section
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={isFirst} onClick={onMoveSectionUp}>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
Move up
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={isLast} onClick={onMoveSectionDown}>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
Move down
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onDeleteSection}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete section
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{!isCollapsed ? (
|
||||
<SidebarGroupContent id={contentId}>
|
||||
{channels.length > 0 ? (
|
||||
<SidebarMenu>
|
||||
{channels.map((channel) => (
|
||||
<ContextMenu key={channel.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<DraggableChannelRow channelId={channel.id}>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
isActive={
|
||||
isActiveChannel &&
|
||||
selectedChannelId === channel.id
|
||||
}
|
||||
onSelectChannel={onSelectChannel}
|
||||
/>
|
||||
</DraggableChannelRow>
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ChannelContextMenuItems
|
||||
channel={channel}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
sections={sections}
|
||||
assignments={assignments}
|
||||
onMarkChannelRead={onMarkChannelRead}
|
||||
onMarkChannelUnread={onMarkChannelUnread}
|
||||
onAssignChannel={onAssignChannel}
|
||||
onUnassignChannel={onUnassignChannel}
|
||||
onCreateSectionForChannel={
|
||||
onCreateSectionForChannel
|
||||
}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
) : null}
|
||||
</SidebarGroupContent>
|
||||
) : null}
|
||||
</SidebarGroup>
|
||||
</DroppableSectionBody>
|
||||
)}
|
||||
</SortableSectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// biome-ignore format: keep compact to stay within file size limit
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Hash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data attribute shapes used on draggable / droppable items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DndChannelData = { type: "channel"; channelId: string };
|
||||
export type DndSectionData = { type: "section"; sectionId: string };
|
||||
export type DndSectionDropData = { type: "section-drop"; sectionId: string };
|
||||
export type DndUngroupedData = { type: "ungrouped" };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DraggableChannelRow — wraps a channel list item with useDraggable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DraggableChannelRow({
|
||||
channelId,
|
||||
children,
|
||||
}: {
|
||||
channelId: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: channelId,
|
||||
data: { type: "channel", channelId } satisfies DndChannelData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={cn("touch-none", isDragging && "opacity-30")}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DroppableSectionBody — makes a section body accept channel drops
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DroppableSectionBody({
|
||||
sectionId,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
sectionId: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const droppableId = `section-drop:${sectionId}`;
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: droppableId,
|
||||
data: { type: "section-drop", sectionId } satisfies DndSectionDropData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"rounded-md transition-all",
|
||||
isOver && "ring-2 ring-primary/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DroppableUngroupedBody — makes the ungrouped Channels section a drop target
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DroppableUngroupedBody({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "ungrouped",
|
||||
data: { type: "ungrouped" } satisfies DndUngroupedData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"rounded-md transition-all",
|
||||
isOver && "ring-2 ring-primary/30",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SortableSectionShell — wraps a CustomChannelSection header area
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function SortableSectionShell({
|
||||
sectionId,
|
||||
children,
|
||||
}: {
|
||||
sectionId: string;
|
||||
children: (props: {
|
||||
dragHandleProps: React.HTMLAttributes<HTMLElement>;
|
||||
isDragging: boolean;
|
||||
style: React.CSSProperties;
|
||||
}) => React.ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sectionId,
|
||||
data: { type: "section", sectionId } satisfies DndSectionData,
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
{children({
|
||||
dragHandleProps: { ...attributes, ...listeners },
|
||||
isDragging,
|
||||
style,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DragOverlayChannel — ghost preview for a channel being dragged
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DragOverlayChannel({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="flex cursor-grabbing items-center gap-2 rounded-md bg-sidebar px-2 py-1.5 text-sm text-sidebar-foreground opacity-90 shadow-lg ring-1 ring-sidebar-border">
|
||||
<Hash className="h-4 w-4 shrink-0 text-sidebar-foreground/60" />
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DragOverlaySection — ghost preview for a section header being dragged
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function DragOverlaySection({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="flex cursor-grabbing items-center gap-1 rounded-md bg-sidebar px-2 py-1 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/60 opacity-90 shadow-lg ring-1 ring-sidebar-border">
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SidebarDndContext — DndContext + SortableContext + DragOverlay wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SidebarDragItem =
|
||||
| { type: "channel"; channelId: string; channelName: string }
|
||||
| { type: "section"; sectionId: string; sectionName: string };
|
||||
|
||||
export function SidebarDndContext({
|
||||
sectionIds,
|
||||
channels,
|
||||
sections,
|
||||
children,
|
||||
onAssignChannel,
|
||||
onUnassignChannel,
|
||||
onReorderSections,
|
||||
}: {
|
||||
sectionIds: string[];
|
||||
channels: { id: string; name: string }[];
|
||||
sections: { id: string; name: string }[];
|
||||
children: React.ReactNode;
|
||||
onAssignChannel: (channelId: string, sectionId: string) => void;
|
||||
onUnassignChannel: (channelId: string) => void;
|
||||
onReorderSections: (orderedIds: string[]) => void;
|
||||
}) {
|
||||
const [activeDragItem, setActiveDragItem] =
|
||||
React.useState<SidebarDragItem | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
);
|
||||
|
||||
const handleDragStart = React.useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const data = event.active.data.current;
|
||||
if (!data) return;
|
||||
if (data.type === "channel") {
|
||||
const ch = channels.find((c) => c.id === data.channelId);
|
||||
if (ch)
|
||||
setActiveDragItem({
|
||||
type: "channel",
|
||||
channelId: ch.id,
|
||||
channelName: ch.name,
|
||||
});
|
||||
} else if (data.type === "section") {
|
||||
const sec = sections.find((s) => s.id === data.sectionId);
|
||||
if (sec)
|
||||
setActiveDragItem({
|
||||
type: "section",
|
||||
sectionId: sec.id,
|
||||
sectionName: sec.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
[channels, sections],
|
||||
);
|
||||
|
||||
const handleDragEnd = React.useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveDragItem(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
if (!activeData) return;
|
||||
if (activeData.type === "channel") {
|
||||
const channelId = activeData.channelId as string;
|
||||
if (overData?.type === "section-drop") {
|
||||
onAssignChannel(channelId, overData.sectionId as string);
|
||||
} else if (overData?.type === "ungrouped") {
|
||||
onUnassignChannel(channelId);
|
||||
}
|
||||
} else if (activeData.type === "section") {
|
||||
const overSectionId =
|
||||
(overData?.sectionId as string | undefined) ?? (over.id as string);
|
||||
const oldIdx = sectionIds.indexOf(active.id as string);
|
||||
const newIdx = sectionIds.indexOf(overSectionId);
|
||||
if (oldIdx !== -1 && newIdx !== -1 && oldIdx !== newIdx) {
|
||||
onReorderSections(arrayMove(sectionIds, oldIdx, newIdx));
|
||||
}
|
||||
}
|
||||
},
|
||||
[sectionIds, onAssignChannel, onUnassignChannel, onReorderSections],
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={pointerWithin}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext
|
||||
items={sectionIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{children}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{activeDragItem?.type === "channel" ? (
|
||||
<DragOverlayChannel name={activeDragItem.channelName} />
|
||||
) : activeDragItem?.type === "section" ? (
|
||||
<DragOverlaySection name={activeDragItem.sectionName} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
Generated
+57
-2
@@ -21,6 +21,15 @@ importers:
|
||||
|
||||
desktop:
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.6)
|
||||
'@emoji-mart/data':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -449,6 +458,28 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||
|
||||
@@ -1359,7 +1390,6 @@ packages:
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
|
||||
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
|
||||
@@ -2778,7 +2808,7 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
@@ -2945,6 +2975,31 @@ snapshots:
|
||||
'@biomejs/cli-win32-x64@2.4.16':
|
||||
optional: true
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.6)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.6)
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.6)':
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
tslib: 2.8.1
|
||||
|
||||
'@emnapi/core@1.10.0':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
|
||||
Reference in New Issue
Block a user