Skip to content

Commit

Permalink
feat(share): add sharing capabilities to import and export diagrams (c…
Browse files Browse the repository at this point in the history
…hartdb#365)

* feat(share): add sharing capabilities to import and export diagrams

* remove use client

* fix build

* add error parse indication

* add import from initial dialog

* fix build
  • Loading branch information
guyb1 authored Nov 10, 2024
1 parent 85e691f commit 94a5d84
Show file tree
Hide file tree
Showing 30 changed files with 1,377 additions and 478 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"timeago-react": "^3.0.6",
"vaul": "^0.9.1"
"vaul": "^0.9.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.1.0",
Expand Down
62 changes: 62 additions & 0 deletions src/components/alert/alert.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';

import { cn } from '@/lib/utils';

const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
}
);

const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
));
Alert.displayName = 'Alert';

const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn(
'mb-1 font-medium leading-none tracking-tight',
className
)}
{...props}
/>
));
AlertTitle.displayName = 'AlertTitle';

const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
));
AlertDescription.displayName = 'AlertDescription';

export { Alert, AlertTitle, AlertDescription };
168 changes: 168 additions & 0 deletions src/components/file-uploader/file-uploader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Upload, FileIcon, AlertCircle, Trash2 } from 'lucide-react';
import { Button } from '../button/button';

interface FileWithPreview extends File {
preview?: string;
}

export interface FileUploaderProps {
onFilesChange?: (files: File[]) => void;
multiple?: boolean;
supportedExtensions?: string[];
}

export const FileUploader: React.FC<FileUploaderProps> = ({
onFilesChange,
multiple,
supportedExtensions,
}) => {
const [files, setFiles] = useState<FileWithPreview[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);

const isFileSupported = useCallback(
(file: File) => {
if (!supportedExtensions) return true;
const fileExtension = file.name.split('.').pop()?.toLowerCase();
return fileExtension
? supportedExtensions.includes(`.${fileExtension}`)
: false;
},
[supportedExtensions]
);

const handleFiles = useCallback(
(selectedFiles: FileList) => {
const newFiles = Array.from(selectedFiles)
.filter((file) => {
if (!isFileSupported(file)) {
setError(
`File type not supported. Supported types: ${supportedExtensions?.join(', ')}`
);
return false;
}
return true;
})
.map((file) =>
Object.assign(file, { preview: URL.createObjectURL(file) })
);

if (newFiles.length === 0) return;

setError(null);
setFiles((prevFiles) => {
if (multiple) {
return [...prevFiles, ...newFiles];
} else {
return newFiles.slice(0, 1);
}
});
},
[multiple, supportedExtensions, isFileSupported]
);

const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(true);
}, []);

const onDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
}, []);

const onDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
},
[handleFiles]
);

useEffect(() => {
if (onFilesChange) {
onFilesChange(files.length > 0 ? files : []);
}
}, [files, onFilesChange]);

const removeFile = useCallback((fileToRemove: File) => {
setFiles((prevFiles) =>
prevFiles.filter((file) => file !== fileToRemove)
);
}, []);

