Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/components/resources/FavoritesSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { IconFolder, IconFolderOpen, IconPlus, IconSearch, IconDotsVertical, IconEdit, IconTrash, IconDownload } from '@tabler/icons-react';
import { IconFolder, IconFolderOpen, IconPlus, IconSearch, IconDotsVertical, IconEdit, IconTrash, IconDownload, IconAlertTriangle } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
Expand All @@ -13,6 +13,7 @@ import {
import { useFavoriteFolders, FavoriteFolder } from '@/hooks/useFavoriteFolders';
import { cn } from '@/lib/utils';
import { useDroppable } from '@dnd-kit/core';
import { UserFavorite, MAX_FOLDER_ITEMS } from '@/hooks/useUserFavorites';

interface FavoritesSidebarProps {
selectedFolderId: string | null;
Expand All @@ -23,6 +24,7 @@ interface FavoritesSidebarProps {
onDownloadFolder: (folder: FavoriteFolder) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
favoritesData: UserFavorite[];
}

const FolderItem = ({
Expand All @@ -33,7 +35,8 @@ const FolderItem = ({
onEdit,
onDelete,
onDownload,
getChildren
getChildren,
folderItemCount
}: {
folder: FavoriteFolder;
level?: number;
Expand All @@ -43,6 +46,7 @@ const FolderItem = ({
onDelete: (id: string) => void;
onDownload: (folder: FavoriteFolder) => void;
getChildren: (parentId: string) => FavoriteFolder[];
folderItemCount: number;
}) => {
const [isOpen, setIsOpen] = useState(false);

Expand All @@ -57,6 +61,7 @@ const FolderItem = ({
const childrenFolders = getChildren(folder.id);
const hasChildren = childrenFolders.length > 0;
const isSelected = selectedFolderId === folder.id;
const isFull = folderItemCount >= MAX_FOLDER_ITEMS;

return (
<div>
Expand Down Expand Up @@ -89,6 +94,15 @@ const FolderItem = ({
<span className="truncate text-sm font-medium">
{folder.name}
</span>
<span className={cn(
"text-[10px] font-mono ml-auto flex-shrink-0",
isFull ? "text-amber-500 font-bold" : "text-muted-foreground/60"
)}>
{folderItemCount}/{MAX_FOLDER_ITEMS}
</span>
{isFull && (
<IconAlertTriangle size={13} className="text-amber-500 flex-shrink-0 ml-0.5" />
)}
</div>

<DropdownMenu>
Expand Down Expand Up @@ -128,6 +142,7 @@ const FolderItem = ({
onDelete={onDelete}
onDownload={onDownload}
getChildren={getChildren}
folderItemCount={0}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

child folders always show 0/40 regardless of actual item count

Suggested change
folderItemCount={0}
folderItemCount={getFolderItemCount(child.id)}

/>
))}
</div>
Expand All @@ -144,7 +159,8 @@ const FavoritesSidebar = ({
onDeleteFolder,
onDownloadFolder,
searchQuery,
onSearchChange
onSearchChange,
favoritesData
}: FavoritesSidebarProps) => {
const { folders, isLoading } = useFavoriteFolders();

Expand All @@ -163,6 +179,10 @@ const FavoritesSidebar = ({
return folders.filter(f => f.parent_id === parentId);
};

const getFolderItemCount = (folderId: string) => {
return favoritesData.filter(f => f.folder_id === folderId).length;
};

const filteredFolders = useMemo(() => {
if (!searchQuery) return rootFolders;
const query = searchQuery.toLowerCase();
Expand Down Expand Up @@ -228,6 +248,7 @@ const FavoritesSidebar = ({
onDelete={onDeleteFolder}
onDownload={onDownloadFolder}
getChildren={getChildren}
folderItemCount={getFolderItemCount(folder.id)}
/>
))
) : (
Expand All @@ -241,6 +262,7 @@ const FavoritesSidebar = ({
onDelete={onDeleteFolder}
onDownload={onDownloadFolder}
getChildren={getChildren}
folderItemCount={getFolderItemCount(folder.id)}
/>
))
)}
Expand Down
9 changes: 9 additions & 0 deletions src/components/resources/FavoritesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { IconHeart } from '@tabler/icons-react';
import FavoritesSidebar from './FavoritesSidebar';
import { useFavoriteFolders, FavoriteFolder } from '@/hooks/useFavoriteFolders';
import FolderDialog from './FolderDialog';
import { MAX_FOLDER_ITEMS } from '@/hooks/useUserFavorites';
import { toast } from 'sonner';
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
Expand Down Expand Up @@ -125,6 +126,13 @@ const FavoritesTab = ({ onSelectResource }: FavoritesTabProps) => {
const resourceUrl = getResourceUrl(resource);
const folderId = over.data.current.folder.id;

// Block drag into full folders
const folderCount = favoritesData.filter(f => f.folder_id === folderId).length;
if (folderCount >= MAX_FOLDER_ITEMS) {
toast.error(`This folder already has ${MAX_FOLDER_ITEMS} items. Please create another folder.`);
return;
}

if (resourceUrl) {
moveFavorite(resourceUrl, folderId);
}
Expand Down Expand Up @@ -262,6 +270,7 @@ const FavoritesTab = ({ onSelectResource }: FavoritesTabProps) => {
onDownloadFolder={handleDownloadFolder}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
favoritesData={favoritesData}
/>
</div>
</div>
Expand Down
212 changes: 212 additions & 0 deletions src/components/resources/FolderPickerPopover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import React, { useState, useRef, useEffect } from 'react';
import { IconFolder, IconPlus, IconAlertTriangle } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import { FavoriteFolder } from '@/hooks/useFavoriteFolders';
import { MAX_FOLDER_ITEMS } from '@/hooks/useUserFavorites';
import { ScrollArea } from '@/components/ui/scroll-area';
import FolderDialog from './FolderDialog';
import { useFavoriteFolders } from '@/hooks/useFavoriteFolders';

interface FolderPickerPopoverProps {
isOpen: boolean;
onClose: () => void;
/** Called when user picks a folder (null = no folder / unsorted) */
onSelectFolder: (folderId: string | null) => void;
folders: FavoriteFolder[];
/** Returns how many items are in a folder */
getFolderItemCount: (folderId: string | null) => number;
/** Position anchor element */
anchorRef: React.RefObject<HTMLElement | null>;
}

const FolderPickerPopover = ({
isOpen,
onClose,
onSelectFolder,
folders,
getFolderItemCount,
anchorRef,
}: FolderPickerPopoverProps) => {
const popoverRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [showCreateDialog, setShowCreateDialog] = useState(false);
const { createFolder } = useFavoriteFolders();

// Position the popover relative to the anchor
useEffect(() => {
if (!isOpen || !anchorRef.current) return;

const rect = anchorRef.current.getBoundingClientRect();
const popoverWidth = 220;
const popoverHeight = 300;
Comment on lines +40 to +41
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

popoverHeight is 300 but maxHeight on line 132 is 340 - inconsistent values may cause positioning issues


let top = rect.bottom + 8;
let left = rect.left + rect.width / 2 - popoverWidth / 2;

// Keep within viewport
if (left < 8) left = 8;
if (left + popoverWidth > window.innerWidth - 8) {
left = window.innerWidth - popoverWidth - 8;
}
if (top + popoverHeight > window.innerHeight - 8) {
top = rect.top - popoverHeight - 8;
}

setPosition({ top, left });
}, [isOpen, anchorRef]);

// Close on outside click
useEffect(() => {
if (!isOpen) return;

const handleClickOutside = (e: MouseEvent) => {
if (
popoverRef.current &&
!popoverRef.current.contains(e.target as Node) &&
anchorRef.current &&
!anchorRef.current.contains(e.target as Node)
) {
onClose();
}
};

// Delay to avoid the current click event closing it
const timer = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
}, 0);

return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose, anchorRef]);

// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);

if (!isOpen) return null;

const rootFolders = folders.filter(f => !f.parent_id);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only root folders are shown in picker - nested folders can't be selected when favoriting a resource


const handleFolderClick = (folderId: string | null) => {
if (folderId) {
const count = getFolderItemCount(folderId);
if (count >= MAX_FOLDER_ITEMS) {
// Don't close, just don't select
return;
}
}
onSelectFolder(folderId);
onClose();
};

const handleCreateFolder = async (name: string, color: string | null) => {
try {
const newFolder = await createFolder(name, null, color);
setShowCreateDialog(false);
if (newFolder?.id) {
onSelectFolder(String(newFolder.id));
onClose();
}
} catch {
setShowCreateDialog(false);
}
};

return (
<>
<div
ref={popoverRef}
className="fixed z-[100] bg-card border border-border/60 rounded-xl shadow-2xl backdrop-blur-xl animate-in fade-in-0 zoom-in-95 duration-150"
style={{
top: position.top,
left: position.left,
width: 220,
maxHeight: 340,
}}
>
<div className="px-3 py-2.5 border-b border-border/40">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Save to folder
</p>
</div>

<ScrollArea className="max-h-[230px]">
<div className="p-1.5 space-y-0.5">
{/* No folder / unsorted option */}
<button
onClick={() => handleFolderClick(null)}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-sm text-left hover:bg-muted/50 transition-colors"
>
<IconFolder size={15} className="text-muted-foreground flex-shrink-0" />
<span className="truncate text-muted-foreground">No folder</span>
</button>

{/* Folder list */}
{rootFolders.map(folder => {
const count = getFolderItemCount(folder.id);
const isFull = count >= MAX_FOLDER_ITEMS;

return (
<button
key={folder.id}
onClick={() => handleFolderClick(folder.id)}
disabled={isFull}
className={cn(
"w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-sm text-left transition-colors",
isFull
? "opacity-50 cursor-not-allowed"
: "hover:bg-muted/50 cursor-pointer"
)}
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: folder.color || 'var(--primary)' }}
/>
<span className="truncate flex-1">{folder.name}</span>
<span className={cn(
"text-[10px] font-mono flex-shrink-0",
isFull ? "text-amber-500" : "text-muted-foreground/60"
)}>
{count}/{MAX_FOLDER_ITEMS}
</span>
{isFull && (
<IconAlertTriangle size={12} className="text-amber-500 flex-shrink-0" />
)}
</button>
);
})}
</div>
</ScrollArea>

{/* Create folder */}
<div className="border-t border-border/40 p-1.5">
<button
onClick={() => setShowCreateDialog(true)}
className="w-full flex items-center gap-2 px-2.5 py-2 rounded-lg text-sm hover:bg-primary/10 text-primary transition-colors"
>
<IconPlus size={15} className="flex-shrink-0" />
<span>Create Folder</span>
</button>
</div>
</div>

<FolderDialog
isOpen={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onSave={handleCreateFolder}
initialData={null}
mode="create"
/>
</>
);
};

export default FolderPickerPopover;
Loading