- 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
291 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|