move feature from meta-sort to meta-core

This commit is contained in:
Pierre
2026-01-25 22:01:25 +01:00
parent 768942d28b
commit 7d9d502912
69 changed files with 12831 additions and 13 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>meta-core Dashboard</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
</style>
<script type="module" crossorigin src="/assets/index-ycUtrPCn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BGbUc1fg.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>meta-core Dashboard</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
}
#root {
min-height: 100vh;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1779
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@meta-core/dashboard",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"iso-639-3": "^3.0.1"
},
"devDependencies": {
"@types/react": "^18.2.38",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.0",
"typescript": "^5.3.2",
"vite": "^5.0.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
import Dashboard from './pages/Dashboard';
import Mounts from './pages/Mounts';
import Files from './pages/Files';
import ServiceNav from './components/ServiceNav';
const navStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '0.75rem 2rem',
background: '#16213e',
borderBottom: '1px solid #0f3460',
flexShrink: 0,
};
const linkStyle: React.CSSProperties = {
color: '#888',
textDecoration: 'none',
padding: '0.5rem 1rem',
borderRadius: '4px',
transition: 'all 0.2s',
};
const activeLinkStyle: React.CSSProperties = {
...linkStyle,
color: '#fff',
background: '#0f3460',
};
const contentStyle: React.CSSProperties = {
flex: 1,
overflow: 'hidden',
minHeight: 0,
};
// Editor page with iframe to standalone editor
function EditorPage() {
return (
<iframe
src="/editor/"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
title="Metadata Editor"
/>
);
}
export default function App() {
return (
<BrowserRouter>
<nav style={navStyle}>
<NavLink
to="/"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Dashboard
</NavLink>
<NavLink
to="/mounts"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Mounts
</NavLink>
<NavLink
to="/files"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Files
</NavLink>
<NavLink
to="/editor"
style={({ isActive }) => (isActive ? activeLinkStyle : linkStyle)}
>
Editor
</NavLink>
<div style={{ flex: 1 }} />
<ServiceNav />
</nav>
<div style={contentStyle}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/mounts" element={<Mounts />} />
<Route path="/files" element={<Files />} />
<Route path="/editor" element={<EditorPage />} />
</Routes>
</div>
</BrowserRouter>
);
}
+157
View File
@@ -0,0 +1,157 @@
import { useState, useEffect } from 'react';
interface ServiceInfo {
name: string;
url: string;
api: string;
status: string;
capabilities: string[];
version: string;
}
interface ServicesResponse {
services: ServiceInfo[];
current: string;
}
const serviceIcons: Record<string, string> = {
'meta-core': '🗄️',
'meta-sort': '📁',
'meta-fuse': '🗂️',
'meta-stremio': '🎬',
'meta-orbit': '🌐',
'default': '📦'
};
function formatServiceName(name: string): string {
return name.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1)
).join(' ');
}
function ServiceNav() {
const [services, setServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const currentService = 'meta-core';
useEffect(() => {
const fetchServices = async () => {
try {
const response = await fetch('/services');
if (response.ok) {
const data: ServicesResponse = await response.json();
setServices(data.services || []);
}
} catch (error) {
console.error('Failed to fetch services:', error);
} finally {
setLoading(false);
}
};
fetchServices();
const interval = setInterval(fetchServices, 30000);
return () => clearInterval(interval);
}, []);
if (loading) return null;
const sortedServices = [...services].sort((a, b) => a.name.localeCompare(b.name));
if (sortedServices.length === 0) return null;
return (
<nav className="services-nav">
<span className="services-nav-label">Services:</span>
<div className="services-nav-items">
{sortedServices.map(service => {
const icon = serviceIcons[service.name] || serviceIcons.default;
const isActive = service.name === currentService;
return (
<a
key={service.name}
href={isActive ? '#' : service.url}
className={`service-link${isActive ? ' active' : ''}`}
onClick={(e) => {
e.preventDefault();
if (!isActive) {
window.location.href = service.url;
}
}}
>
<span className="service-icon">{icon}</span>
<span>{formatServiceName(service.name)}</span>
<span className="service-status"></span>
</a>
);
})}
</div>
<style>{`
.services-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.services-nav-label {
color: #888;
font-size: 0.8rem;
white-space: nowrap;
}
.services-nav-items {
display: flex;
gap: 0.35rem;
}
.service-link {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border-radius: 6px;
text-decoration: none;
font-size: 0.8rem;
font-weight: 500;
transition: all 0.2s;
color: #ccc;
background: #0f3460;
border: 1px solid #1a4a7a;
}
.service-link:hover {
background: #1a4a7a;
border-color: rgba(78, 205, 196, 0.5);
text-decoration: none;
color: #fff;
}
.service-link.active {
background: linear-gradient(135deg, rgba(78, 205, 196, 0.2), rgba(68, 160, 141, 0.2));
border-color: rgba(78, 205, 196, 0.5);
color: #fff;
}
.service-link .service-icon {
font-size: 0.9rem;
}
.service-link .service-status {
width: 5px;
height: 5px;
border-radius: 50%;
background: #4ecdc4;
}
@media (max-width: 800px) {
.services-nav-label {
display: none;
}
}
`}</style>
</nav>
);
}
export default ServiceNav;
+33
View File
@@ -0,0 +1,33 @@
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #16213e;
--bg-tertiary: #1a1a2e;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--accent-primary: #4ecdc4;
--border-color: #0f3460;
}
*, *::before, *::after {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
}
#root {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react';
interface HealthStatus {
status: string;
role: string;
redis: boolean;
timestamp: string;
}
interface ScanStatus {
status: string;
scanning: boolean;
lastScan: number;
fileCount: number;
}
interface ServiceInfo {
name: string;
api: string;
capabilities: string[];
timestamp: number;
}
const cardStyle: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1rem',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '1rem',
};
const statStyle: React.CSSProperties = {
fontSize: '2rem',
fontWeight: 'bold',
color: '#4ade80',
};
export default function Dashboard() {
const [health, setHealth] = useState<HealthStatus | null>(null);
const [scanStatus, setScanStatus] = useState<ScanStatus | null>(null);
const [services, setServices] = useState<ServiceInfo[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
// Fetch health
const healthRes = await fetch('/health');
if (healthRes.ok) {
setHealth(await healthRes.json());
}
// Fetch scan status
const scanRes = await fetch('/api/scan/status');
if (scanRes.ok) {
setScanStatus(await scanRes.json());
}
// Fetch services
const servicesRes = await fetch('/services');
if (servicesRes.ok) {
const data = await servicesRes.json();
setServices(data.services || []);
}
setError(null);
} catch (err) {
setError(String(err));
}
};
fetchData();
const interval = setInterval(fetchData, 5000);
return () => clearInterval(interval);
}, []);
const handleTriggerScan = async () => {
try {
await fetch('/api/scan/trigger', { method: 'POST' });
} catch (err) {
console.error('Failed to trigger scan:', err);
}
};
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<h1 style={{ marginBottom: '2rem' }}>meta-core Dashboard</h1>
{error && (
<div style={{ ...cardStyle, background: '#4a1a1a', color: '#f87171' }}>
Error: {error}
</div>
)}
<div style={gridStyle}>
{/* Health Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Service Health</h3>
{health ? (
<>
<div style={statStyle}>
{health.status === 'ok' ? 'Healthy' : health.status}
</div>
<p>Role: <strong>{health.role}</strong></p>
<p>Redis: {health.redis ? 'Connected' : 'Disconnected'}</p>
</>
) : (
<p>Loading...</p>
)}
</div>
{/* Scan Status Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>File Watcher</h3>
{scanStatus ? (
<>
<div style={statStyle}>{scanStatus.fileCount}</div>
<p>Files discovered</p>
<p>Status: {scanStatus.scanning ? 'Scanning...' : scanStatus.status}</p>
{scanStatus.lastScan > 0 && (
<p>Last scan: {new Date(scanStatus.lastScan).toLocaleString()}</p>
)}
<button
onClick={handleTriggerScan}
style={{
marginTop: '1rem',
padding: '0.5rem 1rem',
background: '#0f3460',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
Trigger Scan
</button>
</>
) : (
<p>Loading...</p>
)}
</div>
{/* Services Card */}
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem', color: '#888' }}>Connected Services</h3>
<div style={statStyle}>{services.length}</div>
<p>Services registered</p>
{services.length > 0 && (
<ul style={{ marginTop: '1rem', paddingLeft: '1.5rem' }}>
{services.map((svc) => (
<li key={svc.name} style={{ marginBottom: '0.5rem' }}>
<strong>{svc.name}</strong>
<br />
<span style={{ color: '#888', fontSize: '0.9rem' }}>
{svc.capabilities?.join(', ') || 'No capabilities'}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useState } from 'react';
interface FileEvent {
type: 'add' | 'change' | 'delete' | 'rename';
path: string;
timestamp: number;
}
const cardStyle: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1rem',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
minHeight: 0,
};
export default function Files() {
const [recentEvents, setRecentEvents] = useState<FileEvent[]>([]);
const fetchEvents = async () => {
try {
const res = await fetch('/api/events/poll?limit=20');
if (res.ok) {
const data = await res.json();
setRecentEvents(data.events || []);
}
} catch (err) {
console.error('Failed to fetch events:', err);
}
};
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 5000);
return () => clearInterval(interval);
}, []);
const formatTime = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<div style={{
padding: '1rem',
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<h1 style={{ margin: '0 0 1rem 0', flexShrink: 0 }}>File Browser</h1>
<div style={{
display: 'grid',
gridTemplateColumns: '2fr 1fr',
gap: '1rem',
flex: 1,
minHeight: 0
}}>
{/* WebDAV Browser Iframe */}
<div style={cardStyle}>
<iframe
src="/webdav-browser/"
style={{
width: '100%',
flex: 1,
border: 'none',
borderRadius: '4px',
background: '#fff',
display: 'block',
minHeight: 0
}}
title="File Browser"
/>
</div>
{/* Recent Events */}
<div style={cardStyle}>
<h3 style={{ margin: '0 0 0.75rem 0', flexShrink: 0 }}>Recent File Events</h3>
{recentEvents.length === 0 ? (
<p style={{ color: '#888', margin: 0 }}>No recent events</p>
) : (
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{recentEvents.map((event, i) => (
<div
key={i}
style={{
padding: '0.5rem',
borderBottom: '1px solid #0f3460',
fontSize: '0.9rem',
}}
>
<span
style={{
display: 'inline-block',
padding: '0.1rem 0.3rem',
borderRadius: '3px',
fontSize: '0.75rem',
marginRight: '0.5rem',
background:
event.type === 'add'
? '#166534'
: event.type === 'delete'
? '#7f1d1d'
: '#854d0e',
}}
>
{event.type}
</span>
<span style={{ color: '#888' }}>{event.path}</span>
<br />
<span style={{ color: '#555', fontSize: '0.8rem' }}>
{formatTime(event.timestamp)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { useEffect, useState } from 'react';
interface Mount {
id: string;
name: string;
type: 'nfs' | 'smb' | 'rclone';
enabled: boolean;
desiredMounted: boolean;
mountPath: string;
mounted: boolean;
error?: string;
lastChecked: number;
}
interface RcloneRemote {
name: string;
type: string;
}
const cardStyle: React.CSSProperties = {
background: '#16213e',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1rem',
};
const buttonStyle: React.CSSProperties = {
padding: '0.5rem 1rem',
background: '#0f3460',
color: '#fff',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '0.5rem',
};
const dangerButtonStyle: React.CSSProperties = {
...buttonStyle,
background: '#7f1d1d',
};
export default function Mounts() {
const [mounts, setMounts] = useState<Mount[]>([]);
const [remotes, setRemotes] = useState<RcloneRemote[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: '',
type: 'nfs' as 'nfs' | 'smb' | 'rclone',
nfsServer: '',
nfsPath: '',
smbServer: '',
smbShare: '',
smbUsername: '',
smbPassword: '',
rcloneRemote: '',
rclonePath: '',
});
const fetchMounts = async () => {
try {
const res = await fetch('/api/mounts');
if (res.ok) {
const data = await res.json();
setMounts(data.mounts || []);
}
} catch (err) {
console.error('Failed to fetch mounts:', err);
} finally {
setLoading(false);
}
};
const fetchRemotes = async () => {
try {
const res = await fetch('/api/mounts/rclone/remotes');
if (res.ok) {
const data = await res.json();
setRemotes(data.remotes || []);
}
} catch (err) {
console.error('Failed to fetch remotes:', err);
}
};
useEffect(() => {
fetchMounts();
fetchRemotes();
const interval = setInterval(fetchMounts, 5000);
return () => clearInterval(interval);
}, []);
const handleMount = async (id: string) => {
await fetch(`/api/mounts/${id}/mount`, { method: 'POST' });
fetchMounts();
};
const handleUnmount = async (id: string) => {
await fetch(`/api/mounts/${id}/unmount`, { method: 'POST' });
fetchMounts();
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this mount?')) return;
await fetch(`/api/mounts/${id}`, { method: 'DELETE' });
fetchMounts();
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
const body: Record<string, unknown> = {
name: formData.name,
type: formData.type,
};
if (formData.type === 'nfs') {
body.nfsServer = formData.nfsServer;
body.nfsPath = formData.nfsPath;
} else if (formData.type === 'smb') {
body.smbServer = formData.smbServer;
body.smbShare = formData.smbShare;
if (formData.smbUsername) body.smbUsername = formData.smbUsername;
if (formData.smbPassword) body.smbPassword = formData.smbPassword;
} else if (formData.type === 'rclone') {
body.rcloneRemote = formData.rcloneRemote;
body.rclonePath = formData.rclonePath;
}
await fetch('/api/mounts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
setShowForm(false);
setFormData({
name: '',
type: 'nfs',
nfsServer: '',
nfsPath: '',
smbServer: '',
smbShare: '',
smbUsername: '',
smbPassword: '',
rcloneRemote: '',
rclonePath: '',
});
fetchMounts();
};
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '0.5rem',
marginBottom: '0.5rem',
background: '#1a1a2e',
border: '1px solid #0f3460',
borderRadius: '4px',
color: '#fff',
};
if (loading) return <div style={{ padding: '2rem' }}>Loading...</div>;
return (
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
<h1>Mount Management</h1>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<a
href="/rclone/"
target="_blank"
rel="noopener noreferrer"
style={{ ...buttonStyle, textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: '0.5rem' }}
>
rclone UI
</a>
<button style={buttonStyle} onClick={() => setShowForm(!showForm)}>
{showForm ? 'Cancel' : 'Add Mount'}
</button>
</div>
</div>
{showForm && (
<div style={cardStyle}>
<h3 style={{ marginBottom: '1rem' }}>Add New Mount</h3>
<form onSubmit={handleCreate}>
<input
style={inputStyle}
placeholder="Mount Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<select
style={inputStyle}
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as 'nfs' | 'smb' | 'rclone' })}
>
<option value="nfs">NFS</option>
<option value="smb">SMB/CIFS</option>
<option value="rclone">rclone</option>
</select>
{formData.type === 'nfs' && (
<>
<input
style={inputStyle}
placeholder="NFS Server (e.g., 192.168.1.100)"
value={formData.nfsServer}
onChange={(e) => setFormData({ ...formData, nfsServer: e.target.value })}
required
/>
<input
style={inputStyle}
placeholder="NFS Path (e.g., /export/media)"
value={formData.nfsPath}
onChange={(e) => setFormData({ ...formData, nfsPath: e.target.value })}
required
/>
</>
)}
{formData.type === 'smb' && (
<>
<input
style={inputStyle}
placeholder="SMB Server (e.g., 192.168.1.100)"
value={formData.smbServer}
onChange={(e) => setFormData({ ...formData, smbServer: e.target.value })}
required
/>
<input
style={inputStyle}
placeholder="Share Name (e.g., media)"
value={formData.smbShare}
onChange={(e) => setFormData({ ...formData, smbShare: e.target.value })}
required
/>
<input
style={inputStyle}
placeholder="Username (optional)"
value={formData.smbUsername}
onChange={(e) => setFormData({ ...formData, smbUsername: e.target.value })}
/>
<input
style={inputStyle}
type="password"
placeholder="Password (optional)"
value={formData.smbPassword}
onChange={(e) => setFormData({ ...formData, smbPassword: e.target.value })}
/>
</>
)}
{formData.type === 'rclone' && (
<>
<select
style={inputStyle}
value={formData.rcloneRemote}
onChange={(e) => setFormData({ ...formData, rcloneRemote: e.target.value })}
required
>
<option value="">Select Remote</option>
{remotes.map((r) => (
<option key={r.name} value={r.name}>
{r.name} ({r.type})
</option>
))}
</select>
<input
style={inputStyle}
placeholder="Remote Path (e.g., /path/to/folder)"
value={formData.rclonePath}
onChange={(e) => setFormData({ ...formData, rclonePath: e.target.value })}
/>
</>
)}
<button type="submit" style={buttonStyle}>
Create Mount
</button>
</form>
</div>
)}
{mounts.length === 0 ? (
<div style={cardStyle}>
<p>No mounts configured. Click "Add Mount" to get started.</p>
</div>
) : (
mounts.map((mount) => (
<div key={mount.id} style={cardStyle}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h3>{mount.name}</h3>
<p style={{ color: '#888' }}>
Type: {mount.type} | Path: {mount.mountPath}
</p>
<p>
Status:{' '}
<span style={{ color: mount.mounted ? '#4ade80' : '#f87171' }}>
{mount.mounted ? 'Mounted' : 'Not mounted'}
</span>
</p>
{mount.error && (
<p style={{ color: '#f87171', marginTop: '0.5rem' }}>Error: {mount.error}</p>
)}
</div>
<div>
{mount.mounted ? (
<button style={buttonStyle} onClick={() => handleUnmount(mount.id)}>
Unmount
</button>
) : (
<button style={buttonStyle} onClick={() => handleMount(mount.id)}>
Mount
</button>
)}
<button style={dangerButtonStyle} onClick={() => handleDelete(mount.id)}>
Delete
</button>
</div>
</div>
</div>
))
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
proxy: {
'/api': 'http://localhost:9000',
'/health': 'http://localhost:9000',
'/meta': 'http://localhost:9000',
'/webdav': 'http://localhost:80',
},
},
});