test docker stuff
This commit is contained in:
parent
347aadbfc9
commit
52e1d6ff6b
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/.idea
|
||||||
|
**/node_modules
|
||||||
|
**/*.user
|
||||||
|
**/*.md
|
||||||
|
**/Dockerfile*
|
||||||
|
**/.dockerignore
|
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy csproj and restore dependencies
|
||||||
|
COPY ["SessionZero.sln", "./"]
|
||||||
|
COPY ["SessionZero/SessionZero.csproj", "SessionZero/"]
|
||||||
|
RUN dotnet restore
|
||||||
|
|
||||||
|
# Copy the rest of the files and build
|
||||||
|
COPY . .
|
||||||
|
RUN dotnet build "SessionZero/SessionZero.csproj" -c Release -o /app/build
|
||||||
|
|
||||||
|
# Publish the application
|
||||||
|
RUN dotnet publish "SessionZero/SessionZero.csproj" -c Release -o /app/publish
|
||||||
|
|
||||||
|
# Use the official ASP.NET Core runtime image
|
||||||
|
FROM nginx:alpine AS final
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
COPY --from=build /app/publish/wwwroot .
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
EXPOSE 443
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
68
README.docker.md
Normal file
68
README.docker.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# SessionZero Docker Deployment Guide
|
||||||
|
|
||||||
|
This guide explains how to build and deploy the SessionZero Blazor WebAssembly PWA using Docker.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker installed on your machine
|
||||||
|
- Docker Compose installed on your machine (optional, but recommended)
|
||||||
|
|
||||||
|
## Building and Running with Docker Compose (Recommended)
|
||||||
|
|
||||||
|
1. Navigate to the project root directory (where the `docker-compose.yml` file is located)
|
||||||
|
2. Run the following command to build and start the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Access the application at http://localhost:8080
|
||||||
|
|
||||||
|
## Building and Running with Docker CLI
|
||||||
|
|
||||||
|
1. Navigate to the project root directory (where the `Dockerfile` is located)
|
||||||
|
2. Build the Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t sessionzero .
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 8080:80 --name sessionzero-app sessionzero
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Access the application at http://localhost:8080
|
||||||
|
|
||||||
|
## Stopping the Container
|
||||||
|
|
||||||
|
### With Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Docker CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop sessionzero-app
|
||||||
|
docker rm sessionzero-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If you encounter any issues with the container not starting, check the logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs sessionzero-app
|
||||||
|
```
|
||||||
|
|
||||||
|
- Make sure ports 8080 is not being used by another application on your host machine.
|
||||||
|
|
||||||
|
- If the application doesn't work as expected, verify that all files were copied correctly by inspecting the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it sessionzero-app sh
|
||||||
|
ls -la /usr/share/nginx/html
|
||||||
|
```
|
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
sessionzero:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:80" # Map container port 80 to host port 8080
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- nginx-cache:/var/cache/nginx
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
nginx-cache:
|
40
nginx.conf
Normal file
40
nginx.conf
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enable compression
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
|
||||||
|
# Configure caching for static assets
|
||||||
|
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Special handling for service worker
|
||||||
|
location = /service-worker.js {
|
||||||
|
expires -1;
|
||||||
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
320
old-react-datasets.txt
Normal file
320
old-react-datasets.txt
Normal file
@ -0,0 +1,320 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import './DatasetManager.css';
|
||||||
|
import { Dataset, LibraryEntry, DataRecord, DataValue } from '../../types';
|
||||||
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { AddCircleIcon, Delete02Icon, FolderAddIcon, Edit02Icon } from "@hugeicons/core-free-icons";
|
||||||
|
|
||||||
|
// Reusable Modal Component
|
||||||
|
const Modal: React.FC<{
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
actions: { label: string; onClick: () => void; className?: string }[];
|
||||||
|
size?: 'default' | 'large';
|
||||||
|
}> = ({ isOpen, onClose, title, children, actions, size = 'default' }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const modalContentClass = `modal-content ${size === 'large' ? 'entry-editor-content' : ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className={modalContentClass} onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
<div className="modal-actions">
|
||||||
|
{actions.map((action, index) => (
|
||||||
|
<button key={index} onClick={action.onClick} className={`builder-btn ${action.className || ''}`}>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data Record Editor Component
|
||||||
|
const DataRecordEditor = ({ data, onChange }: { data: DataRecord, onChange: (newData: DataRecord) => void }) => {
|
||||||
|
const [addingInfo, setAddingInfo] = useState<{type: 'field' | 'group'} | null>(null);
|
||||||
|
const [newKey, setNewKey] = useState('');
|
||||||
|
const newKeyInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (addingInfo) {
|
||||||
|
newKeyInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [addingInfo]);
|
||||||
|
|
||||||
|
const handleFieldChange = (key: string, value: DataValue) => {
|
||||||
|
onChange({ ...data, [key]: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveField = (key: string) => {
|
||||||
|
const { [key]: _, ...rest } = data;
|
||||||
|
onChange(rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInitiateAdd = (type: 'field' | 'group') => {
|
||||||
|
setAddingInfo({ type });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmAdd = () => {
|
||||||
|
const key = newKey.trim();
|
||||||
|
if (key && !data.hasOwnProperty(key)) {
|
||||||
|
onChange({ ...data, [key]: addingInfo?.type === 'group' ? {} : '' });
|
||||||
|
setNewKey('');
|
||||||
|
setAddingInfo(null);
|
||||||
|
} else if (key) {
|
||||||
|
alert(`The key "${key}" already exists.`);
|
||||||
|
} else {
|
||||||
|
setAddingInfo(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="data-record-editor">
|
||||||
|
{Object.keys(data).length === 0 && <p className="placeholder-text">This entry is empty. Add a field or group to begin.</p>}
|
||||||
|
{Object.entries(data).map(([key, value]) => (
|
||||||
|
<div key={key} className="data-field-row">
|
||||||
|
<label>{key}</label>
|
||||||
|
<div className="data-field-input-group">
|
||||||
|
{typeof value === 'object' && value !== null ?
|
||||||
|
(
|
||||||
|
<div className="data-group-container">
|
||||||
|
<DataRecordEditor
|
||||||
|
data={value as DataRecord}
|
||||||
|
onChange={(newValue) => handleFieldChange(key, newValue)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) :
|
||||||
|
( <textarea value={value as string || ''} onChange={e => handleFieldChange(key, e.target.value)} rows={3} /> )
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleRemoveField(key)} className="builder-btn remove-btn">
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16}/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{addingInfo ? (
|
||||||
|
<div className="add-field-form">
|
||||||
|
<input
|
||||||
|
ref={newKeyInputRef}
|
||||||
|
type="text"
|
||||||
|
value={newKey}
|
||||||
|
onChange={e => setNewKey(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === 'Enter' && handleConfirmAdd()}
|
||||||
|
placeholder={`New ${addingInfo.type} name...`}
|
||||||
|
/>
|
||||||
|
<button className="builder-btn save-btn" onClick={handleConfirmAdd}>Save</button>
|
||||||
|
<button className="builder-btn load-btn" onClick={() => setAddingInfo(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="data-editor-actions">
|
||||||
|
<button className="builder-btn add-btn" onClick={() => handleInitiateAdd('field')}><HugeiconsIcon icon={AddCircleIcon} size={14}/> Add Field</button>
|
||||||
|
<button className="builder-btn add-btn" onClick={() => handleInitiateAdd('group')}><HugeiconsIcon icon={FolderAddIcon} size={14}/> Add Group</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main Dataset Manager Component
|
||||||
|
function DatasetManager() {
|
||||||
|
const [datasets, setDatasets] = useState<Dataset[]>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('libraryDatasets');
|
||||||
|
return stored ? JSON.parse(stored) : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse datasets from localStorage", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selectedDatasetId, setSelectedDatasetId] = useState<number | null>(null);
|
||||||
|
const [editingEntry, setEditingEntry] = useState<LibraryEntry | null>(null);
|
||||||
|
const [showCreateDatasetModal, setShowCreateDatasetModal] = useState(false);
|
||||||
|
const [newDatasetInfo, setNewDatasetInfo] = useState({ name: '', type: 'generic' });
|
||||||
|
const [confirmDeleteInfo, setConfirmDeleteInfo] = useState<{type: 'dataset'|'entry', id: number, name: string} | null>(null);
|
||||||
|
|
||||||
|
// Derive selectedDataset from state - this is our single source of truth
|
||||||
|
const selectedDataset = datasets.find(d => d.id === selectedDatasetId) || null;
|
||||||
|
|
||||||
|
// Effect to save to localStorage whenever datasets change
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('libraryDatasets', JSON.stringify(datasets));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save datasets to localStorage", error);
|
||||||
|
}
|
||||||
|
}, [datasets]);
|
||||||
|
|
||||||
|
const handleSaveNewDataset = () => {
|
||||||
|
if (!newDatasetInfo.name.trim()) {
|
||||||
|
alert("Dataset name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newDataset: Dataset = { id: Date.now(), name: newDatasetInfo.name, type: newDatasetInfo.type || 'generic', entries: [] };
|
||||||
|
setDatasets([...datasets, newDataset]);
|
||||||
|
setShowCreateDatasetModal(false);
|
||||||
|
setNewDatasetInfo({ name: '', type: 'generic' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExecuteDelete = () => {
|
||||||
|
if (!confirmDeleteInfo) return;
|
||||||
|
const { type, id } = confirmDeleteInfo;
|
||||||
|
|
||||||
|
if (type === 'dataset') {
|
||||||
|
setDatasets(datasets.filter(d => d.id !== id));
|
||||||
|
if (selectedDatasetId === id) {
|
||||||
|
setSelectedDatasetId(null);
|
||||||
|
setEditingEntry(null);
|
||||||
|
}
|
||||||
|
} else if (type === 'entry' && selectedDatasetId) {
|
||||||
|
const updatedDatasets = datasets.map(d => {
|
||||||
|
if (d.id === selectedDatasetId) {
|
||||||
|
return { ...d, entries: d.entries.filter(e => e.id !== id) };
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
setDatasets(updatedDatasets);
|
||||||
|
}
|
||||||
|
setConfirmDeleteInfo(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddOrUpdateEntry = (entry: LibraryEntry) => {
|
||||||
|
if (!selectedDatasetId || !entry.name.trim()) return;
|
||||||
|
|
||||||
|
const updatedDatasets = datasets.map(d => {
|
||||||
|
if (d.id === selectedDatasetId) {
|
||||||
|
const isNew = entry.id === 0;
|
||||||
|
const updatedEntries = isNew
|
||||||
|
? [...d.entries, { ...entry, id: Date.now() }]
|
||||||
|
: d.entries.map(e => e.id === entry.id ? entry : e);
|
||||||
|
return { ...d, entries: updatedEntries };
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
setDatasets(updatedDatasets);
|
||||||
|
setEditingEntry(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dataset-manager-container">
|
||||||
|
{/* Entry Editor Modal */}
|
||||||
|
{editingEntry && (
|
||||||
|
<Modal
|
||||||
|
isOpen={!!editingEntry}
|
||||||
|
onClose={() => setEditingEntry(null)}
|
||||||
|
title={editingEntry.id === 0 ? 'Create New Entry' : 'Edit Entry'}
|
||||||
|
size="large"
|
||||||
|
actions={[
|
||||||
|
{ label: 'Cancel', onClick: () => setEditingEntry(null), className: 'load-btn' },
|
||||||
|
{ label: 'Save Entry', onClick: () => handleAddOrUpdateEntry(editingEntry), className: 'save-btn' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Entry Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., 'Longsword' or 'Fireball'"
|
||||||
|
value={editingEntry.name}
|
||||||
|
onChange={(e) => setEditingEntry({ ...editingEntry, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Data</label>
|
||||||
|
<DataRecordEditor
|
||||||
|
data={editingEntry.data}
|
||||||
|
onChange={(newData) => setEditingEntry({ ...editingEntry, data: newData })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Dataset Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showCreateDatasetModal}
|
||||||
|
onClose={() => setShowCreateDatasetModal(false)}
|
||||||
|
title="Create New Dataset"
|
||||||
|
actions={[
|
||||||
|
{ label: 'Cancel', onClick: () => setShowCreateDatasetModal(false), className: 'load-btn' },
|
||||||
|
{ label: 'Create', onClick: handleSaveNewDataset, className: 'save-btn' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="datasetName">Dataset Name</label>
|
||||||
|
<input id="datasetName" type="text" placeholder='e.g., "Core Rulebook Items"' value={newDatasetInfo.name} onChange={e => setNewDatasetInfo({...newDatasetInfo, name: e.target.value})}/>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="datasetType">Dataset Type</label>
|
||||||
|
<input id="datasetType" type="text" placeholder='e.g., "spells" or "equipment"' value={newDatasetInfo.type} onChange={e => setNewDatasetInfo({...newDatasetInfo, type: e.target.value})}/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Confirm Deletion Modal */}
|
||||||
|
<Modal
|
||||||
|
isOpen={!!confirmDeleteInfo}
|
||||||
|
onClose={() => setConfirmDeleteInfo(null)}
|
||||||
|
title="Confirm Deletion"
|
||||||
|
actions={[
|
||||||
|
{ label: 'Cancel', onClick: () => setConfirmDeleteInfo(null), className: 'load-btn' },
|
||||||
|
{ label: 'Delete', onClick: handleExecuteDelete, className: 'delete-btn' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p>Are you sure you want to delete the {confirmDeleteInfo?.type} "<strong>{confirmDeleteInfo?.name}</strong>"? This action cannot be undone.</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
|
||||||
|
<div className="datasets-list-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>Datasets</h3>
|
||||||
|
</div>
|
||||||
|
<button className="builder-btn new-dataset-btn" onClick={() => { setNewDatasetInfo({name: '', type: 'generic'}); setShowCreateDatasetModal(true); }}>
|
||||||
|
<HugeiconsIcon icon={FolderAddIcon} size={16}/> New Dataset
|
||||||
|
</button>
|
||||||
|
<ul className="list-group">
|
||||||
|
{datasets.map(d => (
|
||||||
|
<li key={d.id}
|
||||||
|
className={`list-group-item ${selectedDatasetId === d.id ? 'active' : ''}`}
|
||||||
|
onClick={() => setSelectedDatasetId(d.id)}>
|
||||||
|
<div className="dataset-info">
|
||||||
|
<span className="dataset-name">{d.name}</span>
|
||||||
|
<span className="dataset-type">{d.type}</span>
|
||||||
|
</div>
|
||||||
|
<button className="builder-btn delete-btn" onClick={(e) => { e.stopPropagation(); setConfirmDeleteInfo({type: 'dataset', id: d.id, name: d.name})}}>
|
||||||
|
<HugeiconsIcon icon={Delete02Icon} size={16}/>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="entries-list-panel">
|
||||||
|
{selectedDataset ? (
|
||||||
|
<>
|
||||||
|
<div className="panel-header">
|
||||||
|
<h3>{selectedDataset.name}</h3>
|
||||||
|
<button className="builder-btn save-btn" onClick={() => setEditingEntry({id: 0, name: '', data: { 'Description': '' }})}>
|
||||||
|
<HugeiconsIcon icon={AddCircleIcon} size={16}/> New Entry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="list-group">
|
||||||
|
{selectedDataset.entries.map(entry => (
|
||||||
|
<li key={entry.id} className="list-group-item">
|
||||||
|
<span className="dataset-info">{entry.name}</span>
|
||||||
|
<div className="entry-actions">
|
||||||
|
<button className="builder-btn load-btn" onClick={() => setEditingEntry(entry)}><HugeiconsIcon icon={Edit02Icon} size={16}/></button>
|
||||||
|
<button className="builder-btn delete-btn" onClick={() => setConfirmDeleteInfo({type: 'entry', id: entry.id, name: entry.name})}><HugeiconsIcon icon={Delete02Icon} size={16}/></button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : <div className="placeholder-text">Select a dataset to view its entries.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DatasetManager;
|
Loading…
Reference in New Issue
Block a user