mirror of
https://github.com/johndoe6345789/metabuilder.git
synced 2026-04-25 06:14:59 +00:00
Merge pull request #151 from johndoe6345789/codex/refactor-packageimportexport-into-modules
Refactor PackageImportExport into modular handlers
This commit is contained in:
@@ -1,32 +1,15 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Textarea } from '@/components/ui'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { useRef, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Database } from '@/lib/database'
|
||||
import { exportPackageAsZip, importPackageFromZip, downloadZip, exportDatabaseSnapshot } from '@/lib/packages/core/package-export'
|
||||
import type { PackageManifest, PackageContent } from '@/lib/package-types'
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
import { installPackage } from '@/lib/api/packages'
|
||||
import {
|
||||
Export,
|
||||
ArrowSquareIn,
|
||||
FileArchive,
|
||||
FileArrowDown,
|
||||
FileArrowUp,
|
||||
Package,
|
||||
CloudArrowDown,
|
||||
Database as DatabaseIcon,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Image as ImageIcon,
|
||||
} from '@phosphor-icons/react'
|
||||
import { createFileSelector } from './import-export/createFileSelector'
|
||||
import { executePackageImport } from './import-export/executePackageImport'
|
||||
import { generatePackageExport } from './import-export/generatePackageExport'
|
||||
import { generateSnapshotExport } from './import-export/generateSnapshotExport'
|
||||
import { validateManifest } from './import-export/validateManifest'
|
||||
import { defaultExportOptions, defaultManifest } from './import-export/defaults'
|
||||
import { ImportDialog } from './import-export/ImportDialog'
|
||||
import { ExportDialog } from './import-export/ExportDialog'
|
||||
|
||||
interface PackageImportExportProps {
|
||||
open: boolean
|
||||
@@ -34,82 +17,27 @@ interface PackageImportExportProps {
|
||||
mode: 'export' | 'import'
|
||||
}
|
||||
|
||||
const createInitialManifest = () => ({ ...defaultManifest, tags: [...(defaultManifest.tags || [])] })
|
||||
const createInitialExportOptions = () => ({ ...defaultExportOptions })
|
||||
|
||||
export function PackageImportExport({ open, onOpenChange, mode }: PackageImportExportProps) {
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [exportOptions, setExportOptions] = useState<ExportPackageOptions>({
|
||||
includeAssets: true,
|
||||
includeSchemas: true,
|
||||
includePages: true,
|
||||
includeWorkflows: true,
|
||||
includeLuaScripts: true,
|
||||
includeComponentHierarchy: true,
|
||||
includeComponentConfigs: true,
|
||||
includeCssClasses: true,
|
||||
includeDropdownConfigs: true,
|
||||
includeSeedData: true,
|
||||
})
|
||||
const [manifest, setManifest] = useState<Partial<PackageManifest>>({
|
||||
name: '',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
author: '',
|
||||
category: 'other',
|
||||
tags: [],
|
||||
})
|
||||
const [exportOptions, setExportOptions] = useState<ExportPackageOptions>(createInitialExportOptions)
|
||||
const [manifest, setManifest] = useState<Partial<PackageManifest>>(createInitialManifest)
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!manifest.name) {
|
||||
toast.error('Please provide a package name')
|
||||
const validationError = validateManifest(manifest)
|
||||
if (validationError) {
|
||||
toast.error(validationError)
|
||||
return
|
||||
}
|
||||
|
||||
setExporting(true)
|
||||
try {
|
||||
const schemas = await Database.getSchemas()
|
||||
const pages = await Database.getPages()
|
||||
const workflows = await Database.getWorkflows()
|
||||
const luaScripts = await Database.getLuaScripts()
|
||||
const componentHierarchy = await Database.getComponentHierarchy()
|
||||
const componentConfigs = await Database.getComponentConfigs()
|
||||
const cssClasses = await Database.getCssClasses()
|
||||
const dropdownConfigs = await Database.getDropdownConfigs()
|
||||
|
||||
const fullManifest: PackageManifest = {
|
||||
id: `pkg_${Date.now()}`,
|
||||
name: manifest.name!,
|
||||
version: manifest.version || '1.0.0',
|
||||
description: manifest.description || '',
|
||||
author: manifest.author || 'Anonymous',
|
||||
category: manifest.category as any || 'other',
|
||||
icon: '📦',
|
||||
screenshots: [],
|
||||
tags: manifest.tags || [],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
installed: false,
|
||||
}
|
||||
|
||||
const content: PackageContent = {
|
||||
schemas: exportOptions.includeSchemas ? schemas : [],
|
||||
pages: exportOptions.includePages ? pages : [],
|
||||
workflows: exportOptions.includeWorkflows ? workflows : [],
|
||||
luaScripts: exportOptions.includeLuaScripts ? luaScripts : [],
|
||||
componentHierarchy: exportOptions.includeComponentHierarchy ? componentHierarchy : {},
|
||||
componentConfigs: exportOptions.includeComponentConfigs ? componentConfigs : {},
|
||||
cssClasses: exportOptions.includeCssClasses ? cssClasses : undefined,
|
||||
dropdownConfigs: exportOptions.includeDropdownConfigs ? dropdownConfigs : undefined,
|
||||
}
|
||||
|
||||
const blob = await exportPackageAsZip(fullManifest, content, [], exportOptions)
|
||||
const fileName = `${manifest.name.toLowerCase().replace(/\s+/g, '-')}-${manifest.version}.zip`
|
||||
downloadZip(blob, fileName)
|
||||
|
||||
await generatePackageExport(manifest, exportOptions)
|
||||
toast.success('Package exported successfully!')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
@@ -123,29 +51,7 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
const handleExportSnapshot = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const schemas = await Database.getSchemas()
|
||||
const pages = await Database.getPages()
|
||||
const workflows = await Database.getWorkflows()
|
||||
const luaScripts = await Database.getLuaScripts()
|
||||
const componentHierarchy = await Database.getComponentHierarchy()
|
||||
const componentConfigs = await Database.getComponentConfigs()
|
||||
const cssClasses = await Database.getCssClasses()
|
||||
const dropdownConfigs = await Database.getDropdownConfigs()
|
||||
|
||||
const blob = await exportDatabaseSnapshot(
|
||||
schemas,
|
||||
pages,
|
||||
workflows,
|
||||
luaScripts,
|
||||
componentHierarchy,
|
||||
componentConfigs,
|
||||
cssClasses,
|
||||
dropdownConfigs
|
||||
)
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
downloadZip(blob, `database-snapshot-${timestamp}.zip`)
|
||||
|
||||
await generateSnapshotExport()
|
||||
toast.success('Database snapshot exported successfully!')
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
@@ -159,13 +65,11 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
const handleImport = async (file: File) => {
|
||||
setImporting(true)
|
||||
try {
|
||||
const { manifest: importedManifest, content, assets } = await importPackageFromZip(file)
|
||||
|
||||
await installPackage(importedManifest.id, { manifest: importedManifest, content })
|
||||
|
||||
const { manifest: importedManifest, content, assets } = await executePackageImport(file)
|
||||
toast.success(`Package "${importedManifest.name}" imported successfully!`)
|
||||
toast.info(`Imported: ${content.schemas.length} schemas, ${content.pages.length} pages, ${content.workflows.length} workflows, ${assets.length} assets`)
|
||||
|
||||
toast.info(
|
||||
`Imported: ${content.schemas.length} schemas, ${content.pages.length} pages, ${content.workflows.length} workflows, ${assets.length} assets`
|
||||
)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
console.error('Import error:', error)
|
||||
@@ -175,22 +79,11 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
toast.error('Please select a .zip file')
|
||||
return
|
||||
}
|
||||
handleImport(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !manifest.tags?.includes(tagInput.trim())) {
|
||||
setManifest(prev => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tagInput.trim()]
|
||||
tags: [...(prev.tags || []), tagInput.trim()],
|
||||
}))
|
||||
setTagInput('')
|
||||
}
|
||||
@@ -199,396 +92,39 @@ export function PackageImportExport({ open, onOpenChange, mode }: PackageImportE
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setManifest(prev => ({
|
||||
...prev,
|
||||
tags: (prev.tags || []).filter(t => t !== tag)
|
||||
tags: (prev.tags || []).filter(t => t !== tag),
|
||||
}))
|
||||
}
|
||||
|
||||
const handleFileSelect = createFileSelector(handleImport, message => toast.error(message))
|
||||
|
||||
if (mode === 'import') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<ArrowSquareIn size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Import Package</DialogTitle>
|
||||
<DialogDescription>Import a package from a ZIP file</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Select Package File</CardTitle>
|
||||
<CardDescription>Choose a .zip file containing a MetaBuilder package</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center hover:border-primary hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileArrowUp size={48} className="mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="font-medium mb-1">Click to select a package file</p>
|
||||
<p className="text-sm text-muted-foreground">Supports .zip files only</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{importing && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Importing package...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">What's Included in Packages?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Data schemas</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Page configurations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Workflows</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Lua scripts</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Component hierarchies</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>CSS configurations</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Assets (images, etc.)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>Seed data</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 flex items-start gap-3">
|
||||
<Warning size={20} className="text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-900 dark:text-yellow-100 mb-1">Import Warning</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">Imported packages will be merged with existing data. Make sure to back up your database before importing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ImportDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileSelect={handleFileSelect}
|
||||
importing={importing}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Export size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Export Package</DialogTitle>
|
||||
<DialogDescription>Create a shareable package or database snapshot</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Package size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Custom Package</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export selected data as a reusable package</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="cursor-pointer hover:border-primary transition-colors"
|
||||
onClick={handleExportSnapshot}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<DatabaseIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Full Snapshot</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export entire database as backup</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="package-name">Package Name *</Label>
|
||||
<Input
|
||||
id="package-name"
|
||||
placeholder="My Awesome Package"
|
||||
value={manifest.name}
|
||||
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="package-version">Version</Label>
|
||||
<Input
|
||||
id="package-version"
|
||||
placeholder="1.0.0"
|
||||
value={manifest.version}
|
||||
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-author">Author</Label>
|
||||
<Input
|
||||
id="package-author"
|
||||
placeholder="Your Name"
|
||||
value={manifest.author}
|
||||
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-description">Description</Label>
|
||||
<Textarea
|
||||
id="package-description"
|
||||
placeholder="Describe what this package does..."
|
||||
value={manifest.description}
|
||||
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-tags">Tags</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
id="package-tags"
|
||||
placeholder="Add a tag..."
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
/>
|
||||
<Button type="button" onClick={handleAddTag}>Add</Button>
|
||||
</div>
|
||||
{manifest.tags && manifest.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{manifest.tags.map(tag => (
|
||||
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Export Options</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-schemas"
|
||||
checked={exportOptions.includeSchemas}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeSchemas: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-schemas" className="font-normal cursor-pointer">
|
||||
Include data schemas
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-pages"
|
||||
checked={exportOptions.includePages}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includePages: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-pages" className="font-normal cursor-pointer">
|
||||
Include page configurations
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-workflows"
|
||||
checked={exportOptions.includeWorkflows}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeWorkflows: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-workflows" className="font-normal cursor-pointer">
|
||||
Include workflows
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-lua"
|
||||
checked={exportOptions.includeLuaScripts}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeLuaScripts: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-lua" className="font-normal cursor-pointer">
|
||||
Include Lua scripts
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-components"
|
||||
checked={exportOptions.includeComponentHierarchy}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeComponentHierarchy: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-components" className="font-normal cursor-pointer">
|
||||
Include component hierarchies
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-configs"
|
||||
checked={exportOptions.includeComponentConfigs}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeComponentConfigs: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-configs" className="font-normal cursor-pointer">
|
||||
Include component configurations
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-css"
|
||||
checked={exportOptions.includeCssClasses}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeCssClasses: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-css" className="font-normal cursor-pointer">
|
||||
Include CSS classes
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-dropdowns"
|
||||
checked={exportOptions.includeDropdownConfigs}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeDropdownConfigs: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-dropdowns" className="font-normal cursor-pointer">
|
||||
Include dropdown configurations
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-seed"
|
||||
checked={exportOptions.includeSeedData}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeSeedData: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-seed" className="font-normal cursor-pointer">
|
||||
Include seed data
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="export-assets"
|
||||
checked={exportOptions.includeAssets}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, includeAssets: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="export-assets" className="font-normal cursor-pointer">
|
||||
Include assets (images, videos, audio, documents)
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={exporting || !manifest.name}>
|
||||
{exporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileArrowDown size={16} className="mr-2" />
|
||||
Export Package
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ExportDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
manifest={manifest}
|
||||
setManifest={setManifest}
|
||||
tagInput={tagInput}
|
||||
setTagInput={setTagInput}
|
||||
onAddTag={handleAddTag}
|
||||
onRemoveTag={handleRemoveTag}
|
||||
exportOptions={exportOptions}
|
||||
setExportOptions={setExportOptions}
|
||||
exporting={exporting}
|
||||
onExport={handleExport}
|
||||
onExportSnapshot={handleExportSnapshot}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type React from 'react'
|
||||
import { Button } from '@/components/ui'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { Label } from '@/components/ui'
|
||||
import { Input } from '@/components/ui'
|
||||
import { Textarea } from '@/components/ui'
|
||||
import { Checkbox } from '@/components/ui'
|
||||
import { ScrollArea } from '@/components/ui'
|
||||
import { Separator } from '@/components/ui'
|
||||
import { Export, Package, Database as DatabaseIcon, FileArrowDown } from '@phosphor-icons/react'
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
|
||||
const exportOptionLabels: { key: keyof ExportPackageOptions; label: string }[] = [
|
||||
{ key: 'includeSchemas', label: 'Include data schemas' },
|
||||
{ key: 'includePages', label: 'Include page configurations' },
|
||||
{ key: 'includeWorkflows', label: 'Include workflows' },
|
||||
{ key: 'includeLuaScripts', label: 'Include Lua scripts' },
|
||||
{ key: 'includeComponentHierarchy', label: 'Include component hierarchies' },
|
||||
{ key: 'includeComponentConfigs', label: 'Include component configurations' },
|
||||
{ key: 'includeCssClasses', label: 'Include CSS classes' },
|
||||
{ key: 'includeDropdownConfigs', label: 'Include dropdown configurations' },
|
||||
{ key: 'includeSeedData', label: 'Include seed data' },
|
||||
{ key: 'includeAssets', label: 'Include assets (images, videos, audio, documents)' },
|
||||
]
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
manifest: Partial<PackageManifest>
|
||||
setManifest: React.Dispatch<React.SetStateAction<Partial<PackageManifest>>>
|
||||
tagInput: string
|
||||
setTagInput: (value: string) => void
|
||||
onAddTag: () => void
|
||||
onRemoveTag: (tag: string) => void
|
||||
exportOptions: ExportPackageOptions
|
||||
setExportOptions: React.Dispatch<React.SetStateAction<ExportPackageOptions>>
|
||||
exporting: boolean
|
||||
onExport: () => void
|
||||
onExportSnapshot: () => void
|
||||
}
|
||||
|
||||
export const ExportDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
manifest,
|
||||
setManifest,
|
||||
tagInput,
|
||||
setTagInput,
|
||||
onAddTag,
|
||||
onRemoveTag,
|
||||
exportOptions,
|
||||
setExportOptions,
|
||||
exporting,
|
||||
onExport,
|
||||
onExportSnapshot,
|
||||
}: ExportDialogProps) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Export size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Export Package</DialogTitle>
|
||||
<DialogDescription>Create a shareable package or database snapshot</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 -mx-6 px-6">
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card className="cursor-pointer hover:border-primary transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-green-500 to-green-700 flex items-center justify-center">
|
||||
<Package size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Custom Package</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export selected data as a reusable package</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary transition-colors" onClick={onExportSnapshot}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<DatabaseIcon size={20} className="text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-base">Full Snapshot</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Export entire database as backup</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="package-name">Package Name *</Label>
|
||||
<Input
|
||||
id="package-name"
|
||||
placeholder="My Awesome Package"
|
||||
value={manifest.name}
|
||||
onChange={e => setManifest(prev => ({ ...prev, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="package-version">Version</Label>
|
||||
<Input
|
||||
id="package-version"
|
||||
placeholder="1.0.0"
|
||||
value={manifest.version}
|
||||
onChange={e => setManifest(prev => ({ ...prev, version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-author">Author</Label>
|
||||
<Input
|
||||
id="package-author"
|
||||
placeholder="Your Name"
|
||||
value={manifest.author}
|
||||
onChange={e => setManifest(prev => ({ ...prev, author: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-description">Description</Label>
|
||||
<Textarea
|
||||
id="package-description"
|
||||
placeholder="Describe what this package does..."
|
||||
value={manifest.description}
|
||||
onChange={e => setManifest(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="package-tags">Tags</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Input
|
||||
id="package-tags"
|
||||
placeholder="Add a tag..."
|
||||
value={tagInput}
|
||||
onChange={e => setTagInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
|
||||
/>
|
||||
<Button type="button" onClick={onAddTag}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{manifest.tags && manifest.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{manifest.tags.map(tag => (
|
||||
<div key={tag} className="px-2 py-1 bg-secondary rounded-md text-sm flex items-center gap-2">
|
||||
<span>{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveTag(tag)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<Label className="mb-3 block">Export Options</Label>
|
||||
<div className="space-y-3">
|
||||
{exportOptionLabels.map(({ key, label }) => (
|
||||
<div className="flex items-center gap-2" key={key}>
|
||||
<Checkbox
|
||||
id={`export-${key}`}
|
||||
checked={exportOptions[key] as boolean}
|
||||
onCheckedChange={checked =>
|
||||
setExportOptions(prev => ({ ...prev, [key]: checked as boolean }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor={`export-${key}`} className="font-normal cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onExport} disabled={exporting || !manifest.name}>
|
||||
{exporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2" />
|
||||
Exporting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileArrowDown size={16} className="mr-2" />
|
||||
Export Package
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
import type React from 'react'
|
||||
import { ArrowSquareIn, FileArrowUp } from '@phosphor-icons/react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { ImportStatus } from './StatusUI'
|
||||
|
||||
interface ImportDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
fileInputRef: React.RefObject<HTMLInputElement>
|
||||
onFileSelect: (event: React.ChangeEvent<HTMLInputElement>) => void
|
||||
importing: boolean
|
||||
}
|
||||
|
||||
export const ImportDialog = ({ open, onOpenChange, fileInputRef, onFileSelect, importing }: ImportDialogProps) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center">
|
||||
<ArrowSquareIn size={24} weight="duotone" className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>Import Package</DialogTitle>
|
||||
<DialogDescription>Import a package from a ZIP file</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<ImportStatus
|
||||
importing={importing}
|
||||
selectionSlot={
|
||||
<div
|
||||
className="border-2 border-dashed rounded-lg p-8 text-center hover:border-primary hover:bg-accent/50 transition-colors cursor-pointer"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<FileArrowUp size={48} className="mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="font-medium mb-1">Click to select a package file</p>
|
||||
<p className="text-sm text-muted-foreground">Supports .zip files only</p>
|
||||
<input ref={fileInputRef} type="file" accept=".zip" onChange={onFileSelect} className="hidden" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { CheckCircle, Warning } from '@phosphor-icons/react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
|
||||
|
||||
interface ImportStatusProps {
|
||||
importing: boolean
|
||||
selectionSlot: ReactNode
|
||||
}
|
||||
|
||||
export const ImportStatus = ({ importing, selectionSlot }: ImportStatusProps) => (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Select Package File</CardTitle>
|
||||
<CardDescription>Choose a .zip file containing a MetaBuilder package</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectionSlot}
|
||||
{importing && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
<span>Importing package...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">What's Included in Packages?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
{['Data schemas', 'Page configurations', 'Workflows', 'Lua scripts', 'Component hierarchies', 'CSS configurations', 'Assets (images, etc.)', 'Seed data'].map(item => (
|
||||
<div key={item} className="flex items-center gap-2">
|
||||
<CheckCircle size={16} className="text-green-500" />
|
||||
<span>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20 flex items-start gap-3">
|
||||
<Warning size={20} className="text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-900 dark:text-yellow-100 mb-1">Import Warning</p>
|
||||
<p className="text-yellow-800 dark:text-yellow-200">
|
||||
Imported packages will be merged with existing data. Make sure to back up your database before importing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import type React from 'react'
|
||||
|
||||
export const createFileSelector = (
|
||||
onValidFile: (file: File) => void,
|
||||
onInvalid: (message: string) => void
|
||||
) =>
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!file.name.endsWith('.zip')) {
|
||||
onInvalid('Please select a .zip file')
|
||||
return
|
||||
}
|
||||
|
||||
onValidFile(file)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
|
||||
export const defaultExportOptions: ExportPackageOptions = {
|
||||
includeAssets: true,
|
||||
includeSchemas: true,
|
||||
includePages: true,
|
||||
includeWorkflows: true,
|
||||
includeLuaScripts: true,
|
||||
includeComponentHierarchy: true,
|
||||
includeComponentConfigs: true,
|
||||
includeCssClasses: true,
|
||||
includeDropdownConfigs: true,
|
||||
includeSeedData: true,
|
||||
}
|
||||
|
||||
export const defaultManifest: Partial<PackageManifest> = {
|
||||
name: '',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
author: '',
|
||||
category: 'other',
|
||||
tags: [],
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { installPackage } from '@/lib/api/packages'
|
||||
import { importPackageFromZip } from '@/lib/packages/core/package-export'
|
||||
|
||||
export const executePackageImport = async (file: File) => {
|
||||
const { manifest, content, assets } = await importPackageFromZip(file)
|
||||
await installPackage(manifest.id, { manifest, content })
|
||||
|
||||
return { manifest, content, assets }
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Database } from '@/lib/database'
|
||||
import { downloadZip, exportPackageAsZip } from '@/lib/packages/core/package-export'
|
||||
import type { ExportPackageOptions } from '@/lib/packages/core/package-export'
|
||||
import type { PackageContent, PackageManifest } from '@/lib/package-types'
|
||||
|
||||
const buildManifest = (manifest: Partial<PackageManifest>): PackageManifest => ({
|
||||
id: `pkg_${Date.now()}`,
|
||||
name: manifest.name!,
|
||||
version: manifest.version || '1.0.0',
|
||||
description: manifest.description || '',
|
||||
author: manifest.author || 'Anonymous',
|
||||
category: (manifest.category as any) || 'other',
|
||||
icon: '📦',
|
||||
screenshots: [],
|
||||
tags: manifest.tags || [],
|
||||
dependencies: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
downloadCount: 0,
|
||||
rating: 0,
|
||||
installed: false,
|
||||
})
|
||||
|
||||
const buildContent = async (exportOptions: ExportPackageOptions): Promise<PackageContent> => {
|
||||
const [schemas, pages, workflows, luaScripts, componentHierarchy, componentConfigs, cssClasses, dropdownConfigs] =
|
||||
await Promise.all([
|
||||
Database.getSchemas(),
|
||||
Database.getPages(),
|
||||
Database.getWorkflows(),
|
||||
Database.getLuaScripts(),
|
||||
Database.getComponentHierarchy(),
|
||||
Database.getComponentConfigs(),
|
||||
Database.getCssClasses(),
|
||||
Database.getDropdownConfigs(),
|
||||
])
|
||||
|
||||
return {
|
||||
schemas: exportOptions.includeSchemas ? schemas : [],
|
||||
pages: exportOptions.includePages ? pages : [],
|
||||
workflows: exportOptions.includeWorkflows ? workflows : [],
|
||||
luaScripts: exportOptions.includeLuaScripts ? luaScripts : [],
|
||||
componentHierarchy: exportOptions.includeComponentHierarchy ? componentHierarchy : {},
|
||||
componentConfigs: exportOptions.includeComponentConfigs ? componentConfigs : {},
|
||||
cssClasses: exportOptions.includeCssClasses ? cssClasses : undefined,
|
||||
dropdownConfigs: exportOptions.includeDropdownConfigs ? dropdownConfigs : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const generatePackageExport = async (
|
||||
manifest: Partial<PackageManifest>,
|
||||
exportOptions: ExportPackageOptions
|
||||
) => {
|
||||
const fullManifest = buildManifest(manifest)
|
||||
const content = await buildContent(exportOptions)
|
||||
const blob = await exportPackageAsZip(fullManifest, content, [], exportOptions)
|
||||
const version = manifest.version || '1.0.0'
|
||||
const sanitizedName = manifest.name?.toLowerCase().replace(/\s+/g, '-') || 'package'
|
||||
const fileName = `${sanitizedName}-${version}.zip`
|
||||
|
||||
downloadZip(blob, fileName)
|
||||
|
||||
return { fileName }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Database } from '@/lib/database'
|
||||
import { downloadZip, exportDatabaseSnapshot } from '@/lib/packages/core/package-export'
|
||||
|
||||
export const generateSnapshotExport = async () => {
|
||||
const [schemas, pages, workflows, luaScripts, componentHierarchy, componentConfigs, cssClasses, dropdownConfigs] =
|
||||
await Promise.all([
|
||||
Database.getSchemas(),
|
||||
Database.getPages(),
|
||||
Database.getWorkflows(),
|
||||
Database.getLuaScripts(),
|
||||
Database.getComponentHierarchy(),
|
||||
Database.getComponentConfigs(),
|
||||
Database.getCssClasses(),
|
||||
Database.getDropdownConfigs(),
|
||||
])
|
||||
|
||||
const blob = await exportDatabaseSnapshot(
|
||||
schemas,
|
||||
pages,
|
||||
workflows,
|
||||
luaScripts,
|
||||
componentHierarchy,
|
||||
componentConfigs,
|
||||
cssClasses,
|
||||
dropdownConfigs
|
||||
)
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
|
||||
downloadZip(blob, `database-snapshot-${timestamp}.zip`)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { PackageManifest } from '@/lib/package-types'
|
||||
|
||||
export const validateManifest = (manifest: Partial<PackageManifest>) => {
|
||||
if (!manifest.name?.trim()) {
|
||||
return 'Please provide a package name'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user