Files
gridline/desktop/src/components/connections/ConnectionGrid.tsx
T
adrianbonpin 6854d889c5 refactor: convert to bun-workspaces monorepo (desktop/ + www/)
- Move the Tauri app (React frontend + Rust backend) into desktop/ via
  git mv — configs unchanged (relative paths: tauri.conf.json, vite.config.ts)
- Root package.json becomes a private bun workspace container with
  orchestration scripts; desktop package renamed gridline-desktop
- Scaffold www/ with Astro 7 + Tailwind v4 (hand-wired vite plugin,
  static output, ready for Dokploy)
- Update release.yml for the new layout: projectPath: desktop,
  desktop/src-tauri resource paths, rust-cache workspace path
- Update README + AGENTS.md structure trees and dev commands
- Verified: vitest (1074), cargo test (354), desktop build, tauri dev
  (window launches), tauri build (dmg + app bundles), Astro build
2026-08-16 19:31:53 +08:00

291 lines
12 KiB
TypeScript

import type { Connection, Folder, Tag } from "../../lib/types";
import { Folder as FolderIcon, Check, Pencil, Trash2 } from "lucide-react";
import { useDroppable } from "@dnd-kit/core";
import { ConnectionCard } from "./ConnectionCard";
import { FolderBreadcrumb } from "../folders/FolderBreadcrumb";
import { getChildFolders, getDescendantFolderIds } from "../../lib/utils";
import { useUiStore } from "../../stores/uiStore";
import { useConnectionStore } from "../../stores/connectionStore";
import { TagBadge } from "../tags/TagBadge";
interface DroppableFolderCardProps {
folder: Folder;
isSelected: boolean;
count: number;
subfolderCount: number;
folderTags: Tag[];
onFolderClick: (id: string) => void;
onToggleSelection: (id: string) => void;
}
function DroppableFolderCard({
folder,
isSelected,
count,
subfolderCount,
folderTags,
onFolderClick,
onToggleSelection,
}: DroppableFolderCardProps) {
const { setNodeRef, isOver } = useDroppable({
id: `folder-${folder.id}`,
data: { type: "folder", folder },
});
return (
<div
ref={setNodeRef}
className={`relative group rounded-xl border transition-colors ${
isSelected
? "bg-accent/10 border-accent"
: "bg-surface border-border hover:border-border-hover"
} ${isOver ? "ring-1 ring-accent bg-accent/10" : ""}`}
>
<button
onClick={() => onFolderClick(folder.id)}
className="w-full p-3 text-left min-w-0 cursor-pointer"
>
<div className="flex items-center gap-2">
<FolderIcon
size={18}
className={
isSelected
? "text-accent"
: "text-text-muted"
}
/>
<span className="font-semibold text-sm truncate text-text">
{folder.name}
</span>
</div>
<div className="text-xs text-text-muted mt-1">
{count > 0 &&
`${count} item${count !== 1 ? "s" : ""}`}
{count > 0 && subfolderCount > 0 && " · "}
{subfolderCount > 0 &&
`${subfolderCount} subfolder${subfolderCount !== 1 ? "s" : ""}`}
{count === 0 &&
subfolderCount === 0 &&
"Empty folder"}
</div>
{folderTags.length > 0 && (
<div className="flex gap-1 flex-wrap mt-2">
{folderTags.map((t) => (
<TagBadge key={t.id} tag={t} />
))}
</div>
)}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onToggleSelection(folder.id);
}}
className={`absolute -top-1.5 -left-1.5 w-4 h-4 rounded border flex items-center justify-center transition-all ${
isSelected
? "bg-accent border-accent opacity-100"
: "border-border bg-surface opacity-0 group-hover:opacity-100"
}`}
>
{isSelected && (
<Check
size={12}
className="text-white"
/>
)}
</button>
</div>
);
}
interface ConnectionGridProps {
connections: Connection[];
tags: Tag[];
folders?: Folder[];
activeFolderId?: string | null;
onFolderSelect?: (id: string | null) => void;
hasSearch?: boolean;
onTagToggle?: (id: string) => void;
onEditFolder?: (folder: Folder) => void;
onDeleteFolder?: (folder: Folder) => void;
onOpenDbViewer?: (connectionId: string) => void;
onEditConnection?: (conn: Connection) => void;
onDuplicateConnection?: (conn: Connection) => void;
onDeleteConnection?: (conn: Connection) => void;
}
export function ConnectionGrid({
connections,
tags,
folders = [],
activeFolderId = null,
onFolderSelect,
hasSearch = false,
onTagToggle,
onEditFolder,
onDeleteFolder,
onOpenDbViewer,
onEditConnection,
onDuplicateConnection,
onDeleteConnection,
}: ConnectionGridProps) {
const selectedItemIds = useUiStore((s) => s.selectedItemIds);
const toggleItemSelection = useUiStore((s) => s.toggleItemSelection);
const clearSelection = useUiStore((s) => s.clearSelection);
const activeTagIds = useUiStore((s) => s.activeTagIds);
const activeDbTypes = useUiStore((s) => s.activeDbTypes);
const activeEnvironment = useUiStore((s) => s.activeEnvironment);
const currentFolderId = hasSearch
? null
: activeFolderId !== null && folders.some((f) => f.id === activeFolderId)
? activeFolderId
: null;
const hasActiveFilters =
activeTagIds.length > 0 ||
activeDbTypes.length > 0 ||
(activeEnvironment !== null && activeEnvironment !== undefined);
const connectionMatchesFilters = (c: Connection) => {
if (activeTagIds.length > 0 && !c.tag_ids.some((id) => activeTagIds.includes(id))) {
return false;
}
if (activeDbTypes.length > 0 && !activeDbTypes.includes(c.db_type)) {
return false;
}
if (activeEnvironment !== null && activeEnvironment !== undefined && c.environment !== activeEnvironment) {
return false;
}
return true;
};
const visibleFolders = hasSearch
? []
: getChildFolders(folders, currentFolderId).filter((f) => {
if (!hasActiveFilters) return true;
const folderMatchesTags = f.tag_ids.some((id) => activeTagIds.includes(id));
if (folderMatchesTags) return true;
const subIds = new Set(getDescendantFolderIds(folders, f.id));
return connections.some(
(c) => c.folder_id !== null && subIds.has(c.folder_id) && connectionMatchesFilters(c),
);
});
const directConnections = hasSearch
? connections
: connections.filter((c) => c.folder_id === currentFolderId);
const allStoreConnections = useConnectionStore((s) => s.connections);
const allStoreFolders = useConnectionStore((s) => s.folders);
// Check if any direct connections OR any subfolder has connections anywhere below
const hasItems = visibleFolders.length > 0 || directConnections.length > 0 ||
(currentFolderId && allStoreConnections.some((c) => {
const allowed = new Set(getDescendantFolderIds(allStoreFolders, currentFolderId));
return c.folder_id !== null && allowed.has(c.folder_id);
}));
const isSelecting = selectedItemIds.length > 0;
const activeFolder = currentFolderId
? (folders.find((f) => f.id === currentFolderId) ?? null)
: null;
const handleFolderClick = (folderId: string) => {
if (isSelecting) {
toggleItemSelection(folderId);
} else {
onFolderSelect?.(folderId);
}
};
const handleBreadcrumbNavigate = (folderId: string | null) => {
clearSelection();
onFolderSelect?.(folderId);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<FolderBreadcrumb
folders={folders}
activeFolderId={currentFolderId}
onNavigate={handleBreadcrumbNavigate}
hasSearch={hasSearch}
onClearSearch={() => useUiStore.getState().clearFilters()}
/>
{activeFolder && (
<div className="flex items-center gap-1">
<button
onClick={() => onEditFolder?.(activeFolder)}
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text transition-colors px-2 py-1 rounded-md cursor-pointer"
>
<Pencil size={12} /> Edit
</button>
<button
onClick={() => onDeleteFolder?.(activeFolder)}
className="inline-flex items-center gap-1 text-xs !text-red-400 hover:!text-red-300 transition-colors px-2 py-1 rounded-md cursor-pointer"
>
<Trash2 size={12} /> Delete
</button>
</div>
)}
</div>
{!hasItems ? (
<div className="text-center w-full py-16 text-text-muted">
{hasSearch
? "No connections match your search."
: activeFolderId
? "This folder is empty. Add a connection or subfolder."
: "No connections yet. Create one to get started."}
</div>
) : (
<div
className="grid gap-3"
style={{
gridTemplateColumns:
"repeat(auto-fill, minmax(260px, 1fr))",
}}
>
{visibleFolders.map((f) => {
const isSelected = selectedItemIds.includes(f.id);
// Count all connections in this subfolder (including nested descendants)
const subIds = new Set(getDescendantFolderIds(folders, f.id));
const count = allStoreConnections.filter(
(c) => c.folder_id !== null && subIds.has(c.folder_id),
).length;
const subfolderCount = getChildFolders(
folders,
f.id,
).length;
const tagMap = new Map(tags.map((t) => [t.id, t]));
const folderTags = f.tag_ids
.map((id) => tagMap.get(id))
.filter(Boolean) as Tag[];
return (
<DroppableFolderCard
key={f.id}
folder={f}
isSelected={isSelected}
count={count}
subfolderCount={subfolderCount}
folderTags={folderTags}
onFolderClick={handleFolderClick}
onToggleSelection={toggleItemSelection}
/>
);
})}
{directConnections.map((c) => (
<ConnectionCard
key={c.id}
connection={c}
tags={tags}
onTagToggle={onTagToggle}
onOpenDbViewer={onOpenDbViewer}
onEdit={onEditConnection}
onDuplicate={onDuplicateConnection}
onDelete={onDeleteConnection}
/>
))}
</div>
)}
</div>
);
}