Add React frontend with Tailwind CSS

This commit is contained in:
oguz ozturk 2026-01-10 13:14:12 +03:00
parent 0593965305
commit 8d0b564738
12 changed files with 825 additions and 0 deletions

14
frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!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>Hosting Platform - DNS & SSL Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

32
frontend/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "hosting-platform-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3001",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.2",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
}
}

View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

79
frontend/src/App.css Normal file
View File

@ -0,0 +1,79 @@
.app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
}
.card {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
background: #6b7280;
color: white;
border: none;
}
.btn-secondary:hover {
background: #4b5563;
}
.input {
width: 100%;
padding: 0.75rem;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1rem;
}
.input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.alert {
padding: 1rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.alert-success {
background: #d1fae5;
color: #065f46;
border: 1px solid #6ee7b7;
}
.alert-error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fca5a5;
}
.alert-info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #93c5fd;
}

70
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,70 @@
import { useState } from 'react'
import DomainSetup from './pages/DomainSetup'
import DomainList from './pages/DomainList'
import './App.css'
function App() {
const [currentPage, setCurrentPage] = useState('setup')
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
<span className="text-white text-xl font-bold">H</span>
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Hosting Platform</h1>
<p className="text-sm text-gray-500">DNS & SSL Management</p>
</div>
</div>
<nav className="flex space-x-4">
<button
onClick={() => setCurrentPage('setup')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
currentPage === 'setup'
? 'bg-blue-500 text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
Add Domain
</button>
<button
onClick={() => setCurrentPage('list')}
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
currentPage === 'list'
? 'bg-blue-500 text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
My Domains
</button>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{currentPage === 'setup' ? <DomainSetup /> : <DomainList />}
</main>
{/* Footer */}
<footer className="bg-white border-t border-gray-200 mt-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="text-center text-gray-500 text-sm">
<p>© 2024 Hosting Platform. Powered by Cloudflare.</p>
<p className="mt-1">Automated DNS & SSL Management</p>
</div>
</div>
</footer>
</div>
)
}
export default App

33
frontend/src/index.css Normal file
View File

@ -0,0 +1,33 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
#root {
width: 100%;
margin: 0 auto;
text-align: center;
}

11
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@ -0,0 +1,151 @@
import { useState, useEffect } from 'react'
import { dnsAPI } from '../services/api'
function DomainList() {
const [domains, setDomains] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetchDomains()
}, [])
const fetchDomains = async () => {
setLoading(true)
setError(null)
try {
const response = await dnsAPI.getDomains()
setDomains(response.data)
} catch (err) {
setError(err.response?.data?.error || 'Failed to fetch domains')
} finally {
setLoading(false)
}
}
const getStatusBadge = (status) => {
const colors = {
active: 'bg-green-100 text-green-800',
pending: 'bg-yellow-100 text-yellow-800',
error: 'bg-red-100 text-red-800',
}
return colors[status] || 'bg-gray-100 text-gray-800'
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading domains...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="card">
<div className="alert alert-error">
<strong>Error:</strong> {error}
</div>
<button onClick={fetchDomains} className="btn btn-primary mt-4">
Retry
</button>
</div>
)
}
if (domains.length === 0) {
return (
<div className="card text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
</div>
<h2 className="text-2xl font-bold text-gray-800 mb-2">No Domains Yet</h2>
<p className="text-gray-600 mb-6">
You haven't added any domains yet. Click "Add Domain" to get started.
</p>
</div>
)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-800">My Domains</h2>
<button onClick={fetchDomains} className="btn btn-secondary">
Refresh
</button>
</div>
<div className="grid gap-4">
{domains.map((domain) => (
<div key={domain.id} className="card">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="text-xl font-bold text-gray-800">
{domain.domain_name}
</h3>
<span className={`px-3 py-1 rounded-full text-xs font-medium ${getStatusBadge(domain.status)}`}>
{domain.status}
</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-500">Load Balancer IP:</span>
<p className="font-medium text-gray-800">{domain.lb_ip || 'N/A'}</p>
</div>
<div>
<span className="text-gray-500">DNS Configured:</span>
<p className="font-medium text-gray-800">
{domain.dns_configured ? '✓ Yes' : '✗ No'}
</p>
</div>
<div>
<span className="text-gray-500">SSL Configured:</span>
<p className="font-medium text-gray-800">
{domain.ssl_configured ? '✓ Yes' : '✗ No'}
</p>
</div>
<div>
<span className="text-gray-500">Cloudflare Proxy:</span>
<p className="font-medium text-gray-800">
{domain.cf_proxy_enabled ? '✓ Enabled' : '✗ Disabled'}
</p>
</div>
</div>
<div className="mt-3 text-xs text-gray-500">
Added: {new Date(domain.created_at).toLocaleDateString()}
</div>
</div>
<div className="ml-4">
<a
href={`https://${domain.domain_name}`}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
Visit Site
</a>
</div>
</div>
</div>
))}
</div>
</div>
)
}
export default DomainList

