diff --git a/src/components/create-folder-button.tsx b/src/components/create-folder-button.tsx new file mode 100644 index 0000000..b9aee76 --- /dev/null +++ b/src/components/create-folder-button.tsx @@ -0,0 +1,18 @@ +import { FolderIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import CreateFolderModal from "./database/create-folder-modal"; +import { useState } from "react"; + +interface Props {} +export default function CreateFolderButton(props: Props) { + const [visible, setVisible] = useState(false); + return ( +
+ + setVisible(false)} /> +
+ ); +} diff --git a/src/components/database/connection-item.tsx b/src/components/database/connection-item.tsx new file mode 100644 index 0000000..ae61b4a --- /dev/null +++ b/src/components/database/connection-item.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { + ConnectionStoreItem, + ConnectionStoreManager, + connectionTypeTemplates, +} from "@/lib/conn-manager-store"; +import { useSortable } from "@dnd-kit/sortable"; +import { useToast } from "@/hooks/use-toast"; +import { useNavigate } from "react-router-dom"; +import { MySQLIcon } from "@/lib/outerbase-icon"; +import { Button } from "../ui/button"; +import { + LucideCopy, + LucideMoreHorizontal, + LucidePencil, + LucideTrash, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu"; +import { motion } from "framer-motion"; +import { CSS } from "@dnd-kit/utilities"; +import { generateConnectionString } from "@/lib/connection-string"; +import { cn } from "@/lib/utils"; + +export default function ConnectionItem({ + item, + selectedConnection, + setSelectedConnection, + setConnectionList, + setDeletingConnectionId, +}: { + item: ConnectionStoreItem; + selectedConnection?: string; + setSelectedConnection: DispatchState; + setConnectionList: DispatchState; + setDeletingConnectionId: DispatchState; +}) { + const [isMenuOpen, setMenuOpen] = useState(false); + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: item.id }); + + const { toast } = useToast(); + const navigate = useNavigate(); + const typeConfig = connectionTypeTemplates[item.type]; + const IconComponent = typeConfig?.icon ?? MySQLIcon; + + function onConnect() { + const connItem: ConnectionStoreItem = { + ...item, + lastConnectAt: new Date().getTime(), + }; + ConnectionStoreManager.save(connItem); + window.outerbaseIpc.connect(connItem); + } + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
{ + setSelectedConnection(item.id); + }} + onDoubleClick={onConnect} + > + + +
+
{item.name}
+
+ {generateConnectionString(item)} +
+
+
+ + + + + + + Connect + + + { + window.outerbaseIpc.connect(item, true); + }} + > + Connect with debugger + + + + { + navigate(`/connection/edit/${item.type}/${item.id}`); + }} + > + + Edit + + + { + setConnectionList(ConnectionStoreManager.duplicate(item)); + }} + inset + > + Duplicate + + + + { + window.navigator.clipboard.writeText( + generateConnectionString(item, false), + ); + toast({ + title: "Connection string copied to clipboard", + duration: 1000, + }); + }} + > + + Copy Connection String + + + + { + setDeletingConnectionId(item); + }} + > + + Delete + + + +
+
+
+ ); +} diff --git a/src/components/database/connection-list.tsx b/src/components/database/connection-list.tsx new file mode 100644 index 0000000..03a783f --- /dev/null +++ b/src/components/database/connection-list.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { + ConnectionStoreItem, + ConnectionStoreManager, +} from "@/lib/conn-manager-store"; +import { + closestCenter, + DndContext, + DragEndEvent, + KeyboardSensor, + Modifier, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { AnimatePresence } from "framer-motion"; +import ConnectionItem from "./connection-item"; +import DeletingConnectionModal from "./delect-connection-modal"; + +interface Props { + data: ConnectionStoreItem[]; + onDragEnd?: (event: DragEndEvent) => void; + setConnectionList: DispatchState; +} + +const restrictToVerticalAxis: Modifier = ({ transform }) => { + return { + ...transform, + x: 0, + }; +}; + +export default function ConnectionList({ + data, + onDragEnd, + setConnectionList, +}: Props) { + const [selectedConnection, setSelectedConnection] = useState(""); + const [deletingConnectionId, setDeletingConnectionId] = + useState(null); + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + return ( + <> + {deletingConnectionId && ( + { + setDeletingConnectionId(null); + }} + onSuccess={() => { + setConnectionList( + ConnectionStoreManager.remove(deletingConnectionId.id), + ); + setDeletingConnectionId(null); + }} + /> + )} + + + + {data.map((item) => ( + + ))} + + + + + ); +} diff --git a/src/components/database/create-folder-modal.tsx b/src/components/database/create-folder-modal.tsx new file mode 100644 index 0000000..8bf3010 --- /dev/null +++ b/src/components/database/create-folder-modal.tsx @@ -0,0 +1,44 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Input } from "../ui/input"; + +export default function CreateFolderModal({ + visible, + onClose, +}: { + visible: boolean; + onClose: () => void; +}) { + return ( + { + if (!openState) { + onClose(); + } + }} + > + + + Create Folder + + Creating folder helping you to organize your database connection + + + + + {}}>Cancel + {}}>Create + + + + ); +} diff --git a/src/components/database/delect-connection-modal.tsx b/src/components/database/delect-connection-modal.tsx new file mode 100644 index 0000000..f90b644 --- /dev/null +++ b/src/components/database/delect-connection-modal.tsx @@ -0,0 +1,48 @@ +import { ConnectionStoreItem } from "@/lib/conn-manager-store"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; + +interface Props { + data: ConnectionStoreItem; + onClose: () => void; + onSuccess: () => void; +} + +export default function DeletingConnectionModal({ + data, + onClose, + onSuccess, +}: Props) { + return ( + { + if (openState === false) { + onClose(); + } + }} + > + + + Delete Connection + + Are you sure you want to delete the connection{" "} + {data.name}? + + + + Cancel + Continue + + + + ); +} diff --git a/src/components/folder.tsx b/src/components/folder.tsx new file mode 100644 index 0000000..6d2e962 --- /dev/null +++ b/src/components/folder.tsx @@ -0,0 +1,68 @@ +import { FolderIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { ConnectionStoreItem } from "@/lib/conn-manager-store"; +import { Input } from "./ui/input"; +import { cn } from "@/lib/utils"; + +interface Props { + search: string; + selected: string; + setSearch: (search: string) => void; + onChangeFolder: (folder: string) => void; +} + +interface Folder { + title: string; + data: ConnectionStoreItem[]; +} + +const connectionTypeList = [ + "recent", + "mysql", + "postgres", + "sqlite", + "turso", + "cloudflare", + "starbase", +]; + +export default function Folder({ + selected, + search, + setSearch, + onChangeFolder, +}: Props) { + return ( +
+
+
+ { + e.preventDefault(); + setSearch(e.target.value); + }} + /> +
+ {connectionTypeList.map((conn, index) => { + const active = conn === selected; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/resizeable-panel.tsx b/src/components/resizeable-panel.tsx new file mode 100644 index 0000000..f287bf3 --- /dev/null +++ b/src/components/resizeable-panel.tsx @@ -0,0 +1,163 @@ +import React, { + PropsWithChildren, + ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { cn } from "@/lib/utils"; +import useWindowDimension from "@/hooks/useWindowDimension"; + +interface PanelGroupProps { + direction: "horizontal" | "vertical"; + children: ReactNode; +} + +const PanelGroup = ({ + direction, + children, +}: PropsWithChildren) => { + const [panelSizes, setPanelSizes] = useState([]); + const { width } = useWindowDimension(); + + const handleResize = (index: number, delta: number) => { + setPanelSizes((prevSizes) => { + const newSizes = [...prevSizes]; + newSizes[index] = Math.max(newSizes[index] + delta, 100); + if (index + 1 < newSizes.length) { + newSizes[index + 1] = Math.max(newSizes[index + 1] - delta, 100); + } + return newSizes; + }); + }; + + const panels = useMemo( + () => React.Children.toArray(children) as React.ReactElement[], + [children], + ); + + useEffect(() => { + setPanelSizes(panels.map(() => width / panels.length)); + }, [width]); + + return ( +
+ {panels.map((panel, index) => ( + + {React.cloneElement(panel, { + size: panelSizes[index], // Pass the size as a prop to Panel + })} + {index < panels.length - 1 && ( + handleResize(index, delta)} + direction={direction} + /> + )} + + ))} +
+ ); +}; + +interface PanelProps { + id?: string; + minSize?: number; + maxSize?: number; + size?: number; + grow?: boolean; + children: ReactNode; + className?: string; +} + +const Panel = ({ + id, + minSize = 100, + maxSize, + size, + grow = false, + children, + className, +}: PropsWithChildren) => { + return ( +
+ {children} +
+ ); +}; + +interface PanelResizeHandleProps { + onResize: (delta: number) => void; + direction: "horizontal" | "vertical"; +} + +const PanelResizeHandle = ({ + onResize, + direction = "horizontal", +}: PropsWithChildren) => { + const isResizing = useRef(false); + + const handleMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + isResizing.current = true; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing.current) return; + + const delta = direction === "horizontal" ? e.movementX : e.movementY; + onResize(delta); + }; + + const handleMouseUp = () => { + isResizing.current = false; + }; + + useEffect(() => { + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); + + return ( +
+ ); +}; + +PanelGroup.Panel = Panel; + +export { PanelGroup }; diff --git a/src/database/index.tsx b/src/database/index.tsx index 75c4925..dc2b8ce 100644 --- a/src/database/index.tsx +++ b/src/database/index.tsx @@ -1,62 +1,26 @@ import { Toolbar, ToolbarDropdown } from "@/components/toolbar"; import { - DropdownMenu, - DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, - DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { - closestCenter, - DndContext, - DragEndEvent, - KeyboardSensor, - Modifier, - PointerSensor, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - arrayMove, - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { DragEndEvent } from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; import { MySQLIcon } from "@/lib/outerbase-icon"; -import { - LucideCopy, - LucideMoreHorizontal, - LucidePencil, - LucidePlus, - LucideTrash, -} from "lucide-react"; +import { LucidePlus } from "lucide-react"; import { AnimatedRouter } from "@/components/animated-router"; import { ConnectionCreateUpdateRoute } from "./editor-route"; import { useNavigate } from "react-router-dom"; -import { CSS } from "@dnd-kit/utilities"; import { ConnectionStoreItem, ConnectionStoreManager, connectionTypeTemplates, } from "@/lib/conn-manager-store"; -import { Dispatch, SetStateAction, useState } from "react"; -import { generateConnectionString } from "@/lib/connection-string"; -import { Button } from "@/components/ui/button"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { AnimatePresence, motion } from "framer-motion"; -import { useToast } from "@/hooks/use-toast"; -import { cn } from "@/lib/utils"; +import { useEffect, useMemo, useState } from "react"; + import ImportConnectionStringRoute from "./import-connection-string"; +import Folder from "@/components/folder"; +import { PanelGroup } from "@/components/resizeable-panel"; +import ConnectionList from "@/components/database/connection-list"; import useNavigateToRoute from "@/hooks/useNavigateToRoute"; const connectionTypeList = [ @@ -68,212 +32,27 @@ const connectionTypeList = [ "starbase", ]; -const restrictToVerticalAxis: Modifier = ({ transform }) => { - return { - ...transform, - x: 0, - }; -}; - -function DeletingModal({ - data, - onClose, - onSuccess, -}: { - data: ConnectionStoreItem; - onClose: () => void; - onSuccess: () => void; -}) { - return ( - { - if (openState === false) { - onClose(); - } - }} - > - - - Delete Connection - - Are you sure you want to delete the connection{" "} - {data.name}? - - - - - Cancel - Continue - - - - ); -} - -function ConnectionItem({ - item, - selectedConnection, - setSelectedConnection, - setConnectionList, - setDeletingConnectionId, -}: { - item: ConnectionStoreItem; - selectedConnection?: string; - setSelectedConnection: Dispatch>; - setConnectionList: Dispatch>; - setDeletingConnectionId: Dispatch>; -}) { - const [isMenuOpen, setMenuOpen] = useState(false); - const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id: item.id }); - - const { toast } = useToast(); - const navigate = useNavigate(); - const typeConfig = connectionTypeTemplates[item.type]; - const IconComponent = typeConfig?.icon ?? MySQLIcon; - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - return ( -
{ - setSelectedConnection(item.id); - }} - onDoubleClick={() => { - window.outerbaseIpc.connect(item); - }} - > - - -
-
{item.name}
-
- {generateConnectionString(item)} -
-
-
- - - - - - { - window.outerbaseIpc.connect(item); - }} - > - Connect - - - { - window.outerbaseIpc.connect(item, true); - }} - > - Connect with debugger - - - - { - navigate(`/connection/edit/${item.type}/${item.id}`); - }} - > - - Edit - - - { - setConnectionList(ConnectionStoreManager.duplicate(item)); - }} - inset - > - Duplicate - - - - { - window.navigator.clipboard.writeText( - generateConnectionString(item, false), - ); - toast({ - title: "Connection string copied to clipboard", - duration: 1000, - }); - }} - > - - Copy Connection String - - - - { - setDeletingConnectionId(item); - }} - > - - Delete - - - -
-
-
- ); -} - function ConnectionListRoute() { + const [search, setSearch] = useState(""); useNavigateToRoute(); - const [connectionList, setConnectionList] = useState(() => { - return ConnectionStoreManager.list(); - }); + const [selectedFolder, setSelectedFolder] = useState("recent"); - const [selectedConnection, setSelectedConnection] = useState(""); - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), + const [connectionList, setConnectionList] = useState( + [], ); - const [deletingConnectionId, setDeletingConnectionId] = - useState(null); - const navigate = useNavigate(); + useEffect(() => { + const list = ConnectionStoreManager.list(); + setConnectionList(list); + }, []); + + function onChangeFolder(folder: string) { + setSelectedFolder(folder); + } + function handleDragEnd(event: DragEndEvent) { const { active, over } = event; @@ -289,6 +68,21 @@ function ConnectionListRoute() { } } + const filterdConnection = useMemo(() => { + if (search) { + return connectionList.filter((conn) => { + return conn.name.toLowerCase().includes(search.toLowerCase()); + }); + } + if (selectedFolder === "recent") { + return connectionList.slice(0, 10); + } + const newList = connectionList.filter( + (conn) => conn.type === selectedFolder, + ); + return newList; + }, [search, selectedFolder, connectionList]); + return (
@@ -323,47 +117,26 @@ function ConnectionListRoute() { - {deletingConnectionId && ( - { - setDeletingConnectionId(null); - }} - onSuccess={() => { - setConnectionList( - ConnectionStoreManager.remove(deletingConnectionId.id), - ); - setDeletingConnectionId(null); - }} - /> - )} - -
- - + + + + + { + // render connection list + } + - - {connectionList.map((item) => ( - - ))} - - - -
+ data={filterdConnection} + /> + +
); } diff --git a/src/hooks/useWindowDimension.ts b/src/hooks/useWindowDimension.ts new file mode 100644 index 0000000..6c79784 --- /dev/null +++ b/src/hooks/useWindowDimension.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +export default function useWindowDimension() { + const [dimension, setDimension] = useState<{ width: number; height: number }>( + { + width: window.innerWidth, + height: window.innerHeight, + }, + ); + useEffect(() => { + const handleResize = () => { + setDimension({ + width: window.innerWidth, + height: window.innerHeight, + }); + }; + + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + return dimension; +} diff --git a/src/lib/conn-manager-store.tsx b/src/lib/conn-manager-store.tsx index 105ce45..31a3b53 100644 --- a/src/lib/conn-manager-store.tsx +++ b/src/lib/conn-manager-store.tsx @@ -20,6 +20,7 @@ export interface ConnectionStoreItem { name: string; type: string; config: ConnectionStoreItemConfig; + lastConnectAt?: number; } interface ConnectionTypeTemplate { @@ -261,7 +262,6 @@ export class ConnectionStoreManager { static save(item: ConnectionStoreItem) { const list = this.list(); const index = list.findIndex((i) => i.id === item.id); - if (index === -1) { list.unshift(item); } else { @@ -274,4 +274,14 @@ export class ConnectionStoreManager { static saveAll(items: ConnectionStoreItem[]) { localStorage.setItem("connections", JSON.stringify(items)); } + + static sort(list: ConnectionStoreItem[]) { + if (list.length === 0) return []; + + return list.sort( + (a, b) => + new Date(b.lastConnectAt || 0).getTime() - + new Date(a.lastConnectAt || 0).getTime(), + ); + } } diff --git a/vite-env.d.ts b/vite-env.d.ts index e8fc772..2da1f5e 100644 --- a/vite-env.d.ts +++ b/vite-env.d.ts @@ -1,5 +1,9 @@ +import { Dispatch, SetStateAction } from "react"; import { type OuterbaseIpc } from "./electron/preload"; declare global { + type Maybe = T | null | undefined; + + type DispatchState = Dispatch>; interface Window { outerbaseIpc: OuterbaseIpc; }