initial commit
This commit is contained in:
5
frontend/dev.Dockerfile
Normal file
5
frontend/dev.Dockerfile
Normal file
@@ -0,0 +1,5 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ProtonVPN Config Generator</title>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-white min-h-screen font-sans antialiased selection:bg-purple-500/30">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "protonvpn-webui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"postcss": "^8.4.38",
|
||||
"autoprefixer": "^10.4.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
404
frontend/src/App.tsx
Normal file
404
frontend/src/App.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
const COUNTRIES = [
|
||||
{ code: 'AR', name: 'Argentina' },
|
||||
{ code: 'AU', name: 'Australia' },
|
||||
{ code: 'AT', name: 'Austria' },
|
||||
{ code: 'BE', name: 'Belgium' },
|
||||
{ code: 'BR', name: 'Brazil' },
|
||||
{ code: 'BG', name: 'Bulgaria' },
|
||||
{ code: 'CA', name: 'Canada' },
|
||||
{ code: 'CL', name: 'Chile' },
|
||||
{ code: 'CO', name: 'Colombia' },
|
||||
{ code: 'CR', name: 'Costa Rica' },
|
||||
{ code: 'HR', name: 'Croatia' },
|
||||
{ code: 'CY', name: 'Cyprus' },
|
||||
{ code: 'CZ', name: 'Czechia' },
|
||||
{ code: 'DK', name: 'Denmark' },
|
||||
{ code: 'EE', name: 'Estonia' },
|
||||
{ code: 'FI', name: 'Finland' },
|
||||
{ code: 'FR', name: 'France' },
|
||||
{ code: 'GE', name: 'Georgia' },
|
||||
{ code: 'DE', name: 'Germany' },
|
||||
{ code: 'GR', name: 'Greece' },
|
||||
{ code: 'HK', name: 'Hong Kong' },
|
||||
{ code: 'HU', name: 'Hungary' },
|
||||
{ code: 'IS', name: 'Iceland' },
|
||||
{ code: 'IN', name: 'India' },
|
||||
{ code: 'IE', name: 'Ireland' },
|
||||
{ code: 'IL', name: 'Israel' },
|
||||
{ code: 'IT', name: 'Italy' },
|
||||
{ code: 'JP', name: 'Japan' },
|
||||
{ code: 'LV', name: 'Latvia' },
|
||||
{ code: 'LT', name: 'Lithuania' },
|
||||
{ code: 'LU', name: 'Luxembourg' },
|
||||
{ code: 'MY', name: 'Malaysia' },
|
||||
{ code: 'MX', name: 'Mexico' },
|
||||
{ code: 'MD', name: 'Moldova' },
|
||||
{ code: 'NL', name: 'Netherlands' },
|
||||
{ code: 'NZ', name: 'New Zealand' },
|
||||
{ code: 'NO', name: 'Norway' },
|
||||
{ code: 'PE', name: 'Peru' },
|
||||
{ code: 'PH', name: 'Philippines' },
|
||||
{ code: 'PL', name: 'Poland' },
|
||||
{ code: 'PT', name: 'Portugal' },
|
||||
{ code: 'RO', name: 'Romania' },
|
||||
{ code: 'RS', name: 'Serbia' },
|
||||
{ code: 'SG', name: 'Singapore' },
|
||||
{ code: 'SK', name: 'Slovakia' },
|
||||
{ code: 'SI', name: 'Slovenia' },
|
||||
{ code: 'ZA', name: 'South Africa' },
|
||||
{ code: 'KR', name: 'South Korea' },
|
||||
{ code: 'ES', name: 'Spain' },
|
||||
{ code: 'SE', name: 'Sweden' },
|
||||
{ code: 'CH', name: 'Switzerland' },
|
||||
{ code: 'TW', name: 'Taiwan' },
|
||||
{ code: 'TR', name: 'Turkey' },
|
||||
{ code: 'UA', name: 'Ukraine' },
|
||||
{ code: 'AE', name: 'United Arab Emirates' },
|
||||
{ code: 'UK', name: 'United Kingdom' },
|
||||
{ code: 'US', name: 'United States' },
|
||||
{ code: 'VN', name: 'Vietnam' }
|
||||
];
|
||||
|
||||
const getFlagImg = (countryCode: string) => {
|
||||
return (
|
||||
<img
|
||||
src={`https://flagcdn.com/w40/${countryCode.toLowerCase()}.png`}
|
||||
width="24"
|
||||
alt={countryCode}
|
||||
className="inline-block rounded-[2px] shadow-[0_0_2px_rgba(0,0,0,0.5)]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [selectedCountries, setSelectedCountries] = useState<string[]>([]);
|
||||
const [isCountryOpen, setIsCountryOpen] = useState(false);
|
||||
const [options, setOptions] = useState({
|
||||
p2pOnly: true,
|
||||
secureCore: false,
|
||||
freeOnly: false,
|
||||
ipv6: false,
|
||||
accelerator: true,
|
||||
duration: '365d'
|
||||
});
|
||||
|
||||
const [taskId, setTaskId] = useState<string | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [logs, setLogs] = useState<{text: string, error?: boolean}[]>([]);
|
||||
const [require2FA, setRequire2FA] = useState(false);
|
||||
const [twoFACode, setTwoFACode] = useState('');
|
||||
const [downloadData, setDownloadData] = useState<string | null>(null);
|
||||
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
const toggleCountry = (code: string) => {
|
||||
setSelectedCountries(prev =>
|
||||
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
|
||||
);
|
||||
};
|
||||
|
||||
const handleGenerate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (selectedCountries.length === 0) {
|
||||
alert("Please select at least one country");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setLogs([]);
|
||||
setRequire2FA(false);
|
||||
setDownloadData(null);
|
||||
setTaskId(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
countries: selectedCountries.join(','),
|
||||
...options
|
||||
})
|
||||
});
|
||||
|
||||
const { taskId } = await res.json();
|
||||
setTaskId(taskId);
|
||||
|
||||
const es = new EventSource(`/api/stream/${taskId}`);
|
||||
|
||||
es.addEventListener('log', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
setLogs(prev => [...prev, data]);
|
||||
});
|
||||
|
||||
es.addEventListener('require_2fa', () => {
|
||||
setRequire2FA(true);
|
||||
});
|
||||
|
||||
es.addEventListener('complete', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
setLogs(prev => [...prev, { text: `Process completed with code ${data.code}` }]);
|
||||
setIsGenerating(false);
|
||||
if (data.success && data.fileContent) {
|
||||
setDownloadData(data.fileContent);
|
||||
}
|
||||
es.close();
|
||||
});
|
||||
|
||||
es.onerror = () => {
|
||||
setLogs(prev => [...prev, { text: 'Lost connection to stream', error: true }]);
|
||||
setIsGenerating(false);
|
||||
es.close();
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
setLogs([{ text: `Failed to initiate generation: ${err}`, error: true }]);
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submit2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!taskId || !twoFACode) return;
|
||||
|
||||
setRequire2FA(false);
|
||||
setLogs(prev => [...prev, { text: `> Submitting 2FA Code: ***`, error: false }]);
|
||||
|
||||
await fetch('/api/2fa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ taskId, code: twoFACode })
|
||||
});
|
||||
|
||||
setTwoFACode('');
|
||||
};
|
||||
|
||||
const downloadFile = () => {
|
||||
if (!downloadData) return;
|
||||
const blob = new Blob([downloadData], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `protonvpn-${selectedCountries.join('_')}.conf`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#121015] flex flex-col items-center p-6 relative">
|
||||
{/* Background aesthetic */}
|
||||
<div className="absolute top-[-20%] left-[-10%] w-[50%] h-[50%] bg-[#6d4aff]/10 blur-[120px] rounded-full pointer-events-none" />
|
||||
<div className="absolute top-[30%] right-[-10%] w-[40%] h-[60%] bg-[#2bbd7e]/5 blur-[120px] rounded-full pointer-events-none" />
|
||||
|
||||
<div className="max-w-6xl w-full flex flex-col gap-8 z-10">
|
||||
<header className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-[#6d4aff] to-[#937bff] uppercase tracking-wider">
|
||||
Proton WireGuard
|
||||
</h1>
|
||||
<p className="text-zinc-400 mt-1 font-medium">Headless Configuration Generator</p>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 bg-white/5 py-2 px-4 rounded-full border border-white/10 backdrop-blur-sm">
|
||||
Powered by open-source protonvpn-wg-confgen
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{/* Controls Panel */}
|
||||
<div className="bg-[#1c1924]/80 p-8 rounded-3xl border border-white/5 shadow-2xl backdrop-blur-md flex flex-col gap-6">
|
||||
<h2 className="text-xl font-bold border-b border-white/5 pb-4">Connection Settings</h2>
|
||||
|
||||
<form id="gen-form" onSubmit={handleGenerate} className="flex flex-col gap-6">
|
||||
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-3 uppercase tracking-wide">Target Countries</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCountryOpen(!isCountryOpen)}
|
||||
className="w-full bg-black/40 border border-white/10 rounded-xl px-4 py-3 text-left focus:outline-none focus:border-[#6d4aff] transition-colors flex justify-between items-center text-zinc-300 shadow-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2 flex-wrap">
|
||||
{selectedCountries.length === 0
|
||||
? 'Toggle countries list...'
|
||||
: selectedCountries.map(c => (
|
||||
<span key={c} className="flex items-center gap-1.5 bg-white/10 px-2 py-0.5 rounded-md text-xs font-bold text-white shadow-sm">
|
||||
{getFlagImg(c)} {c}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
<svg className={`w-5 h-5 shrink-0 transition-transform ${isCountryOpen ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isCountryOpen && (
|
||||
<div className="w-full bg-black/20 border border-white/5 rounded-xl max-h-72 overflow-y-auto overflow-x-hidden p-2 backdrop-blur-sm">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{COUNTRIES.map(country => (
|
||||
<label
|
||||
key={country.code}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg cursor-pointer transition-colors border max-w-full ${
|
||||
selectedCountries.includes(country.code)
|
||||
? 'bg-[#6d4aff]/20 border-[#6d4aff]/50 text-white'
|
||||
: 'border-transparent hover:bg-white/5 text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="hidden"
|
||||
checked={selectedCountries.includes(country.code)}
|
||||
onChange={() => toggleCountry(country.code)}
|
||||
/>
|
||||
<div className={`w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors ${
|
||||
selectedCountries.includes(country.code) ? 'bg-[#6d4aff] border-[#6d4aff]' : 'border-white/20 bg-black/40'
|
||||
}`}>
|
||||
{selectedCountries.includes(country.code) && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 flex items-center justify-center w-[24px]">
|
||||
{getFlagImg(country.code)}
|
||||
</span>
|
||||
<span className="text-sm font-medium truncate shrink min-w-0" title={country.name}>{country.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-400 mb-3 uppercase tracking-wide">Advanced Options</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ key: 'p2pOnly', label: 'P2P Optimized' },
|
||||
{ key: 'secureCore', label: 'Secure Core' },
|
||||
{ key: 'freeOnly', label: 'Free Tier Only' },
|
||||
{ key: 'ipv6', label: 'Enable IPv6' },
|
||||
{ key: 'accelerator', label: 'VPN Accelerator' }
|
||||
].map(opt => (
|
||||
<label key={opt.key} className="flex items-center gap-3 cursor-pointer group">
|
||||
<div className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${
|
||||
options[opt.key as keyof typeof options] ? 'bg-[#6d4aff] border-[#6d4aff]' : 'border-white/20 bg-black/40 group-hover:border-white/40'
|
||||
}`}>
|
||||
{options[opt.key as keyof typeof options] && (
|
||||
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-300">{opt.label}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="hidden"
|
||||
checked={options[opt.key as keyof typeof options] as boolean}
|
||||
onChange={e => setOptions({...options, [opt.key]: e.target.checked})}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isGenerating}
|
||||
className="w-full bg-gradient-to-r from-[#6d4aff] to-[#937bff] hover:opacity-90 text-white font-bold py-4 rounded-xl shadow-lg transition-transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isGenerating ? 'Generating...' : 'Generate Configuration'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Terminal / Output Panel */}
|
||||
<div className="bg-black/80 p-6 rounded-3xl border border-white/10 shadow-2xl backdrop-blur-xl flex flex-col h-[600px]">
|
||||
<div className="flex items-center gap-2 mb-4 px-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/80"></div>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||
<span className="ml-2 text-xs font-mono text-zinc-500">generator.log</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto terminal-scroll font-mono text-xs md:text-sm bg-[#050505] rounded-xl p-4 border border-white/5">
|
||||
{logs.length === 0 && !isGenerating && (
|
||||
<p className="text-zinc-600 italic">Waiting for execution...</p>
|
||||
)}
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className={`mb-1 ${log.error ? 'text-red-400' : 'text-zinc-300'}`}>
|
||||
{log.text.split('\n').map((line, j) => (
|
||||
<div key={j} className="break-all whitespace-pre-wrap">{line}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Success Download */}
|
||||
{downloadData && (
|
||||
<div className="mt-4 animate-in fade-in slide-in-from-bottom-4">
|
||||
<button
|
||||
onClick={downloadFile}
|
||||
className="w-full bg-[#2bbd7e] border border-[#2bbd7e] text-[#2bbd7e] bg-opacity-20 font-bold py-3 rounded-xl hover:bg-opacity-30 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download WireGuard Config
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2FA Modal */}
|
||||
{require2FA && (
|
||||
<div className="fixed inset-0 bg-black/80 backdrop-blur-md z-50 flex items-center justify-center p-4 animate-in fade-in">
|
||||
<div className="bg-[#1c1924] border border-[#6d4aff]/30 p-8 rounded-3xl max-w-sm w-full shadow-2xl">
|
||||
<h3 className="text-2xl font-bold mb-2 flex items-center gap-2">
|
||||
<svg className="w-6 h-6 text-[#6d4aff]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
Two-Factor Auth
|
||||
</h3>
|
||||
<p className="text-zinc-400 text-sm mb-6">Your Proton account requires a TOTP code to authenticate the VPN API.</p>
|
||||
|
||||
<form onSubmit={submit2FA}>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={6}
|
||||
value={twoFACode}
|
||||
onChange={e => setTwoFACode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000 000"
|
||||
className="w-full bg-black/60 border border-white/10 rounded-xl px-4 py-4 text-center text-2xl tracking-widest font-mono focus:outline-none focus:border-[#6d4aff] transition-colors mb-6"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={twoFACode.length !== 6}
|
||||
className="w-full bg-white text-black font-bold py-3 rounded-xl disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
Verify & Continue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
frontend/src/index.css
Normal file
31
frontend/src/index.css
Normal file
@@ -0,0 +1,31 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-proton-purple: #6d4aff;
|
||||
--color-proton-dark: #121015;
|
||||
--color-proton-card: #1c1924;
|
||||
--color-proton-accent: #937bff;
|
||||
--color-proton-green: #2bbd7e;
|
||||
--font-sans: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-proton-dark);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for terminal */
|
||||
.terminal-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.terminal-scroll::-webkit-scrollbar-track {
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
.terminal-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(109, 74, 255, 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.terminal-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(109, 74, 255, 0.6);
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
],
|
||||
server: {
|
||||
host: '0.0.0.0', // needed for docker
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user