View File

@ -0,0 +1,346 @@
import { useState } from 'react'
import { dnsAPI } from '../services/api'
function DomainSetup() {
const [step, setStep] = useState(1)
const [domain, setDomain] = useState('')
const [cfToken, setCfToken] = useState('')
const [zoneInfo, setZoneInfo] = useState(null)
const [preview, setPreview] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const handleValidateToken = async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const response = await dnsAPI.validateToken(domain, cfToken)
if (response.data.status === 'success') {
setZoneInfo(response.data)
setStep(2)
} else {
setError(response.data.message)
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to validate token')
} finally {
setLoading(false)
}
}
const handlePreviewChanges = async () => {
setLoading(true)
setError(null)
try {
const response = await dnsAPI.previewChanges(
domain,
zoneInfo.zone_id,
cfToken
)
if (response.data.status !== 'error') {
setPreview(response.data)
setStep(3)
} else {
setError(response.data.message)
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to preview changes')
} finally {
setLoading(false)
}
}
const handleApplyChanges = async () => {
setLoading(true)
setError(null)
try {
const response = await dnsAPI.applyChanges(
domain,
zoneInfo.zone_id,
cfToken,
preview,
true
)
if (response.data.status === 'success') {
setSuccess('Domain successfully configured!')
setStep(4)
} else {
setError('Failed to apply changes')
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to apply changes')
} finally {
setLoading(false)
}
}
const resetForm = () => {
setStep(1)
setDomain('')
setCfToken('')
setZoneInfo(null)
setPreview(null)
setError(null)
setSuccess(null)
}
return (
<div className="max-w-4xl mx-auto">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
{[1, 2, 3, 4].map((s) => (
<div key={s} className="flex items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center font-bold ${
step >= s
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-500'
}`}
>
{s}
</div>
{s < 4 && (
<div
className={`w-24 h-1 mx-2 ${
step > s ? 'bg-blue-500' : 'bg-gray-200'
}`}
/>
)}
</div>
))}
</div>
<div className="flex justify-between mt-2 text-sm text-gray-600">
<span>Domain Info</span>
<span>Verify</span>
<span>Preview</span>
<span>Complete</span>
</div>
</div>
{/* Error Alert */}
{error && (
<div className="alert alert-error mb-4">
<strong>Error:</strong> {error}
</div>
)}
{/* Success Alert */}
{success && (
<div className="alert alert-success mb-4">
<strong>Success!</strong> {success}
</div>
)}
{/* Step 1: Domain & Token */}
{step === 1 && (
<div className="card">
<h2 className="text-2xl font-bold mb-6 text-gray-800">
Step 1: Enter Domain & Cloudflare Token
</h2>
<form onSubmit={handleValidateToken} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Domain Name
</label>
<input
type="text"
className="input"
placeholder="example.com"
value={domain}
onChange={(e) => setDomain(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Cloudflare API Token
</label>
<input
type="password"
className="input"
placeholder="Your Cloudflare API Token"
value={cfToken}
onChange={(e) => setCfToken(e.target.value)}
required
/>
<p className="mt-2 text-sm text-gray-500">
Get your API token from Cloudflare Dashboard My Profile API Tokens
</p>
</div>
<button
type="submit"
className="btn btn-primary w-full"
disabled={loading}
>
{loading ? 'Validating...' : 'Validate & Continue'}
</button>
</form>
</div>
)}
{/* Step 2: Zone Info */}
{step === 2 && zoneInfo && (
<div className="card">
<h2 className="text-2xl font-bold mb-6 text-gray-800">
Step 2: Verify Zone Information
</h2>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 font-medium"> Token validated successfully!</p>
</div>
<div className="space-y-4 text-left">
<div>
<label className="text-sm font-medium text-gray-500">Zone Name</label>
<p className="text-lg font-semibold text-gray-800">{zoneInfo.zone_name}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Zone ID</label>
<p className="text-gray-800 font-mono text-sm">{zoneInfo.zone_id}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Status</label>
<p className="text-gray-800">{zoneInfo.zone_status}</p>
</div>
<div>
<label className="text-sm font-medium text-gray-500">Nameservers</label>
<ul className="list-disc list-inside text-gray-800">
{zoneInfo.nameservers?.map((ns, i) => (
<li key={i}>{ns}</li>
))}
</ul>
</div>
</div>
<div className="flex space-x-4 mt-6">
<button
onClick={() => setStep(1)}
className="btn btn-secondary flex-1"
>
Back
</button>
<button
onClick={handlePreviewChanges}
className="btn btn-primary flex-1"
disabled={loading}
>
{loading ? 'Loading...' : 'Preview Changes'}
</button>
</div>
</div>
)}
{/* Step 3: Preview Changes */}
{step === 3 && preview && (
<div className="card">
<h2 className="text-2xl font-bold mb-6 text-gray-800">
Step 3: Preview DNS Changes
</h2>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800">
<strong>New IP:</strong> {preview.new_ip}
</p>
</div>
<div className="space-y-4 text-left">
<h3 className="font-semibold text-gray-800">Changes to be applied:</h3>
{preview.changes?.map((change, i) => (
<div key={i} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-gray-800">
{change.record_type} Record: {change.name}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${
change.action === 'create' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
}`}>
{change.action.toUpperCase()}
</span>
</div>
{change.current && (
<div className="text-sm text-gray-600 mb-1">
Current: {change.current.value} (Proxied: {change.current.proxied ? 'Yes' : 'No'})
</div>
)}
<div className="text-sm text-gray-800 font-medium">
New: {change.new.value} (Proxied: {change.new.proxied ? 'Yes' : 'No'})
</div>
</div>
))}
</div>
<div className="flex space-x-4 mt-6">
<button
onClick={() => setStep(2)}
className="btn btn-secondary flex-1"
>
Back
</button>
<button
onClick={handleApplyChanges}
className="btn btn-primary flex-1"
disabled={loading}
>
{loading ? 'Applying...' : 'Apply Changes'}
</button>
</div>
</div>
)}
{/* Step 4: Success */}
{step === 4 && (
<div className="card text-center">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-10 h-10 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-3xl font-bold text-gray-800 mb-4">
Domain Configured Successfully!
</h2>
<p className="text-gray-600 mb-6">
Your domain <strong>{domain}</strong> has been configured with DNS and SSL settings.
</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6 text-left">
<h3 className="font-semibold text-blue-900 mb-2">What's Next?</h3>
<ul className="list-disc list-inside text-blue-800 space-y-1">
<li>DNS changes may take a few minutes to propagate</li>
<li>SSL certificate will be automatically provisioned by Cloudflare</li>
<li>Your site will be accessible via HTTPS within 15 minutes</li>
</ul>
</div>
<button
onClick={resetForm}
className="btn btn-primary"
>
Add Another Domain
</button>
</div>
)}
</div>
)
}
export default DomainSetup

View File

@ -0,0 +1,53 @@
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://176.96.129.77:5000'
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
export const dnsAPI = {
// Health check
health: () => api.get('/health'),
// Validate Cloudflare token
validateToken: (domain, cfToken) =>
api.post('/api/dns/validate-token', {
domain,
cf_token: cfToken,
}),
// Preview DNS changes
previewChanges: (domain, zoneId, cfToken) =>
api.post('/api/dns/preview-changes', {
domain,
zone_id: zoneId,
cf_token: cfToken,
}),
// Apply DNS changes
applyChanges: (domain, zoneId, cfToken, preview, proxyEnabled = true) =>
api.post('/api/dns/apply-changes', {
domain,
zone_id: zoneId,
cf_token: cfToken,
preview,
proxy_enabled: proxyEnabled,
customer_id: 1, // TODO: Get from auth
}),
// Get domains
getDomains: (customerId = 1) =>
api.get('/api/domains', {
params: { customer_id: customerId },
}),
// Get domain by ID
getDomain: (domainId) => api.get(`/api/domains/${domainId}`),
}
export default api

View File

@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

17
frontend/vite.config.js Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3001,
proxy: {
'/api': {
target: 'http://176.96.129.77:5000',
changeOrigin: true,
}
}
}
})