return (
<div className="mx-auto w-full max-w-md">
<div
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragging
? 'border-primary bg-primary/10 dark:bg-primary/20'
: 'border-gray-300 hover:border-primary dark:border-gray-600 dark:hover:border-primary'
}`}
>
<input
type="file"
multiple={multiple}
onChange={(e) =>
e.target.files && handleFiles(e.target.files)
}
className="hidden"
id="fileInput"
accept={supportedExtensions?.join(',')}
/>
<label htmlFor="fileInput" className="cursor-pointer">
<Upload className="mx-auto size-12 text-gray-400 dark:text-gray-500" />
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{multiple
? 'Drag and drop files here or click to select'
: 'Drag and drop a file here or click to select'}
</p>
{supportedExtensions ? (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Supported types: {supportedExtensions.join(', ')}
</p>
) : null}
</label>
</div>

{error ? (
<div className="mt-4 flex items-center rounded-lg bg-red-100 p-3 text-red-700 dark:bg-red-900 dark:text-red-200">
<AlertCircle className="mr-2 size-5" />
<span className="text-sm">{error}</span>
</div>
) : null}

{files.length > 0 ? (
<ul className="mt-4 space-y-4">
{files.map((file) => (
<li
key={file.name}
className="flex items-center justify-between rounded-lg bg-gray-100 p-3 dark:bg-gray-800"
>
<div className="flex min-w-0 flex-1 items-center space-x-2">
<FileIcon className="size-5 text-primary" />
<span className="truncate text-sm font-medium text-gray-700 dark:text-gray-300">
{file.name}
</span>
</div>
<Button
variant="ghost"
className="size-5 p-0 hover:bg-primary-foreground"
onClick={() => removeFile(file)}
>
<Trash2 className="size-3.5 text-red-700" />
</Button>
</li>
))}
</ul>
) : null}
</div>
);
};
18 changes: 18 additions & 0 deletions src/context/dialog-context/dialog-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import type { TableSchemaDialogProps } from '@/dialogs/table-schema-dialog/table
import type { ImportDatabaseDialogProps } from '@/dialogs/import-database-dialog/import-database-dialog';
import type { ExportSQLDialogProps } from '@/dialogs/export-sql-dialog/export-sql-dialog';
import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/export-image-dialog';
import type { ExportDiagramDialogProps } from '@/dialogs/export-diagram-dialog/export-diagram-dialog';
import type { ImportDiagramDialogProps } from '@/dialogs/import-diagram-dialog/import-diagram-dialog';

export interface DialogContext {
// Create diagram dialog
Expand Down Expand Up @@ -48,6 +50,18 @@ export interface DialogContext {
params: Omit<ExportImageDialogProps, 'dialog'>
) => void;
closeExportImageDialog: () => void;

// Export diagram dialog
openExportDiagramDialog: (
params: Omit<ExportDiagramDialogProps, 'dialog'>
) => void;
closeExportDiagramDialog: () => void;

// Import diagram dialog
openImportDiagramDialog: (
params: Omit<ImportDiagramDialogProps, 'dialog'>
) => void;
closeImportDiagramDialog: () => void;
}

export const dialogContext = createContext<DialogContext>({
Expand All @@ -69,4 +83,8 @@ export const dialogContext = createContext<DialogContext>({
closeStarUsDialog: emptyFn,
openExportImageDialog: emptyFn,
closeExportImageDialog: emptyFn,
openExportDiagramDialog: emptyFn,
closeExportDiagramDialog: emptyFn,
openImportDiagramDialog: emptyFn,
closeImportDiagramDialog: emptyFn,
});
18 changes: 18 additions & 0 deletions src/context/dialog-context/dialog-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { emptyFn } from '@/lib/utils';
import { StarUsDialog } from '@/dialogs/star-us-dialog/star-us-dialog';
import type { ExportImageDialogProps } from '@/dialogs/export-image-dialog/export-image-dialog';
import { ExportImageDialog } from '@/dialogs/export-image-dialog/export-image-dialog';
import { ExportDiagramDialog } from '@/dialogs/export-diagram-dialog/export-diagram-dialog';
import { ImportDiagramDialog } from '@/dialogs/import-diagram-dialog/import-diagram-dialog';

export const DialogProvider: React.FC<React.PropsWithChildren> = ({
children,
Expand Down Expand Up @@ -86,6 +88,14 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
[setOpenTableSchemaDialog]
);

// Export image dialog
const [openExportDiagramDialog, setOpenExportDiagramDialog] =
useState(false);

// Import diagram dialog
const [openImportDiagramDialog, setOpenImportDiagramDialog] =
useState(false);

// Alert dialog
const [showAlert, setShowAlert] = useState(false);
const [alertParams, setAlertParams] = useState<BaseAlertDialogProps>({
Expand Down Expand Up @@ -126,6 +136,12 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
closeStarUsDialog: () => setOpenStarUsDialog(false),
closeExportImageDialog: () => setOpenExportImageDialog(false),
openExportImageDialog: openExportImageDialogHandler,
openExportDiagramDialog: () => setOpenExportDiagramDialog(true),
closeExportDiagramDialog: () =>
setOpenExportDiagramDialog(false),
openImportDiagramDialog: () => setOpenImportDiagramDialog(true),
closeImportDiagramDialog: () =>
setOpenImportDiagramDialog(false),
}}
>
{children}
Expand All @@ -152,6 +168,8 @@ export const DialogProvider: React.FC<React.PropsWithChildren> = ({
dialog={{ open: openExportImageDialog }}
{...exportImageDialogParams}
/>
<ExportDiagramDialog dialog={{ open: openExportDiagramDialog }} />
<ImportDiagramDialog dialog={{ open: openImportDiagramDialog }} />
</dialogContext.Provider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { DatabaseType } from '@/lib/domain/database-type';
import { useTranslation } from 'react-i18next';
import { SelectDatabaseContent } from './select-database-content';
import { useDialog } from '@/hooks/use-dialog';

export interface SelectDatabaseProps {
onContinue: () => void;
Expand All @@ -27,6 +28,7 @@ export const SelectDatabase: React.FC<SelectDatabaseProps> = ({
createNewDiagram,
}) => {
const { t } = useTranslation();
const { openImportDiagramDialog } = useDialog();

return (
<>
Expand All @@ -51,7 +53,13 @@ export const SelectDatabase: React.FC<SelectDatabaseProps> = ({
</Button>
</DialogClose>
) : (
<div></div>
<Button
type="button"
variant="ghost"
onClick={openImportDiagramDialog}
>
{t('new_diagram_dialog.import_from_file')}
</Button>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2">
<Button
Expand Down
Loading

0 comments on commit 94a5d84

Please sign in to comment.