mirror of
https://github.com/stackrender/stackrender.git
synced 2026-09-10 03:05:42 +00:00
Mobile Responsivity
This commit is contained in:
Generated
+28
-41
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vite-template",
|
||||
"version": "0.0.0",
|
||||
"name": "StackRender",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vite-template",
|
||||
"version": "0.0.0",
|
||||
"name": "StackRender",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-sql": "^6.9.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -39,7 +39,6 @@
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@xyflow/react": "^12.6.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -62,7 +61,7 @@
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "6.23.0",
|
||||
"react-tag-input": "^6.10.6",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -72,7 +71,8 @@
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-undo": "^1.1.1",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -81,7 +81,6 @@
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.11.0",
|
||||
"@typescript-eslint/parser": "8.11.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
@@ -7610,16 +7609,6 @@
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-resizable": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-resizable/-/react-resizable-3.0.8.tgz",
|
||||
"integrity": "sha512-Pcvt2eGA7KNXldt1hkhVhAgZ8hK41m0mp89mFgQi7LAAEZiaLgm4fHJ5zbJZ/4m2LVaAyYrrRRv1LHDcrGQanA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz",
|
||||
@@ -11814,6 +11803,7 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -12208,6 +12198,7 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -12441,31 +12432,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz",
|
||||
"integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==",
|
||||
"node_modules/react-resizable-panels": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz",
|
||||
"integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "15.x",
|
||||
"react-draggable": "^4.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-resizable/node_modules/react-draggable": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.3.0",
|
||||
"react-dom": ">= 16.3.0"
|
||||
"react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
@@ -13736,6 +13710,19 @@
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.20",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz",
|
||||
|
||||
+3
-4
@@ -41,7 +41,6 @@
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@uiw/react-codemirror": "^4.23.12",
|
||||
"@xyflow/react": "^12.6.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -64,7 +63,7 @@
|
||||
"react-day-picker": "^9.10.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "6.23.0",
|
||||
"react-tag-input": "^6.10.6",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -74,7 +73,8 @@
|
||||
"tailwindcss": "^4.1.13",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"use-undo": "^1.1.1",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -83,7 +83,6 @@
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/react-resizable": "^3.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.11.0",
|
||||
"@typescript-eslint/parser": "8.11.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
||||
@@ -64,9 +64,12 @@ export const Combobox = (props: ComboboxProps) => {
|
||||
aria-expanded={open}
|
||||
className={cn("justify-between", className)}
|
||||
>
|
||||
{value
|
||||
? items.find((item) => item.id === value)?.[label]
|
||||
: (placeholder ? placeholder : "Select items...")}
|
||||
<label className="truncate">
|
||||
{
|
||||
value
|
||||
? items.find((item) => item.id === value)?.[label]
|
||||
: (placeholder ? placeholder : "Select items...")}
|
||||
</label>
|
||||
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
+22
-12
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { ThemeSwitch } from './theme-switch'
|
||||
import RenameDb from '@/features/database/components/rename-db'
|
||||
import LanguagesDropdown from './languages-dropdown'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
|
||||
interface HeaderProps extends React.HTMLAttributes<HTMLElement> {
|
||||
fixed?: boolean
|
||||
@@ -38,19 +39,13 @@ export const Header = ({
|
||||
return () => document.removeEventListener('scroll', onScroll)
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
|
||||
const { open } = useModal();
|
||||
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'bg-background flex items-center gap-3 p-2 sm:gap-4 ',
|
||||
'bg-background flex-col md:flex items-center gap-3 p-2 sm:gap-4 ',
|
||||
fixed && 'header-fixed peer/header fixed z-50 w-[inherit] rounded-md ',
|
||||
offset > 10 && fixed ? 'shadow-sm' : 'shadow-none',
|
||||
className
|
||||
@@ -61,23 +56,38 @@ export const Header = ({
|
||||
<div className="justify-self-start flex h-8 gap-3 items-center ">
|
||||
<SidebarTrigger variant='outline' className='scale-125 sm:scale-100' />
|
||||
<Separator orientation='vertical' className='h-6' />
|
||||
<Menu />
|
||||
{!isMobile && <Menu />}
|
||||
</div>
|
||||
|
||||
|
||||
<div className=" justify-self-center ">
|
||||
<Search className='hidden md:flex' placeholder={t("navbar.search")} />
|
||||
<Search className='hidden lg:flex' placeholder={t("navbar.search")} />
|
||||
{
|
||||
isMobile &&
|
||||
<div className='w-full'>
|
||||
<RenameDb />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="justify-self-end flex gap-2">
|
||||
|
||||
<RenameDb />
|
||||
{
|
||||
!isMobile &&
|
||||
<div className='w-full'>
|
||||
<RenameDb />
|
||||
</div>
|
||||
}
|
||||
|
||||
<ThemeSwitch />
|
||||
<LanguagesDropdown />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{isMobile && <div className='flex justify-center'>
|
||||
<Menu />
|
||||
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
import { NavGroup } from '../nav-group'
|
||||
import { useSidebarData } from './data/sidebar-data'
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useDiagramOps } from '@/providers/diagram-provider/diagram-provider';
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const sidebarData = useSidebarData();
|
||||
const { openController } = useDiagramOps();
|
||||
return (
|
||||
<Sidebar collapsible='icon' variant='floating' {...props} className='bg-card border-r' >
|
||||
<SidebarHeader>
|
||||
@@ -24,12 +26,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<h3 className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground truncate font-semibold text-sm '>
|
||||
StackRender
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{sidebarData.navGroups.map((props: any, index: number) => (
|
||||
<div key={props.title}>
|
||||
<NavGroup key={props.title} {...props} />
|
||||
<NavGroup key={props.title} {...props} onClick={() => openController(true)} />
|
||||
{
|
||||
index != sidebarData.navGroups.length - 1 &&
|
||||
<div className='px-2'>
|
||||
@@ -50,7 +52,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '../ui/dropdown-menu'
|
||||
import { NavCollapsible, NavItem, NavLink, NavGroup as NavGroupType } from './types.ts'
|
||||
|
||||
export function NavGroup({ title, items }: NavGroupType) {
|
||||
export function NavGroup({ title, items , onClick }: NavGroupType) {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const location = useLocation();
|
||||
const href = location.pathname
|
||||
@@ -41,7 +41,7 @@ export function NavGroup({ title, items }: NavGroupType) {
|
||||
const key = `${item.title}-${item.url}`
|
||||
|
||||
if (!item.items)
|
||||
return <SidebarMenuLink key={key} item={item} href={href} target={item.newTab ? "_blank" : undefined}/>
|
||||
return <SidebarMenuLink key={key} item={item} href={href} target={item.newTab ? "_blank" : undefined} onClick={onClick}/>
|
||||
|
||||
if (state === 'collapsed' && !isMobile)
|
||||
return (
|
||||
@@ -59,7 +59,7 @@ const NavBadge = ({ children }: { children: ReactNode }) => (
|
||||
<Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
|
||||
)
|
||||
|
||||
const SidebarMenuLink = ({ item, href , target}: { item: NavLink; href: string; target?: string }) => {
|
||||
const SidebarMenuLink = ({ item, href , target , onClick}: { item: NavLink; href: string; target?: string , onClick? : ()=> void }) => {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
@@ -67,6 +67,7 @@ const SidebarMenuLink = ({ item, href , target}: { item: NavLink; href: string;
|
||||
asChild
|
||||
isActive={checkIsActive(href, item)}
|
||||
tooltip={item.title}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Link to={item.url} onClick={() => setOpenMobile(false)} target={target}>
|
||||
{item.icon && <item.icon />}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
interface BaseNavItem {
|
||||
@@ -10,8 +10,8 @@ interface BaseNavItem {
|
||||
|
||||
type NavLink = BaseNavItem & {
|
||||
url: string
|
||||
items?: never;
|
||||
newTab? : boolean
|
||||
items?: never;
|
||||
newTab?: boolean
|
||||
}
|
||||
|
||||
type NavCollapsible = BaseNavItem & {
|
||||
@@ -23,13 +23,14 @@ type NavItem = NavCollapsible | NavLink
|
||||
|
||||
interface NavGroup {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
items: NavItem[];
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface SidebarData {
|
||||
|
||||
|
||||
footerNavGroups : NavGroup[]
|
||||
|
||||
footerNavGroups: NavGroup[]
|
||||
navGroups: NavGroup[]
|
||||
}
|
||||
|
||||
@@ -47,4 +48,4 @@ interface MenuItem {
|
||||
clickHandler?: () => void
|
||||
}
|
||||
|
||||
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink , MenuItem }
|
||||
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink, MenuItem }
|
||||
|
||||
@@ -67,7 +67,7 @@ const Modal: React.FC<ModalProps> = ({
|
||||
{
|
||||
footer &&
|
||||
<DialogFooter >
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex justify-between w-full flex-col gap-4 sm:flex-row">
|
||||
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,351 @@
|
||||
// Importing necessary types and hooks from React Flow (XYFlow)
|
||||
import {
|
||||
Background, ColorMode, Connection, Controls, EdgeChange,
|
||||
EdgeRemoveChange, MiniMap, NodeChange, NodePositionChange,
|
||||
NodeRemoveChange, OnEdgesChange, OnNodesChange,
|
||||
ReactFlow, useEdgesState, useNodesState, useReactFlow
|
||||
} from "@xyflow/react";
|
||||
|
||||
// React built-ins
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
|
||||
// Custom components and styles
|
||||
import Table from "./table";
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import Relationship from "./relationship";
|
||||
import { TableInsertType } from "@/lib/schemas/table-schema";
|
||||
|
||||
// Custom context providers and hooks
|
||||
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
|
||||
import { useTableToNode } from "@/hooks/use-table-to-node";
|
||||
import { useRelationshipToEdge } from "@/hooks/use-relationship-to-edge";
|
||||
import { RelationshipInsertType } from "@/lib/schemas/relationship-schema";
|
||||
|
||||
// Utils and constants
|
||||
import { v4 } from "uuid";
|
||||
import { TARGET_PREFIX } from "./field";
|
||||
import CardinalityMarker from "@/features/database/components/cardinality-marker";
|
||||
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
|
||||
|
||||
|
||||
import { AlertTriangle, Menu } from "lucide-react";
|
||||
import { adjustTablesPositions } from "@/utils/tables";
|
||||
//import DatabaseControlButtons from "./database-control-buttons";
|
||||
import { FieldType } from "@/lib/schemas/field-schema";
|
||||
import useHighlightedEdges from "@/hooks/use-highlighted-edges";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useOverlappingTables from "@/hooks/use-overlapping-tables";
|
||||
|
||||
|
||||
import { getRelationshipSourceAndTarget } from "@/utils/relationship";
|
||||
import { Loading } from "@/components/layout/loading-modal";
|
||||
|
||||
import { CardinalityStyle } from "@/lib/database";
|
||||
import { useTheme } from "@/providers/theme-provider/theme-provider";
|
||||
import { toast } from "sonner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DatabaseControlButtons from "./database-control-buttons";
|
||||
|
||||
import { Modals } from "@/providers/modal-provider/modal-contxet";
|
||||
import { useModal } from "@/providers/modal-provider/modal-provider";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
|
||||
const DatabaseDiagram: React.FC = () => {
|
||||
const { open } = useModal();
|
||||
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { theme: resolvedTheme } = useTheme();
|
||||
// Extract database state and operations
|
||||
const { database, getField, isSwitchingDatabase, isLoading, databases } = useDatabase();
|
||||
|
||||
const { updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabaseOperations();
|
||||
|
||||
// Node and edge state hooks
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
// Diagram-related state (e.g. connection in progress)
|
||||
const { setIsConnectionInProgress, cardinalityStyle, showController, openController } = useDiagramOps();
|
||||
|
||||
// Destructure tables and relationships from database
|
||||
const { tables, relationships } = database || { tables: [], relationships: [] };
|
||||
|
||||
// Hook to allow zooming and centering the diagram
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Define custom node and edge types
|
||||
const nodeTypes = useMemo(() => ({ table: Table }), []);
|
||||
const edgeTypes = useMemo(() => ({ 'relationship-edge': Relationship }), []);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading)
|
||||
return;
|
||||
// no database selected , open (open database) Modal
|
||||
if (!database && databases?.length > 0) {
|
||||
|
||||
open(Modals.OPEN_DATABASE, {
|
||||
closable: false
|
||||
})
|
||||
}
|
||||
else if (databases.length == 0) {
|
||||
// there is no databases in the first place to select from ,
|
||||
// we have to create a new one .
|
||||
open(Modals.CREATE_DATABASE, {
|
||||
closable: false
|
||||
});
|
||||
} else {
|
||||
fitView({
|
||||
duration: 500
|
||||
});
|
||||
}
|
||||
}, [database?.id, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSwitchingDatabase) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
} else {
|
||||
fitView({
|
||||
duration: 500
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}, [isSwitchingDatabase, database?.id])
|
||||
|
||||
// Called when a connection is made between fields
|
||||
const onConnect = useCallback(async (connection: Connection) => {
|
||||
|
||||
const sourceId: string | undefined = (connection.sourceHandle as string).split("_").pop();
|
||||
const targetId: string = (connection.targetHandle as string).replace(TARGET_PREFIX, "");
|
||||
|
||||
const sourceField: FieldType = getField(connection.source, sourceId as string) as FieldType;
|
||||
const targetField: FieldType = getField(connection.target, targetId) as FieldType;
|
||||
|
||||
const { sourceTableId, targetTableId, sourceFieldId, targetFieldId } = getRelationshipSourceAndTarget(connection.source, sourceField, connection.target, targetField);
|
||||
|
||||
// Check if both fields have the same type (valid relationship)
|
||||
if (sourceField?.typeId == targetField?.typeId) {
|
||||
await createRelationship({
|
||||
id: v4(),
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
sourceFieldId,
|
||||
targetFieldId,
|
||||
|
||||
} as RelationshipInsertType);
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
toast(t("db_controller.invalid_relationship.title"), {
|
||||
description: t("db_controller.invalid_relationship.description"),
|
||||
classNames: {
|
||||
description: "!text-destructive",
|
||||
title: "!text-destructive"
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setIsConnectionInProgress(false);
|
||||
}, [database, t]);
|
||||
|
||||
// Called when nodes are updated (position changes or removed)
|
||||
const handleNodesChanges: OnNodesChange<never> = useCallback(async (changes: NodeChange<never>[]) => {
|
||||
|
||||
const nodePositionChanges: NodePositionChange[] = changes.filter((change: NodeChange) =>
|
||||
change.type == "position" && !change.dragging
|
||||
) as NodePositionChange[];
|
||||
|
||||
const nodeRemoveChanges: NodeRemoveChange[] = changes.filter((change: NodeChange) => change.type == "remove");
|
||||
// Save new positions to the database
|
||||
if (nodePositionChanges.length > 0)
|
||||
|
||||
updateTablePositions(nodePositionChanges.map((change: NodePositionChange) => ({
|
||||
id: change.id,
|
||||
posX: change.position?.x,
|
||||
posY: change.position?.y
|
||||
} as TableInsertType)));
|
||||
|
||||
|
||||
// Delete tables if removed
|
||||
if (nodeRemoveChanges.length > 0 && !isSwitchingDatabase) {
|
||||
deleteMultiTables(nodeRemoveChanges.map((change: NodeRemoveChange) => change.id));
|
||||
}
|
||||
return onNodesChange(changes);
|
||||
}, [onNodesChange, database, isSwitchingDatabase]);
|
||||
|
||||
// Called when edges (relationships) change
|
||||
const handleEdgeChanges: OnEdgesChange<any> = useCallback((changes: EdgeChange<any>[]) => {
|
||||
const edgeRemoveChanges: EdgeRemoveChange[] = changes.filter((change: EdgeChange) => change.type == "remove") as EdgeRemoveChange[];
|
||||
// Delete relationships from database
|
||||
if (edgeRemoveChanges.length > 0 && !isSwitchingDatabase) {
|
||||
deleteMultiRelationships(edgeRemoveChanges.map((change: EdgeRemoveChange) => change.id));
|
||||
}
|
||||
return onEdgesChange(changes as EdgeChange<never>[]);
|
||||
}, [onEdgesChange, isSwitchingDatabase]);
|
||||
|
||||
// When user starts connecting fields
|
||||
const onConnectStart = useCallback(() => {
|
||||
setIsConnectionInProgress(true);
|
||||
}, []);
|
||||
|
||||
// When user ends connecting fields
|
||||
const onConnectEnd = useCallback(() => {
|
||||
setIsConnectionInProgress(false);
|
||||
}, []);
|
||||
|
||||
// Automatically reposition tables to avoid overlap and fit the view
|
||||
const adjustPositions = useCallback(async () => {
|
||||
const adjustedTables = await adjustTablesPositions(nodes, relationships);
|
||||
|
||||
updateTablePositions(adjustedTables)
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
duration: 500
|
||||
});
|
||||
}, 300);
|
||||
}, [relationships, nodes]);
|
||||
|
||||
|
||||
// Convert tables and relationships into flow elements
|
||||
useTableToNode(tables);
|
||||
useRelationshipToEdge(relationships);
|
||||
useHighlightedEdges(nodes, relationships, edges);
|
||||
const { isOverlapping, puls } = useOverlappingTables(tables);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
return (
|
||||
|
||||
<div className="w-full h-full flex relative overflow-hidden">
|
||||
{
|
||||
(isSwitchingDatabase || isLoading) && <Loading />
|
||||
}
|
||||
|
||||
{
|
||||
<div className="relative w-full h-full ">
|
||||
<ReactFlow
|
||||
colorMode={resolvedTheme as ColorMode}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
fitView
|
||||
className="w-full h-full cursor-default "
|
||||
onNodesChange={handleNodesChanges}
|
||||
onEdgesChange={handleEdgeChanges}
|
||||
onConnect={onConnect}
|
||||
defaultEdgeOptions={{
|
||||
type: 'relationship-edge',
|
||||
animated: false
|
||||
}}
|
||||
panOnDrag={true}
|
||||
zoomOnScroll={true}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
snapGrid={[20, 20]}
|
||||
minZoom={0.10}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
|
||||
>
|
||||
<Controls
|
||||
position={isMobile ? "top-center" : "bottom-center"}
|
||||
showFitView={false}
|
||||
showZoom={false}
|
||||
showInteractive={false}
|
||||
className="!shadow-none !border-none">
|
||||
<DatabaseControlButtons
|
||||
adjustPositions={adjustPositions}
|
||||
/>
|
||||
</Controls >
|
||||
<MiniMap
|
||||
nodeStrokeWidth={4}
|
||||
className="!bg-background border-1 rounded-lg overflow-hidden "
|
||||
maskStrokeColor={resolvedTheme == "dark" ? "#FFFFFF1A" : "#e2e8f0"}
|
||||
maskColor={resolvedTheme == "dark" ? "#21262d77" : "#62748e05"}
|
||||
maskStrokeWidth={1}
|
||||
nodeClassName={"!fill-muted-foreground/20 "}
|
||||
style={{
|
||||
width: 164,
|
||||
height: 128
|
||||
}}
|
||||
/>
|
||||
<Background color="#62748e" className="dark:!bg-background " />
|
||||
|
||||
</ReactFlow>
|
||||
<div
|
||||
className={cn("absolute left-[24px] ", {
|
||||
"bottom-[24px]": !isMobile,
|
||||
"top-[24px]": isMobile
|
||||
})}
|
||||
>
|
||||
{
|
||||
isOverlapping &&
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="shadow-lg size-8"
|
||||
onClick={puls}>
|
||||
<AlertTriangle className="size-4 text-white" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-destructive fill-destructive ">
|
||||
{t("table.overlapping_tables")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
isMobile &&
|
||||
<Button size={"icon"} className="absolute bottom-[24px] left-[24px]"
|
||||
onClick={() => {
|
||||
openController(!showController)
|
||||
}}
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
|
||||
}
|
||||
{
|
||||
(cardinalityStyle != CardinalityStyle.HIDDEN) &&
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="start" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="start" selected />
|
||||
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="end" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="end" selected />
|
||||
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="start" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="start" selected />
|
||||
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="end" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="end" selected />
|
||||
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default DatabaseDiagram;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Outlet } from "react-router-dom"
|
||||
import { ResizableBox as ResizableBoxRaw } from 'react-resizable';
|
||||
import 'react-resizable/css/styles.css';
|
||||
|
||||
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
|
||||
|
||||
const ResizableBox = ResizableBoxRaw as unknown as React.FC<any>;
|
||||
|
||||
interface Props {
|
||||
|
||||
}
|
||||
|
||||
|
||||
const DBController: React.FC<Props> = ({ }) => {
|
||||
const [width, setWidth] = useState<number>(512);
|
||||
const { showController } = useDiagramOps()
|
||||
const [show, setShow] = useState<boolean>(!showController);
|
||||
const [style, setStyle] = useState<any>(undefined);
|
||||
const onResize = (event: any, params: any) => {
|
||||
setWidth(params.size.width);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!showController) {
|
||||
setWidth(0);
|
||||
setStyle({
|
||||
transition: 'width 0.5s',
|
||||
})
|
||||
setTimeout(() => {
|
||||
setShow(false);
|
||||
}, 500);
|
||||
|
||||
} else if (showController) {
|
||||
setShow(true);
|
||||
setTimeout(() => setWidth(486));
|
||||
setTimeout(() => {
|
||||
setStyle(undefined);
|
||||
}, 500)
|
||||
}
|
||||
}, [showController])
|
||||
|
||||
if (show)
|
||||
return (
|
||||
<ResizableBox
|
||||
width={width}
|
||||
onResize={onResize} className="min-h-full overflow-hidden "
|
||||
minConstraints={[486]}
|
||||
axis="x"
|
||||
style={style}
|
||||
handle={
|
||||
<div className="w-[6px] border-r-2 border-transparent h-full absolute right-0 top-0 cursor-ew-resize hover:border-primary/50 active:border-primary transition-colors duration-200 ">
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="min-w-[486px] w-full h-full bg-background dark:bg-card border-r ">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)
|
||||
else
|
||||
return;
|
||||
}
|
||||
|
||||
export default React.memo(DBController)
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ const RelationshipController: React.FC = ({ }) => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col pl-3 ">
|
||||
<div className="w-full h-full flex flex-col pl-3 min-h-0">
|
||||
|
||||
<div className="flex items-center pb-2 gap-2 pt-3 pr-3 ">
|
||||
<Tooltip >
|
||||
|
||||
@@ -128,7 +128,7 @@ const TablesController: React.FC = ({ }) => {
|
||||
}, [tables]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col pl-3 ">
|
||||
<div className="w-full h-full flex flex-col pl-3 min-h-0">
|
||||
<div className="flex items-center pb-2 gap-2 pt-3 pr-3 ">
|
||||
<Tooltip >
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
+6
-6
@@ -105,7 +105,7 @@ const FieldItem: React.FC<Props> = ({ field }) => {
|
||||
placeholder={t("db_controller.type")}
|
||||
selectedItem={selectedType}
|
||||
onSelectionChange={updateFieldType}
|
||||
className="flex flex-1 !bg-transparent !font-normal"
|
||||
className="flex flex-1 !bg-transparent !font-normal min-w-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-2 ">
|
||||
@@ -113,9 +113,9 @@ const FieldItem: React.FC<Props> = ({ field }) => {
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Toggle size={"sm"}
|
||||
// className="size-9 rounded-md bg-secondary data-[state=on]:bg-background dark:data-[state=on]:bg-card data-[state=on]:border-1 text-muted-foreground data-[state=on]:text-foreground/75 "
|
||||
className="size-9 text-muted-foreground data-[state=on]:border-1"
|
||||
pressed={!field.nullable as boolean}
|
||||
|
||||
className="size-9 text-muted-foreground "
|
||||
pressed={!field.nullable as boolean}
|
||||
onPressedChange={toggleNullable}
|
||||
>
|
||||
<IconKeyframe className="size-4" />
|
||||
@@ -130,8 +130,8 @@ const FieldItem: React.FC<Props> = ({ field }) => {
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Toggle size={"sm"}
|
||||
className="size-9 text-muted-foreground data-[state=on]:border-1"
|
||||
pressed={field.isPrimary as boolean}
|
||||
className="size-9 text-muted-foreground "
|
||||
pressed={field.isPrimary as boolean}
|
||||
onPressedChange={togglePrimaryKey}
|
||||
>
|
||||
<IconKey className="size-4" />
|
||||
|
||||
@@ -46,7 +46,7 @@ const RenameDB: React.FC = ({ }) => {
|
||||
});
|
||||
setIsLoading(false);
|
||||
setEditMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!database)
|
||||
@@ -59,7 +59,7 @@ const RenameDB: React.FC = ({ }) => {
|
||||
<Tooltip >
|
||||
<TooltipTrigger asChild>
|
||||
<label
|
||||
className=" w-full max-w-[256px] truncate h-8 text-sm font-medium flex gap-4 items-center justify-center cursor-pointer"
|
||||
className=" w-full max-w-[256px] truncate h-8 text-sm font-medium flex gap-4 items-center justify-center cursor-pointer"
|
||||
onDoubleClick={() => setEditMode(true)}
|
||||
>
|
||||
<img
|
||||
@@ -95,7 +95,7 @@ const RenameDB: React.FC = ({ }) => {
|
||||
value={dbName}
|
||||
onBlur={saveDatabaseName}
|
||||
autoFocus
|
||||
className="h-8"
|
||||
className="h-8 min-w-[164px]"
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
|
||||
import DatabaseDiagram from "./components/database-diagram";
|
||||
|
||||
export const DatabaseDesktopLayout: React.FC = () => {
|
||||
const { showController } = useDiagramOps()
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal">
|
||||
<ResizablePanel
|
||||
|
||||
minSize={25}
|
||||
maxSize={showController ? 99 : 0}
|
||||
className={cn('transition-[flex-grow] duration-500 bg-card ', {
|
||||
"min-w-[460px]": showController
|
||||
})}
|
||||
>
|
||||
<div className="min-w-[460px] h-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle
|
||||
disabled={!showController}
|
||||
className={cn("h-full cursor-ew-resize hover:bg-primary/50 active:border-primary transition-colors duration-200", !showController ? 'hidden' : '')}
|
||||
/>
|
||||
<ResizablePanel defaultSize={75} >
|
||||
|
||||
<DatabaseDiagram/>
|
||||
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatabaseDesktopLayout;
|
||||
+22
-328
@@ -1,340 +1,34 @@
|
||||
// Importing necessary types and hooks from React Flow (XYFlow)
|
||||
import {
|
||||
Background, ColorMode, Connection, Controls, EdgeChange,
|
||||
EdgeRemoveChange, MiniMap, NodeChange, NodePositionChange,
|
||||
NodeRemoveChange, OnEdgesChange, OnNodesChange,
|
||||
ReactFlow, useEdgesState, useNodesState, useReactFlow
|
||||
} from "@xyflow/react";
|
||||
|
||||
// React built-ins
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
|
||||
// Custom components and styles
|
||||
import Table from "./components/table";
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import Relationship from "./components/relationship";
|
||||
import DBController from "./components/db-controller";
|
||||
import { TableInsertType } from "@/lib/schemas/table-schema";
|
||||
|
||||
// Custom context providers and hooks
|
||||
import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider";
|
||||
import { useTableToNode } from "@/hooks/use-table-to-node";
|
||||
import { useRelationshipToEdge } from "@/hooks/use-relationship-to-edge";
|
||||
import { RelationshipInsertType } from "@/lib/schemas/relationship-schema";
|
||||
|
||||
// Utils and constants
|
||||
import { v4 } from "uuid";
|
||||
import { TARGET_PREFIX } from "./components/field";
|
||||
import CardinalityMarker from "@/features/database/components/cardinality-marker";
|
||||
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
|
||||
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { adjustTablesPositions } from "@/utils/tables";
|
||||
//import DatabaseControlButtons from "./database-control-buttons";
|
||||
import { FieldType } from "@/lib/schemas/field-schema";
|
||||
import useHighlightedEdges from "@/hooks/use-highlighted-edges";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useOverlappingTables from "@/hooks/use-overlapping-tables";
|
||||
|
||||
|
||||
import { getRelationshipSourceAndTarget } from "@/utils/relationship";
|
||||
|
||||
import React, { Suspense } from "react";
|
||||
import { Loading } from "@/components/layout/loading-modal";
|
||||
import { usePowerSync } from "@powersync/react";
|
||||
import { CardinalityStyle } from "@/lib/database";
|
||||
import { useTheme } from "@/providers/theme-provider/theme-provider";
|
||||
import { toast } from "sonner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import DatabaseControlButtons from "./components/database-control-buttons";
|
||||
import RenameDb from "./components/rename-db";
|
||||
import { Modals } from "@/providers/modal-provider/modal-contxet";
|
||||
import { useModal } from "@/providers/modal-provider/modal-provider";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
|
||||
|
||||
|
||||
const DatabaseDesktopLayout = React.lazy(
|
||||
() => import('./desktop-layout')
|
||||
);
|
||||
|
||||
const Database: React.FC = () => {
|
||||
const { open } = useModal();
|
||||
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { theme: resolvedTheme } = useTheme();
|
||||
// Extract database state and operations
|
||||
const { database, getField, isSwitchingDatabase, isLoading, databases } = useDatabase();
|
||||
|
||||
const { updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabaseOperations();
|
||||
|
||||
// Node and edge state hooks
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
// Diagram-related state (e.g. connection in progress)
|
||||
const { setIsConnectionInProgress, cardinalityStyle } = useDiagramOps();
|
||||
|
||||
// Destructure tables and relationships from database
|
||||
const { tables, relationships } = database || { tables: [], relationships: [] };
|
||||
|
||||
// Hook to allow zooming and centering the diagram
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// Define custom node and edge types
|
||||
const nodeTypes = useMemo(() => ({ table: Table }), []);
|
||||
const edgeTypes = useMemo(() => ({ 'relationship-edge': Relationship }), []);
|
||||
|
||||
const DatabaseMobileLayout = React.lazy(
|
||||
() => import('./mobile-layout')
|
||||
);
|
||||
export const DatabaseLayout: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading)
|
||||
return;
|
||||
// no database selected , open (open database) Modal
|
||||
if (!database && databases?.length > 0) {
|
||||
|
||||
open(Modals.OPEN_DATABASE, {
|
||||
closable: false
|
||||
})
|
||||
}
|
||||
else if (databases.length == 0) {
|
||||
// there is no databases in the first place to select from ,
|
||||
// we have to create a new one .
|
||||
open(Modals.CREATE_DATABASE, {
|
||||
closable: false
|
||||
});
|
||||
} else {
|
||||
fitView({
|
||||
duration: 500
|
||||
});
|
||||
}
|
||||
}, [database?.id, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
if (isSwitchingDatabase) {
|
||||
setNodes([]);
|
||||
setEdges([]);
|
||||
} else {
|
||||
fitView({
|
||||
duration: 500
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}, [isSwitchingDatabase, database?.id])
|
||||
|
||||
// Called when a connection is made between fields
|
||||
const onConnect = useCallback(async (connection: Connection) => {
|
||||
|
||||
const sourceId: string | undefined = (connection.sourceHandle as string).split("_").pop();
|
||||
const targetId: string = (connection.targetHandle as string).replace(TARGET_PREFIX, "");
|
||||
|
||||
const sourceField: FieldType = getField(connection.source, sourceId as string) as FieldType;
|
||||
const targetField: FieldType = getField(connection.target, targetId) as FieldType;
|
||||
|
||||
const { sourceTableId, targetTableId, sourceFieldId, targetFieldId } = getRelationshipSourceAndTarget(connection.source, sourceField, connection.target, targetField);
|
||||
|
||||
// Check if both fields have the same type (valid relationship)
|
||||
if (sourceField?.typeId == targetField?.typeId) {
|
||||
await createRelationship({
|
||||
id: v4(),
|
||||
sourceTableId,
|
||||
targetTableId,
|
||||
sourceFieldId,
|
||||
targetFieldId,
|
||||
|
||||
} as RelationshipInsertType);
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
toast(t("db_controller.invalid_relationship.title"), {
|
||||
description: t("db_controller.invalid_relationship.description"),
|
||||
classNames: {
|
||||
description: "!text-destructive",
|
||||
title: "!text-destructive"
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setIsConnectionInProgress(false);
|
||||
}, [database, t]);
|
||||
|
||||
// Called when nodes are updated (position changes or removed)
|
||||
const handleNodesChanges: OnNodesChange<never> = useCallback(async (changes: NodeChange<never>[]) => {
|
||||
|
||||
const nodePositionChanges: NodePositionChange[] = changes.filter((change: NodeChange) =>
|
||||
change.type == "position" && !change.dragging
|
||||
) as NodePositionChange[];
|
||||
|
||||
const nodeRemoveChanges: NodeRemoveChange[] = changes.filter((change: NodeChange) => change.type == "remove");
|
||||
// Save new positions to the database
|
||||
if (nodePositionChanges.length > 0)
|
||||
|
||||
updateTablePositions(nodePositionChanges.map((change: NodePositionChange) => ({
|
||||
id: change.id,
|
||||
posX: change.position?.x,
|
||||
posY: change.position?.y
|
||||
} as TableInsertType)));
|
||||
|
||||
|
||||
// Delete tables if removed
|
||||
if (nodeRemoveChanges.length > 0 && !isSwitchingDatabase) {
|
||||
deleteMultiTables(nodeRemoveChanges.map((change: NodeRemoveChange) => change.id));
|
||||
}
|
||||
return onNodesChange(changes);
|
||||
}, [onNodesChange, database, isSwitchingDatabase]);
|
||||
|
||||
// Called when edges (relationships) change
|
||||
const handleEdgeChanges: OnEdgesChange<any> = useCallback((changes: EdgeChange<any>[]) => {
|
||||
const edgeRemoveChanges: EdgeRemoveChange[] = changes.filter((change: EdgeChange) => change.type == "remove") as EdgeRemoveChange[];
|
||||
// Delete relationships from database
|
||||
if (edgeRemoveChanges.length > 0 && !isSwitchingDatabase) {
|
||||
deleteMultiRelationships(edgeRemoveChanges.map((change: EdgeRemoveChange) => change.id));
|
||||
}
|
||||
return onEdgesChange(changes as EdgeChange<never>[]);
|
||||
}, [onEdgesChange, isSwitchingDatabase]);
|
||||
|
||||
// When user starts connecting fields
|
||||
const onConnectStart = useCallback(() => {
|
||||
setIsConnectionInProgress(true);
|
||||
}, []);
|
||||
|
||||
// When user ends connecting fields
|
||||
const onConnectEnd = useCallback(() => {
|
||||
setIsConnectionInProgress(false);
|
||||
}, []);
|
||||
|
||||
// Automatically reposition tables to avoid overlap and fit the view
|
||||
const adjustPositions = useCallback(async () => {
|
||||
const adjustedTables = await adjustTablesPositions(nodes, relationships);
|
||||
|
||||
updateTablePositions(adjustedTables)
|
||||
setTimeout(() => {
|
||||
fitView({
|
||||
duration: 500
|
||||
});
|
||||
}, 300);
|
||||
}, [relationships, nodes]);
|
||||
|
||||
|
||||
// Convert tables and relationships into flow elements
|
||||
useTableToNode(tables);
|
||||
useRelationshipToEdge(relationships);
|
||||
useHighlightedEdges(nodes, relationships, edges);
|
||||
const { isOverlapping, puls } = useOverlappingTables(tables);
|
||||
const isMobile = useIsMobile() ;
|
||||
|
||||
return (
|
||||
|
||||
<div className="w-full h-screen flex relative overflow-hidden">
|
||||
{
|
||||
(isSwitchingDatabase || isLoading ) && <Loading />
|
||||
<Suspense
|
||||
fallback={
|
||||
<Loading/>
|
||||
}
|
||||
<div className="flex max-w-full">
|
||||
{
|
||||
<DBController />
|
||||
}
|
||||
</div>
|
||||
>
|
||||
{
|
||||
<div className="relative w-full h-full ">
|
||||
<ReactFlow
|
||||
colorMode={resolvedTheme as ColorMode}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
fitView
|
||||
className="w-full h-full cursor-default "
|
||||
onNodesChange={handleNodesChanges}
|
||||
onEdgesChange={handleEdgeChanges}
|
||||
onConnect={onConnect}
|
||||
defaultEdgeOptions={{
|
||||
type: 'relationship-edge',
|
||||
animated: false
|
||||
}}
|
||||
panOnDrag={true}
|
||||
zoomOnScroll={true}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
snapGrid={[20, 20]}
|
||||
minZoom={0.10}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
|
||||
>
|
||||
<Controls
|
||||
position="bottom-center"
|
||||
showFitView={false}
|
||||
showZoom={false}
|
||||
showInteractive={false}
|
||||
className="!shadow-none !border-none">
|
||||
<DatabaseControlButtons
|
||||
adjustPositions={adjustPositions}
|
||||
/>
|
||||
</Controls >
|
||||
<MiniMap
|
||||
nodeStrokeWidth={4}
|
||||
className="!bg-background border-1 rounded-lg overflow-hidden "
|
||||
maskStrokeColor={resolvedTheme == "dark" ? "#FFFFFF1A" : "#e2e8f0"}
|
||||
maskColor={resolvedTheme == "dark" ? "#21262d77" : "#62748e05"}
|
||||
maskStrokeWidth={1}
|
||||
nodeClassName={"!fill-muted-foreground/20 "}
|
||||
style={{
|
||||
width: 164,
|
||||
height: 128
|
||||
}}
|
||||
/>
|
||||
<Background color="#62748e" className="dark:!bg-background " />
|
||||
|
||||
</ReactFlow>
|
||||
<div
|
||||
className="absolute left-[24px] bottom-[24px] "
|
||||
>
|
||||
{
|
||||
isOverlapping &&
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="shadow-lg size-8"
|
||||
onClick={puls}>
|
||||
<AlertTriangle className="size-4 text-white" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-destructive fill-destructive ">
|
||||
{t("table.overlapping_tables")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
isMobile ? <DatabaseMobileLayout/> : <DatabaseDesktopLayout/>
|
||||
}
|
||||
{
|
||||
(cardinalityStyle != CardinalityStyle.HIDDEN) &&
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="start" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="start" selected />
|
||||
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="end" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="one" direction="end" selected />
|
||||
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="start" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="start" selected />
|
||||
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="end" />
|
||||
<CardinalityMarker style={cardinalityStyle} cardinality="many" direction="end" selected />
|
||||
|
||||
</defs>
|
||||
</svg>
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default Database;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatabaseLayout;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
|
||||
import { Outlet } from "react-router-dom";
|
||||
import DatabaseDiagram from "./components/database-diagram";
|
||||
import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
} from "@/components/ui/drawer"
|
||||
|
||||
|
||||
|
||||
|
||||
export const DatabaseMobileLayout: React.FC = () => {
|
||||
const { showController, openController } = useDiagramOps()
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
|
||||
<DatabaseDiagram/>
|
||||
<Drawer open={showController} onOpenChange={openController} >
|
||||
<DrawerContent className=" min-h-[80vh] h-[80vh] bg-card">
|
||||
<div className="mt-2 h-full overflow-hidden flex flex-col ">
|
||||
<Outlet />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DatabaseMobileLayout;
|
||||
@@ -88,7 +88,7 @@ const CreateRelationshipModal: React.FC<CreateRelationshipModalProps> = ({ onRel
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("db_controller.create_relationship")}
|
||||
actionName={t("modals.create")}
|
||||
className="min-w-[520px]"
|
||||
className="md:min-w-[520px] w-full"
|
||||
isDisabled={!isValid}
|
||||
actionHandler={addRelationship}
|
||||
>
|
||||
|
||||
@@ -26,7 +26,7 @@ const DeleteDatabaseModal: React.FC<ModalProps> = ({ isOpen, onOpenChange }) =>
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("modals.delete_database")}
|
||||
actionName={t("modals.delete")}
|
||||
className="min-w-[560px]"
|
||||
className="md:min-w-[560px] w-full"
|
||||
actionHandler={onDelete}
|
||||
variant="danger"
|
||||
>
|
||||
|
||||
@@ -15,13 +15,13 @@ const ExportSqlModal: React.FC<ModalProps> = ({ isOpen, onOpenChange }) => {
|
||||
isOpen={isOpen}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("modals.export_sql")}
|
||||
className="min-w-[960px] max-w-[960px] max-h-[90vh] "
|
||||
className="w-full lg:min-w-[960px]"
|
||||
description={t("modals.export_sql_header")}
|
||||
>
|
||||
<div className="h-full max-h-[72vh] ">
|
||||
<div className="h-full max-h-[72vh] min-w-0">
|
||||
<SqlPreview />
|
||||
</div>
|
||||
|
||||
|
||||
</Modal>
|
||||
|
||||
)
|
||||
|
||||
@@ -194,12 +194,12 @@ const ImportDatabaseModal: React.FC<ModalProps> = ({ isOpen, onOpenChange }) =>
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("modals.import_database.title")}
|
||||
actionName={t("modals.import_database.import")}
|
||||
className="!min-w-[860px] !max-w-[860px] flex flex-col"
|
||||
className="lg:min-w-[860px] md:min-w-[560px] w-full max-h-screen overflow-auto "
|
||||
actionHandler={onImport}
|
||||
isDisabled={!parsedDatabase}
|
||||
>
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<div className="w-full space-y-2">
|
||||
<div className="flex flex-col gap-4 !min-w-0 ">
|
||||
<div className="w-full space-y-2 !min-w-0 ">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("modals.import_database.import_options")}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import Database from '@/features/database/';
|
||||
import DatabaseLayout from '@/features/database';
|
||||
import RelationshipController from '@/features/database/components/db-controller/relationship-controller';
|
||||
import TablesController from '@/features/database/components/db-controller/tables-controller';
|
||||
import { Navigate, Route } from 'react-router-dom';
|
||||
@@ -8,7 +8,7 @@ import { Navigate, Route } from 'react-router-dom';
|
||||
const useDatabaseRoutes = () => {
|
||||
|
||||
return (
|
||||
<Route path="/database" element={<Database />} >
|
||||
<Route path="/database" element={<DatabaseLayout />} >
|
||||
<Route index element={<Navigate to="tables" replace />} />
|
||||
<Route path='tables' element={<TablesController />}></Route>
|
||||
<Route path='relationships' element={<RelationshipController />}></Route>
|
||||
|
||||
Reference in New Issue
Block a user