new file: astro-site/src/components/auth/SupabaseLogin.jsx
new file: astro-site/src/components/auth/SupabaseLogin.jsx
This commit is contained in:
parent
48f095c8ad
commit
de54903c15
75 changed files with 9940 additions and 111 deletions
|
|
@ -202,7 +202,7 @@ Get temporary TURN server credentials.
|
|||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"urls": ["turn:turn.example.com:3478"],
|
||||
"urls": ["turn:turn.example.com:3000"],
|
||||
"username": "1736517600:username",
|
||||
"credential": "hmac-sha1-hash"
|
||||
},
|
||||
|
|
@ -427,7 +427,7 @@ Edit `/etc/turnserver.conf`:
|
|||
|
||||
```conf
|
||||
# Listening port
|
||||
listening-port=3478
|
||||
listening-port=3000
|
||||
tls-listening-port=5349
|
||||
|
||||
# External IP (replace with your server IP)
|
||||
|
|
@ -467,7 +467,7 @@ Add to `.env`:
|
|||
```env
|
||||
# TURN Server Configuration
|
||||
TURN_SERVER_HOST=turn.yourdomain.com
|
||||
TURN_SERVER_PORT=3478
|
||||
TURN_SERVER_PORT=3000
|
||||
TURN_SECRET=your-turn-secret-key
|
||||
TURN_TTL=86400
|
||||
```
|
||||
|
|
@ -476,8 +476,8 @@ TURN_TTL=86400
|
|||
|
||||
```bash
|
||||
# Allow TURN ports
|
||||
sudo ufw allow 3478/tcp
|
||||
sudo ufw allow 3478/udp
|
||||
sudo ufw allow 3000/tcp
|
||||
sudo ufw allow 3000/udp
|
||||
sudo ufw allow 5349/tcp
|
||||
sudo ufw allow 5349/udp
|
||||
|
||||
|
|
@ -496,7 +496,7 @@ sudo systemctl status coturn
|
|||
|
||||
Use the [Trickle ICE](https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/) test page:
|
||||
|
||||
1. Add your TURN server URL: `turn:YOUR_SERVER_IP:3478`
|
||||
1. Add your TURN server URL: `turn:YOUR_SERVER_IP:3000`
|
||||
2. Generate TURN credentials using the HMAC method
|
||||
3. Click "Gather candidates"
|
||||
4. Verify `relay` candidates appear
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ Add to `.env`:
|
|||
```env
|
||||
# TURN Server Configuration
|
||||
TURN_SERVER_HOST=turn.example.com
|
||||
TURN_SERVER_PORT=3478
|
||||
TURN_SERVER_PORT=3000
|
||||
TURN_SECRET=your-turn-secret-key
|
||||
TURN_TTL=86400
|
||||
```
|
||||
|
|
|
|||
|
|
@ -106,12 +106,12 @@ packages/ui/styles/tokens.ts # Complete redesign with dark theme
|
|||
http://localhost:3000
|
||||
http://localhost:3000/health
|
||||
|
||||
# Port 4321 - Astro Landing Site
|
||||
http://localhost:4321
|
||||
# Port 3000 - Astro Landing Site
|
||||
http://localhost:3000
|
||||
cd astro-site && npm run dev
|
||||
|
||||
# Port 5173 - React Frontend (Vite)
|
||||
http://localhost:5173
|
||||
# Port 3000 - React Frontend (Vite)
|
||||
http://localhost:3000
|
||||
cd src/frontend && npm run dev
|
||||
```
|
||||
|
||||
|
|
@ -327,7 +327,7 @@ cd src/frontend && npm run dev # React (Terminal 3)
|
|||
|
||||
### 2. Check Status
|
||||
- Visit http://localhost:5173 (React app)
|
||||
- Visit http://localhost:4321 (Astro landing)
|
||||
- Visit http://localhost:3000 (Astro landing)
|
||||
- Check git status: `git status`
|
||||
|
||||
### 3. Continue Development
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev --port 4321",
|
||||
"dev": "astro dev --port 3000",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
|
|
|
|||
7
astro-site/src/components/ReactAppIsland.jsx
Normal file
7
astro-site/src/components/ReactAppIsland.jsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import React from "react";
|
||||
|
||||
import Demo from "../react-app/Demo";
|
||||
|
||||
export default function ReactAppIsland() {
|
||||
return <Demo />;
|
||||
}
|
||||
70
astro-site/src/components/auth/SupabaseLogin.jsx
Normal file
70
astro-site/src/components/auth/SupabaseLogin.jsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import React, { useState } from "react";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
export default function SupabaseLogin() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
setSuccess(true);
|
||||
// Optionally redirect or reload
|
||||
window.location.href = "/app";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-bg min-h-screen flex flex-col items-center justify-center" style={{background: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)"}}>
|
||||
<div className="account-linker bg-[#181818cc] p-8 rounded-2xl max-w-md w-full mx-auto shadow-2xl border border-[#23234a] flex flex-col items-center animate-fade-in">
|
||||
<img src="/favicon.svg" alt="AeThex Logo" className="w-16 h-16 mb-4 drop-shadow-lg" />
|
||||
<h1 className="text-3xl font-extrabold mb-2 text-white tracking-tight text-center">AeThex Connect</h1>
|
||||
<h2 className="text-lg font-semibold mb-6 text-blue-300 text-center">Sign in to your account</h2>
|
||||
{error && <div className="mb-2 text-red-400 text-center w-full">{error}</div>}
|
||||
{success && <div className="mb-2 text-green-400 text-center w-full">Login successful!</div>}
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4 w-full">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
className="px-4 py-3 rounded-lg bg-[#23234a] text-white border border-[#2d2d5a] focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="px-4 py-3 rounded-lg bg-[#23234a] text-white border border-[#2d2d5a] focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg py-3 font-bold mt-2 shadow-lg hover:from-blue-700 hover:to-purple-700 transition" disabled={loading}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-6 text-center text-gray-400 text-xs w-full">
|
||||
<span>By continuing, you agree to the <a href="/terms" className="underline hover:text-blue-300">Terms of Service</a> and <a href="/privacy" className="underline hover:text-blue-300">Privacy Policy</a>.</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
.animate-fade-in { animation: fadeIn 0.8s cubic-bezier(.4,0,.2,1) both; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(30px); } to { opacity: 1; transform: none; } }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,2 @@
|
|||
import React from "react";
|
||||
import MainLayout from "../components/mockup/MainLayout";
|
||||
import "../components/mockup/global.css";
|
||||
|
||||
export default function MockupPage() {
|
||||
return <MainLayout />;
|
||||
}
|
||||
// Removed: This page is deprecated. Use /app for the full platform UI.
|
||||
6
astro-site/src/pages/app.astro
Normal file
6
astro-site/src/pages/app.astro
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
|
||||
---
|
||||
import ReactAppIsland from '../components/ReactAppIsland.jsx';
|
||||
---
|
||||
|
||||
<ReactAppIsland client:load />
|
||||
|
|
@ -9,6 +9,8 @@ import Layout from '../layouts/Layout.astro';
|
|||
<h1 class="hero-title">AeThex Connect</h1>
|
||||
<p class="hero-subtitle">Next-generation voice & chat for gamers.<br />Own your identity. Connect everywhere.</p>
|
||||
<a href="/login" class="hero-btn">Get Started</a>
|
||||
<a href="/app" class="hero-btn" style="margin-left: 1em; background: #00d9ff; color: #000;">Open AeThex Connect Platform</a>
|
||||
<a href="/mockup" class="hero-btn" style="margin-left: 1em; background: #222; color: #00d9ff;">Legacy Mockup</a>
|
||||
</div>
|
||||
</section>
|
||||
<section class="landing-features">
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import LoginIsland from '../components/auth/LoginIsland.jsx';
|
||||
import SupabaseLogin from '../components/auth/SupabaseLogin.jsx';
|
||||
---
|
||||
|
||||
|
||||
<Layout title="AeThex Connect – Login">
|
||||
<LoginIsland client:load />
|
||||
<SupabaseLogin client:load />
|
||||
</Layout>
|
||||
|
|
|
|||
14
astro-site/src/pages/mockup.astro
Normal file
14
astro-site/src/pages/mockup.astro
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!-- AeThex Connect Mockup merged as Astro page -->
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AeThex Connect - Metaverse Communication</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
/* All CSS from mockup HTML here */
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<div class="connect-container">
|
||||
<!-- ...existing mockup HTML content... -->
|
||||
</div>
|
||||
|
|
@ -1,7 +1,2 @@
|
|||
import React from "react";
|
||||
import MainLayout from "../components/mockup/MainLayout";
|
||||
import "../components/mockup/global.css";
|
||||
|
||||
export default function MockupPage() {
|
||||
return <MainLayout />;
|
||||
}
|
||||
// Removed: This page is deprecated. Use /app for the full platform UI.
|
||||
|
|
|
|||
189
astro-site/src/react-app/App.css
Normal file
189
astro-site/src/react-app/App.css
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* Sleek Dark Gaming Theme - BitChat/Root Inspired */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #000000;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
padding: 20px 32px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
box-shadow: 0 4px 24px rgba(0, 217, 255, 0.05);
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #00d9ff;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
text-shadow: 0 0 30px rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.app-header p {
|
||||
margin: 0;
|
||||
color: #a1a1aa;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
padding: 40px 24px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 217, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
margin-bottom: 32px;
|
||||
box-shadow: 0 8px 32px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.profile-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #ffffff;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.profile-header p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #a1a1aa;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
padding: 12px 32px;
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
color: #000000;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
align-self: center;
|
||||
box-shadow: 0 4px 20px rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.toggle-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(0, 217, 255, 0.4), 0 0 40px rgba(0, 255, 136, 0.2);
|
||||
}
|
||||
|
||||
.verification-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(0, 217, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 8px 32px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #00d9ff;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #d4d4d8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
padding: 12px 16px;
|
||||
color: #d4d4d8;
|
||||
font-size: 16px;
|
||||
background: rgba(0, 217, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid #00d9ff;
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
color: #a1a1aa;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.app-main {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.user-profile,
|
||||
.info-section {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.profile-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
84
astro-site/src/react-app/App.jsx
Normal file
84
astro-site/src/react-app/App.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import DomainVerification from './components/DomainVerification';
|
||||
import VerifiedDomainBadge from './components/VerifiedDomainBadge';
|
||||
import './App.css';
|
||||
|
||||
/**
|
||||
* Main application component
|
||||
* Demo of domain verification feature
|
||||
*/
|
||||
function App() {
|
||||
const { user, loading } = useAuth();
|
||||
const [showVerification, setShowVerification] = React.useState(false);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-screen">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>AeThex Passport</h1>
|
||||
<p>Domain Verification</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{user && (
|
||||
<div className="user-profile">
|
||||
<div className="profile-header">
|
||||
<h2>{user.email}</h2>
|
||||
{user.verifiedDomain && (
|
||||
<VerifiedDomainBadge
|
||||
verifiedDomain={user.verifiedDomain}
|
||||
verifiedAt={user.domainVerifiedAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-section">
|
||||
<button
|
||||
onClick={() => setShowVerification(!showVerification)}
|
||||
className="toggle-button"
|
||||
>
|
||||
{showVerification ? 'Hide' : 'Show'} Domain Verification
|
||||
</button>
|
||||
|
||||
{showVerification && (
|
||||
<div className="verification-container">
|
||||
<DomainVerification />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-section">
|
||||
<h3>About Domain Verification</h3>
|
||||
<p>
|
||||
Domain verification allows you to prove ownership of a domain by adding
|
||||
a DNS TXT record or connecting a wallet that owns a .aethex blockchain domain.
|
||||
</p>
|
||||
<ul>
|
||||
<li>✓ Verify traditional domains via DNS TXT records</li>
|
||||
<li>✓ Verify .aethex domains via blockchain</li>
|
||||
<li>✓ Display verified domain on your profile</li>
|
||||
<li>✓ Prevent domain impersonation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>© 2026 AeThex Corporation. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
579
astro-site/src/react-app/Demo.css
Normal file
579
astro-site/src/react-app/Demo.css
Normal file
|
|
@ -0,0 +1,579 @@
|
|||
/* Demo App Styles - Sleek Dark Gaming Theme (BitChat/Root Inspired) */
|
||||
|
||||
.demo-app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000000;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
/* Loading Screen */
|
||||
.loading-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-cont#0a0a0f;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
font-size: 4rem;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
margin-bottom: 1rem;
|
||||
filter: drop-shadow(0 0 20px rgba(139, 92, 246, 0.6));
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-screen p {
|
||||
font-size: 1.2rem;
|
||||
opacity: 0.7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.demo-header {
|
||||
background: #18181b;
|
||||
border-bottom: 1px solid #27272a;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
padding: 10 2px 10px rgba(0, 0, 0, 0.1);
|
||||
padding: 1.5rem 2rem;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-section h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
color: #00d9ff;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
text-shadow: 0 0 30px rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 0.25rem 0 0 0;
|
||||
color: #71717a;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 0.8rem;
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.demo-nav {
|
||||
background: #18181b;
|
||||
border-bottom: 1px solid #27272a;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 2rem;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f3f46 transparent;
|
||||
}
|
||||
|
||||
.demo-nav::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.demo-nav::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.demo-nav::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
background: #09090b;
|
||||
border: 1px solid #27272a;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-width: 120px;
|
||||
color: #a1a1aa;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background: #18181b;
|
||||
border-color: #3f3f46;
|
||||
color: #e4e4e7;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
border-color: transparent;
|
||||
color: #000000;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(0, 217, 255, 0.4);
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tab-phase {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-tab.active .tab-phase {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.demo-main {
|
||||
flex: 1;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
/* Overview Section */
|
||||
.overview-section {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.overview-section h2 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
color: #e4e4e7;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.intro {
|
||||
color: #a1a1aa;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Feature Grid */
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: #09090b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: #3f3f46;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.feature-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
filter: drop-shadow(0 4px 8px rgba(139, 92, 246, 0.3));
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
margin: 0.5rem 0;
|
||||
color: #e4e4e7;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: #71717a;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.feature-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.feature-card ul li {
|
||||
padding: 0.4rem 0;
|
||||
color: #a1a1aa;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.feature-card ul li:before {
|
||||
content: "✓";
|
||||
color: #00d9ff;
|
||||
font-weight: bold;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.phase-1 { background: rgba(59, 130, 246, 0.15); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
|
||||
.phase-2 { background: rgba(168, 85, 247, 0.15); color: #c084fc; border: 1px solid rgba(168, 85, 247, 0.3); }
|
||||
.phase-3 { background: rgba(34, 197, 94, 0.15); color: #4ade80; border: 1px solid rgba(34, 197, 94, 0.3); }
|
||||
.phase-4 { background: rgba(251, 146, 60, 0.15); color: #fb923c; border: 1px solid rgba(251, 146, 60, 0.3); }
|
||||
.phase-5 { background: rgba(236, 72, 153, 0.15); color: #f472b6; border: 1px solid rgba(236, 72, 153, 0.3); }
|
||||
.phase-6 { background: rgba(234, 179, 8, 0.15); color: #fbbf24; border: 1px solid rgba(234, 179, 8, 0.3); }
|
||||
|
||||
/* Status Section */
|
||||
.status-section {
|
||||
background: linear-gradient(135deg, #18181b 0%, #27272a 100%);
|
||||
border: 1px solid #3f3f46;
|
||||
color: #e4e4e7;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
margin: 2rem 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
right: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: radial-gradient(circle, rgba(139, 92, 246, 0.1) 0%, transparent 70%);
|
||||
animation: rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.status-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.status-section p {
|
||||
margin: 0.5rem 0;
|
||||
opacity: 0.9;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.platform-badges {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
background: rgba(24, 24, 27, 0.6);
|
||||
border: 1px solid #3f3f46;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
backdrop-filter: blur(10px);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.platform-badge:hover {
|
||||
background: rgba(0, 217, 255, 0.15);
|
||||
border-color: #00d9ff;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.timeline {
|
||||
font-style: italic;
|
||||
opacity: 0.8;
|
||||
margin-top: 1rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Quick Stats */
|
||||
.quick-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1.25rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
background: #09090b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #00d9ff, #00ff88);
|
||||
transform: scaleX(0);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.stat:hover {
|
||||
border-color: #3f3f46;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat:hover::before {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #71717a;
|
||||
margin-top: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Feature Section */
|
||||
.feature-section {
|
||||
background: #18181b;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #27272a;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
color: #e4e4e7;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: #a1a1aa;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.demo-footer {
|
||||
background: #18181b;
|
||||
border-top: 1px solid #27272a;
|
||||
margin-top: auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
color: #71717a;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-section ul li {
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: #71717a;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-section ul li:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.footer-section ul li a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-section ul li a:hover {
|
||||
color: #00d9ff;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
text-align: center;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #27272a;
|
||||
}
|
||||
|
||||
.footer-bottom p {
|
||||
margin: 0;
|
||||
color: #52525b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.demo-nav {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
311
astro-site/src/react-app/Demo.jsx
Normal file
311
astro-site/src/react-app/Demo.jsx
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import React, { useState } from 'react';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import DomainVerification from './components/DomainVerification';
|
||||
import VerifiedDomainBadge from './components/VerifiedDomainBadge';
|
||||
import Chat from './components/Chat/Chat';
|
||||
import Call from './components/Call';
|
||||
import GameForgeChat from './components/GameForgeChat';
|
||||
import UpgradeFlow from './components/Premium';
|
||||
import './App.css';
|
||||
|
||||
/**
|
||||
* Comprehensive demo showcasing all AeThex Connect features
|
||||
* Phases 1-6 implementation
|
||||
*/
|
||||
function DemoContent() {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// Show loading state while auth initializes
|
||||
if (loading || !user) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner">{String.fromCodePoint(0x1F680)}</div>
|
||||
<p>Loading AeThex Connect...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: '🏠 Overview', icon: '🏠' },
|
||||
{ id: 'domain', label: '🌐 Domain Verification', phase: 'Phase 1' },
|
||||
{ id: 'messaging', label: '💬 Real-time Chat', phase: 'Phase 2' },
|
||||
{ id: 'gameforge', label: '🎮 GameForge', phase: 'Phase 3' },
|
||||
{ id: 'calls', label: '📞 Voice/Video', phase: 'Phase 4' },
|
||||
{ id: 'premium', label: '⭐ Premium', phase: 'Phase 6' }
|
||||
];
|
||||
|
||||
return (
|
||||
<SocketProvider>
|
||||
<div className="demo-app">
|
||||
<header className="demo-header">
|
||||
<div className="header-content">
|
||||
<div className="logo-section">
|
||||
<h1>🚀 AeThex Connect</h1>
|
||||
<p className="tagline">Next-Gen Communication for Gamers</p>
|
||||
</div>
|
||||
<div className="user-section">
|
||||
<div className="user-info">
|
||||
<span className="user-name">{user.name}</span>
|
||||
<span className="user-email">{user.email}</span>
|
||||
</div>
|
||||
{user.verifiedDomain && (
|
||||
<VerifiedDomainBadge
|
||||
verifiedDomain={user.verifiedDomain}
|
||||
verifiedAt={user.domainVerifiedAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="demo-nav">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`nav-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="tab-label">{tab.label}</span>
|
||||
{tab.phase && <span className="tab-phase">{tab.phase}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<main className="demo-main">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="overview-section">
|
||||
<h2>Welcome to AeThex Connect</h2>
|
||||
<p className="intro">
|
||||
A comprehensive communication platform built specifically for gamers and game developers.
|
||||
Explore each feature using the tabs above.
|
||||
</p>
|
||||
|
||||
<div className="feature-grid">
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">🌐</div>
|
||||
<h3>Domain Verification</h3>
|
||||
<span className="badge phase-1">Phase 1</span>
|
||||
<p>Verify ownership of traditional domains (DNS) or blockchain .aethex domains</p>
|
||||
<ul>
|
||||
<li>DNS TXT record verification</li>
|
||||
<li>Blockchain domain integration</li>
|
||||
<li>Verified profile badges</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">💬</div>
|
||||
<h3>Real-time Messaging</h3>
|
||||
<span className="badge phase-2">Phase 2</span>
|
||||
<p>Instant, encrypted messaging with WebSocket connections</p>
|
||||
<ul>
|
||||
<li>Private conversations</li>
|
||||
<li>Message history</li>
|
||||
<li>Read receipts</li>
|
||||
<li>Typing indicators</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">🎮</div>
|
||||
<h3>GameForge Integration</h3>
|
||||
<span className="badge phase-3">Phase 3</span>
|
||||
<p>Built-in chat for game development teams</p>
|
||||
<ul>
|
||||
<li>Project channels</li>
|
||||
<li>Team collaboration</li>
|
||||
<li>Build notifications</li>
|
||||
<li>Asset sharing</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">📞</div>
|
||||
<h3>Voice & Video Calls</h3>
|
||||
<span className="badge phase-4">Phase 4</span>
|
||||
<p>High-quality WebRTC calls with screen sharing</p>
|
||||
<ul>
|
||||
<li>1-on-1 voice calls</li>
|
||||
<li>Video conferencing</li>
|
||||
<li>Screen sharing</li>
|
||||
<li>Call recording</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">🔗</div>
|
||||
<h3>Nexus Engine</h3>
|
||||
<span className="badge phase-5">Phase 5</span>
|
||||
<p>Cross-game identity and social features</p>
|
||||
<ul>
|
||||
<li>Unified player profiles</li>
|
||||
<li>Friend system</li>
|
||||
<li>Game lobbies</li>
|
||||
<li>Rich presence</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">⭐</div>
|
||||
<h3>Premium Subscriptions</h3>
|
||||
<span className="badge phase-6">Phase 6</span>
|
||||
<p>Monetization with blockchain domains</p>
|
||||
<ul>
|
||||
<li>.aethex domain marketplace</li>
|
||||
<li>Premium tiers ($10/mo)</li>
|
||||
<li>Enterprise plans</li>
|
||||
<li>Stripe integration</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="status-section">
|
||||
<h3>🚀 Phase 7: Full Platform (In Progress)</h3>
|
||||
<p>Transform AeThex Connect into cross-platform apps:</p>
|
||||
<div className="platform-badges">
|
||||
<span className="platform-badge">🌐 Progressive Web App</span>
|
||||
<span className="platform-badge">📱 iOS & Android</span>
|
||||
<span className="platform-badge">💻 Windows, macOS, Linux</span>
|
||||
</div>
|
||||
<p className="timeline">Expected completion: May 2026 (5 months)</p>
|
||||
</div>
|
||||
|
||||
<div className="quick-stats">
|
||||
<div className="stat">
|
||||
<div className="stat-value">6</div>
|
||||
<div className="stat-label">Phases Complete</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">1</div>
|
||||
<div className="stat-label">Phase In Progress</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">3</div>
|
||||
<div className="stat-label">Platforms</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-value">95%</div>
|
||||
<div className="stat-label">Code Sharing</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'domain' && (
|
||||
<div className="feature-section">
|
||||
<div className="section-header">
|
||||
<h2>🌐 Domain Verification</h2>
|
||||
<span className="badge phase-1">Phase 1</span>
|
||||
</div>
|
||||
<p className="section-description">
|
||||
Prove ownership of your domain to display it on your profile and prevent impersonation.
|
||||
Supports both traditional domains (via DNS) and blockchain .aethex domains.
|
||||
</p>
|
||||
<DomainVerification />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'messaging' && (
|
||||
<div className="feature-section">
|
||||
<div className="section-header">
|
||||
<h2>💬 Real-time Messaging</h2>
|
||||
<span className="badge phase-2">Phase 2</span>
|
||||
</div>
|
||||
<p className="section-description">
|
||||
Private encrypted conversations with real-time delivery. Messages sync across all devices.
|
||||
</p>
|
||||
<Chat />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'gameforge' && (
|
||||
<div className="feature-section">
|
||||
<div className="section-header">
|
||||
<h2>🎮 GameForge Integration</h2>
|
||||
<span className="badge phase-3">Phase 3</span>
|
||||
</div>
|
||||
<p className="section-description">
|
||||
Collaborate with your game development team. Channels auto-provision with your GameForge projects.
|
||||
</p>
|
||||
<GameForgeChat />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'calls' && (
|
||||
<div className="feature-section">
|
||||
<div className="section-header">
|
||||
<h2>📞 Voice & Video Calls</h2>
|
||||
<span className="badge phase-4">Phase 4</span>
|
||||
</div>
|
||||
<p className="section-description">
|
||||
Crystal-clear WebRTC calls with screen sharing. Perfect for co-op gaming or team standups.
|
||||
</p>
|
||||
<Call />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'premium' && (
|
||||
<div className="feature-section">
|
||||
<div className="section-header">
|
||||
<h2>⭐ Premium Subscriptions</h2>
|
||||
<span className="badge phase-6">Phase 6</span>
|
||||
</div>
|
||||
<p className="section-description">
|
||||
Upgrade to unlock blockchain .aethex domains, increased storage, and advanced features.
|
||||
</p>
|
||||
<UpgradeFlow />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<footer className="demo-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-section">
|
||||
<h4>AeThex Connect</h4>
|
||||
<p>Next-generation communication platform</p>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Technology</h4>
|
||||
<ul>
|
||||
<li>React 18 + Vite</li>
|
||||
<li>WebSocket (Socket.io)</li>
|
||||
<li>WebRTC</li>
|
||||
<li>Stripe</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Phases</h4>
|
||||
<ul>
|
||||
<li>✅ Phase 1-6 Complete</li>
|
||||
<li>🔄 Phase 7 In Progress</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="footer-section">
|
||||
<h4>Links</h4>
|
||||
<ul>
|
||||
<li><a href="#" onClick={(e) => { e.preventDefault(); setActiveTab('overview'); }}>Overview</a></li>
|
||||
<li><a href="http://localhost:3000/health" target="_blank" rel="noopener noreferrer">API Health</a></li>
|
||||
<li><a href="https://github.com/AeThex-Corporation/AeThex-Connect" target="_blank" rel="noopener noreferrer">GitHub</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>© 2026 AeThex Corporation. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Demo() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DemoContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default Demo;
|
||||
345
astro-site/src/react-app/components/Call/Call.css
Normal file
345
astro-site/src/react-app/components/Call/Call.css
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
.call-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #1a1a1a;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.call-error {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.call-error button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.call-header {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.call-status {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quality-indicator {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.remote-videos {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
/* Grid layouts for different participant counts */
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(1):last-child) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(2)) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(3)),
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(4)) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(5)),
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(6)) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.remote-videos:has(.remote-video-wrapper:nth-child(7)) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.remote-video-wrapper {
|
||||
position: relative;
|
||||
background-color: #2c2c2c;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.remote-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
left: 12px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.local-video-wrapper {
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
width: 200px;
|
||||
height: 150px;
|
||||
background-color: #2c2c2c;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #ffffff20;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.local-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1); /* Mirror effect for local video */
|
||||
}
|
||||
|
||||
.local-label {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
padding: 16px 24px;
|
||||
border-radius: 50px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: #3c3c3c;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.control-btn:hover {
|
||||
transform: scale(1.1);
|
||||
background-color: #4c4c4c;
|
||||
}
|
||||
|
||||
.control-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.control-btn.active {
|
||||
background-color: #4CAF50;
|
||||
}
|
||||
|
||||
.control-btn.inactive {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.control-btn.accept-btn {
|
||||
background-color: #4CAF50;
|
||||
width: 120px;
|
||||
border-radius: 28px;
|
||||
font-size: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn.accept-btn .icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.control-btn.reject-btn {
|
||||
background-color: #f44336;
|
||||
width: 120px;
|
||||
border-radius: 28px;
|
||||
font-size: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-btn.reject-btn .icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.control-btn.end-btn {
|
||||
background-color: #f44336;
|
||||
}
|
||||
|
||||
.control-btn.end-btn:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.start-call-btn {
|
||||
padding: 16px 32px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: all 0.2s ease;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.start-call-btn.audio {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.start-call-btn.video {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
.start-call-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.start-call-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.local-video-wrapper {
|
||||
width: 120px;
|
||||
height: 90px;
|
||||
bottom: 110px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.call-controls {
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.control-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.control-btn.accept-btn,
|
||||
.control-btn.reject-btn {
|
||||
width: 100px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.remote-videos {
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.call-actions {
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.start-call-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animation for ringing */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.call-status:has(:contains("Calling")) {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
556
astro-site/src/react-app/components/Call/index.jsx
Normal file
556
astro-site/src/react-app/components/Call/index.jsx
Normal file
|
|
@ -0,0 +1,556 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import WebRTCManager from '../../utils/webrtc';
|
||||
import './Call.css';
|
||||
|
||||
const Call = ({ socket, conversationId, participants, onCallEnd }) => {
|
||||
const [callId, setCallId] = useState(null);
|
||||
const [callStatus, setCallStatus] = useState('idle'); // idle, initiating, ringing, connected, ended
|
||||
const [isAudioEnabled, setIsAudioEnabled] = useState(true);
|
||||
const [isVideoEnabled, setIsVideoEnabled] = useState(true);
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [callDuration, setCallDuration] = useState(0);
|
||||
const [connectionQuality, setConnectionQuality] = useState('good'); // good, fair, poor
|
||||
const [remoteParticipants, setRemoteParticipants] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const webrtcManager = useRef(null);
|
||||
const localVideoRef = useRef(null);
|
||||
const remoteVideosRef = useRef(new Map());
|
||||
const callStartTime = useRef(null);
|
||||
const durationInterval = useRef(null);
|
||||
const statsInterval = useRef(null);
|
||||
|
||||
/**
|
||||
* Initialize WebRTC manager
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
webrtcManager.current = new WebRTCManager(socket);
|
||||
|
||||
// Setup event handlers
|
||||
webrtcManager.current.onRemoteStream = handleRemoteStream;
|
||||
webrtcManager.current.onRemoteStreamRemoved = handleRemoteStreamRemoved;
|
||||
webrtcManager.current.onConnectionStateChange = handleConnectionStateChange;
|
||||
|
||||
return () => {
|
||||
if (webrtcManager.current) {
|
||||
webrtcManager.current.cleanup();
|
||||
}
|
||||
clearInterval(durationInterval.current);
|
||||
clearInterval(statsInterval.current);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Listen for incoming calls
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
socket.on('call:incoming', handleIncomingCall);
|
||||
socket.on('call:ended', handleCallEnded);
|
||||
|
||||
return () => {
|
||||
socket.off('call:incoming', handleIncomingCall);
|
||||
socket.off('call:ended', handleCallEnded);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
/**
|
||||
* Update call duration timer
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (callStatus === 'connected' && !durationInterval.current) {
|
||||
callStartTime.current = Date.now();
|
||||
durationInterval.current = setInterval(() => {
|
||||
const duration = Math.floor((Date.now() - callStartTime.current) / 1000);
|
||||
setCallDuration(duration);
|
||||
}, 1000);
|
||||
} else if (callStatus !== 'connected' && durationInterval.current) {
|
||||
clearInterval(durationInterval.current);
|
||||
durationInterval.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (durationInterval.current) {
|
||||
clearInterval(durationInterval.current);
|
||||
}
|
||||
};
|
||||
}, [callStatus]);
|
||||
|
||||
/**
|
||||
* Monitor connection quality
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (callStatus === 'connected' && !statsInterval.current) {
|
||||
statsInterval.current = setInterval(async () => {
|
||||
if (webrtcManager.current && remoteParticipants.length > 0) {
|
||||
const firstParticipant = remoteParticipants[0];
|
||||
const stats = await webrtcManager.current.getConnectionStats(firstParticipant.userId);
|
||||
|
||||
if (stats && stats.connection) {
|
||||
const rtt = stats.connection.roundTripTime || 0;
|
||||
const bitrate = stats.connection.availableOutgoingBitrate || 0;
|
||||
|
||||
// Determine quality based on RTT and bitrate
|
||||
if (rtt < 0.1 && bitrate > 500000) {
|
||||
setConnectionQuality('good');
|
||||
} else if (rtt < 0.3 && bitrate > 200000) {
|
||||
setConnectionQuality('fair');
|
||||
} else {
|
||||
setConnectionQuality('poor');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
} else if (callStatus !== 'connected' && statsInterval.current) {
|
||||
clearInterval(statsInterval.current);
|
||||
statsInterval.current = null;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (statsInterval.current) {
|
||||
clearInterval(statsInterval.current);
|
||||
}
|
||||
};
|
||||
}, [callStatus, remoteParticipants]);
|
||||
|
||||
/**
|
||||
* Handle incoming call
|
||||
*/
|
||||
const handleIncomingCall = async (data) => {
|
||||
console.log('Incoming call:', data);
|
||||
setCallId(data.callId);
|
||||
setCallStatus('ringing');
|
||||
setRemoteParticipants(data.participants || []);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle remote stream received
|
||||
*/
|
||||
const handleRemoteStream = (userId, stream) => {
|
||||
console.log('Remote stream received from:', userId);
|
||||
|
||||
// Get or create video element for this user
|
||||
const videoElement = remoteVideosRef.current.get(userId);
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle remote stream removed
|
||||
*/
|
||||
const handleRemoteStreamRemoved = (userId) => {
|
||||
console.log('Remote stream removed from:', userId);
|
||||
setRemoteParticipants(prev => prev.filter(p => p.userId !== userId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle connection state change
|
||||
*/
|
||||
const handleConnectionStateChange = (userId, state) => {
|
||||
console.log(`Connection state with ${userId}:`, state);
|
||||
|
||||
if (state === 'connected') {
|
||||
setCallStatus('connected');
|
||||
} else if (state === 'failed' || state === 'disconnected') {
|
||||
setError(`Connection ${state} with user ${userId}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle call ended
|
||||
*/
|
||||
const handleCallEnded = (data) => {
|
||||
console.log('Call ended:', data);
|
||||
setCallStatus('ended');
|
||||
|
||||
if (webrtcManager.current) {
|
||||
webrtcManager.current.cleanup();
|
||||
}
|
||||
|
||||
if (onCallEnd) {
|
||||
onCallEnd(data);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initiate a new call
|
||||
*/
|
||||
const initiateCall = async (type = 'video') => {
|
||||
try {
|
||||
setCallStatus('initiating');
|
||||
setError(null);
|
||||
|
||||
// Get TURN credentials
|
||||
const turnResponse = await axios.get('/api/calls/turn-credentials', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (webrtcManager.current && turnResponse.data.credentials) {
|
||||
await webrtcManager.current.setTurnCredentials(turnResponse.data.credentials);
|
||||
}
|
||||
|
||||
// Initialize local media stream
|
||||
const audioEnabled = true;
|
||||
const videoEnabled = type === 'video';
|
||||
|
||||
if (webrtcManager.current) {
|
||||
const localStream = await webrtcManager.current.initializeLocalStream(audioEnabled, videoEnabled);
|
||||
|
||||
// Display local video
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = localStream;
|
||||
}
|
||||
}
|
||||
|
||||
// Initiate call via API
|
||||
const response = await axios.post('/api/calls/initiate', {
|
||||
conversationId: conversationId,
|
||||
type: type,
|
||||
participantIds: participants.map(p => p.userId)
|
||||
}, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
const { callId: newCallId } = response.data;
|
||||
setCallId(newCallId);
|
||||
setCallStatus('ringing');
|
||||
|
||||
if (webrtcManager.current) {
|
||||
webrtcManager.current.currentCallId = newCallId;
|
||||
webrtcManager.current.isInitiator = true;
|
||||
|
||||
// Create peer connections for each participant
|
||||
for (const participant of participants) {
|
||||
await webrtcManager.current.initiateCallToUser(participant.userId);
|
||||
}
|
||||
}
|
||||
|
||||
setRemoteParticipants(participants);
|
||||
} catch (err) {
|
||||
console.error('Error initiating call:', err);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to initiate call');
|
||||
setCallStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Answer incoming call
|
||||
*/
|
||||
const answerCall = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Get TURN credentials
|
||||
const turnResponse = await axios.get('/api/calls/turn-credentials', {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
if (webrtcManager.current && turnResponse.data.credentials) {
|
||||
await webrtcManager.current.setTurnCredentials(turnResponse.data.credentials);
|
||||
}
|
||||
|
||||
// Initialize local media stream
|
||||
if (webrtcManager.current) {
|
||||
const localStream = await webrtcManager.current.initializeLocalStream(true, true);
|
||||
|
||||
// Display local video
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = localStream;
|
||||
}
|
||||
}
|
||||
|
||||
// Answer call via API
|
||||
await axios.post(`/api/calls/${callId}/answer`, {}, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
setCallStatus('connected');
|
||||
} catch (err) {
|
||||
console.error('Error answering call:', err);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to answer call');
|
||||
setCallStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject incoming call
|
||||
*/
|
||||
const rejectCall = async () => {
|
||||
try {
|
||||
await axios.post(`/api/calls/${callId}/reject`, {}, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
|
||||
setCallStatus('idle');
|
||||
setCallId(null);
|
||||
} catch (err) {
|
||||
console.error('Error rejecting call:', err);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to reject call');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* End active call
|
||||
*/
|
||||
const endCall = async () => {
|
||||
try {
|
||||
if (callId) {
|
||||
await axios.post(`/api/calls/${callId}/end`, {}, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
}
|
||||
|
||||
if (webrtcManager.current) {
|
||||
webrtcManager.current.cleanup();
|
||||
}
|
||||
|
||||
setCallStatus('ended');
|
||||
setCallId(null);
|
||||
|
||||
if (onCallEnd) {
|
||||
onCallEnd({ reason: 'ended-by-user' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error ending call:', err);
|
||||
setError(err.response?.data?.message || err.message || 'Failed to end call');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle audio on/off
|
||||
*/
|
||||
const toggleAudio = async () => {
|
||||
if (webrtcManager.current) {
|
||||
const enabled = !isAudioEnabled;
|
||||
webrtcManager.current.toggleAudio(enabled);
|
||||
setIsAudioEnabled(enabled);
|
||||
|
||||
// Update media state via API
|
||||
if (callId) {
|
||||
try {
|
||||
await axios.patch(`/api/calls/${callId}/media`, {
|
||||
audioEnabled: enabled
|
||||
}, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error updating media state:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle video on/off
|
||||
*/
|
||||
const toggleVideo = async () => {
|
||||
if (webrtcManager.current) {
|
||||
const enabled = !isVideoEnabled;
|
||||
webrtcManager.current.toggleVideo(enabled);
|
||||
setIsVideoEnabled(enabled);
|
||||
|
||||
// Update media state via API
|
||||
if (callId) {
|
||||
try {
|
||||
await axios.patch(`/api/calls/${callId}/media`, {
|
||||
videoEnabled: enabled
|
||||
}, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error updating media state:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle screen sharing
|
||||
*/
|
||||
const toggleScreenShare = async () => {
|
||||
if (webrtcManager.current) {
|
||||
try {
|
||||
if (isScreenSharing) {
|
||||
webrtcManager.current.stopScreenShare();
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Restore local video
|
||||
if (localVideoRef.current && webrtcManager.current.getLocalStream()) {
|
||||
localVideoRef.current.srcObject = webrtcManager.current.getLocalStream();
|
||||
}
|
||||
} else {
|
||||
const screenStream = await webrtcManager.current.startScreenShare();
|
||||
setIsScreenSharing(true);
|
||||
|
||||
// Display screen in local video
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = screenStream;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error toggling screen share:', err);
|
||||
setError('Failed to share screen');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format call duration (HH:MM:SS or MM:SS)
|
||||
*/
|
||||
const formatDuration = (seconds) => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render call controls
|
||||
*/
|
||||
const renderControls = () => {
|
||||
if (callStatus === 'ringing' && !webrtcManager.current?.isInitiator) {
|
||||
return (
|
||||
<div className="call-controls">
|
||||
<button className="control-btn accept-btn" onClick={answerCall}>
|
||||
<span className="icon">📞</span>
|
||||
Answer
|
||||
</button>
|
||||
<button className="control-btn reject-btn" onClick={rejectCall}>
|
||||
<span className="icon">📵</span>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (callStatus === 'connected' || callStatus === 'ringing') {
|
||||
return (
|
||||
<div className="call-controls">
|
||||
<button
|
||||
className={`control-btn ${isAudioEnabled ? 'active' : 'inactive'}`}
|
||||
onClick={toggleAudio}
|
||||
>
|
||||
<span className="icon">{isAudioEnabled ? '🎤' : '🔇'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`control-btn ${isVideoEnabled ? 'active' : 'inactive'}`}
|
||||
onClick={toggleVideo}
|
||||
>
|
||||
<span className="icon">{isVideoEnabled ? '📹' : '🚫'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`control-btn ${isScreenSharing ? 'active' : ''}`}
|
||||
onClick={toggleScreenShare}
|
||||
>
|
||||
<span className="icon">🖥️</span>
|
||||
</button>
|
||||
|
||||
<button className="control-btn end-btn" onClick={endCall}>
|
||||
<span className="icon">📵</span>
|
||||
End
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render connection quality indicator
|
||||
*/
|
||||
const renderQualityIndicator = () => {
|
||||
if (callStatus !== 'connected') return null;
|
||||
|
||||
const colors = {
|
||||
good: '#4CAF50',
|
||||
fair: '#FFC107',
|
||||
poor: '#F44336'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="quality-indicator" style={{ backgroundColor: colors[connectionQuality] }}>
|
||||
{connectionQuality}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="call-container">
|
||||
{error && (
|
||||
<div className="call-error">
|
||||
{error}
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="call-header">
|
||||
<div className="call-status">
|
||||
{callStatus === 'ringing' && 'Calling...'}
|
||||
{callStatus === 'connected' && `Call Duration: ${formatDuration(callDuration)}`}
|
||||
{callStatus === 'ended' && 'Call Ended'}
|
||||
</div>
|
||||
{renderQualityIndicator()}
|
||||
</div>
|
||||
|
||||
<div className="video-container">
|
||||
{/* Remote videos */}
|
||||
<div className="remote-videos">
|
||||
{remoteParticipants.map(participant => (
|
||||
<div key={participant.userId} className="remote-video-wrapper">
|
||||
<video
|
||||
ref={el => {
|
||||
if (el) remoteVideosRef.current.set(participant.userId, el);
|
||||
}}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="remote-video"
|
||||
/>
|
||||
<div className="participant-name">{participant.userName || participant.userIdentifier}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Local video */}
|
||||
{(callStatus === 'ringing' || callStatus === 'connected') && (
|
||||
<div className="local-video-wrapper">
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="local-video"
|
||||
/>
|
||||
<div className="local-label">You</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderControls()}
|
||||
|
||||
{callStatus === 'idle' && (
|
||||
<div className="call-actions">
|
||||
<button className="start-call-btn audio" onClick={() => initiateCall('audio')}>
|
||||
🎤 Start Audio Call
|
||||
</button>
|
||||
<button className="start-call-btn video" onClick={() => initiateCall('video')}>
|
||||
📹 Start Video Call
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Call;
|
||||
156
astro-site/src/react-app/components/Chat/Chat.css
Normal file
156
astro-site/src/react-app/components/Chat/Chat.css
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* Chat Container - Dark Gaming Theme */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #000000;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-status {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
background: rgba(0, 255, 136, 0.2);
|
||||
color: #00ff88;
|
||||
border: 1px solid #00ff88;
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
/* Main Chat Area */
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(10, 10, 15, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
border-left: 1px solid rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.conversation-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.conversation-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.conversation-info h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.participant-info {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* No Conversation Selected */
|
||||
.no-conversation-selected {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.no-conversation-selected p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.no-conversation-selected .hint {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Loading/Error States */
|
||||
.chat-loading,
|
||||
.chat-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-error p {
|
||||
color: #dc2626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-error button {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chat-error button:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.chat-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
425
astro-site/src/react-app/components/Chat/Chat.jsx
Normal file
425
astro-site/src/react-app/components/Chat/Chat.jsx
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
/**
|
||||
* Chat Component - Main messaging interface
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useSocket } from '../../contexts/SocketContext';
|
||||
import ConversationList from './ConversationList';
|
||||
import MessageList from './MessageList';
|
||||
import MessageInput from './MessageInput';
|
||||
import './Chat.css';
|
||||
|
||||
export default function Chat() {
|
||||
const { socket, connected } = useSocket();
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [activeConversation, setActiveConversation] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [typingUsers, setTypingUsers] = useState(new Set());
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const typingTimeoutRef = useRef(null);
|
||||
|
||||
// Load conversations on mount
|
||||
useEffect(() => {
|
||||
loadConversations();
|
||||
}, []);
|
||||
|
||||
// Socket event listeners
|
||||
useEffect(() => {
|
||||
if (!socket || !connected) return;
|
||||
|
||||
// New message
|
||||
socket.on('new_message', handleNewMessage);
|
||||
|
||||
// Message edited
|
||||
socket.on('message_edited', handleMessageEdited);
|
||||
|
||||
// Message deleted
|
||||
socket.on('message_deleted', handleMessageDeleted);
|
||||
|
||||
// Reaction added
|
||||
socket.on('reaction_added', handleReactionAdded);
|
||||
|
||||
// Reaction removed
|
||||
socket.on('reaction_removed', handleReactionRemoved);
|
||||
|
||||
// Typing indicators
|
||||
socket.on('user_typing', handleTypingStart);
|
||||
socket.on('user_stopped_typing', handleTypingStop);
|
||||
|
||||
// User status changed
|
||||
socket.on('user_status_changed', handleStatusChange);
|
||||
|
||||
return () => {
|
||||
socket.off('new_message', handleNewMessage);
|
||||
socket.off('message_edited', handleMessageEdited);
|
||||
socket.off('message_deleted', handleMessageDeleted);
|
||||
socket.off('reaction_added', handleReactionAdded);
|
||||
socket.off('reaction_removed', handleReactionRemoved);
|
||||
socket.off('user_typing', handleTypingStart);
|
||||
socket.off('user_stopped_typing', handleTypingStop);
|
||||
socket.off('user_status_changed', handleStatusChange);
|
||||
};
|
||||
}, [socket, connected, activeConversation]);
|
||||
|
||||
// Load conversations from API
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/messaging/conversations`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setConversations(data.conversations);
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error);
|
||||
setError('Failed to load conversations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load messages for a conversation
|
||||
const loadMessages = async (conversationId) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/messaging/conversations/${conversationId}/messages`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setMessages(data.messages);
|
||||
|
||||
// Mark as read
|
||||
if (data.messages.length > 0) {
|
||||
markAsRead(conversationId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Select conversation
|
||||
const selectConversation = async (conversation) => {
|
||||
setActiveConversation(conversation);
|
||||
await loadMessages(conversation.id);
|
||||
};
|
||||
|
||||
// Handle new message
|
||||
const handleNewMessage = (message) => {
|
||||
// If this conversation is active, add message
|
||||
if (activeConversation && activeConversation.id === message.conversationId) {
|
||||
setMessages(prev => [...prev, message]);
|
||||
|
||||
// Mark as read
|
||||
markAsRead(message.conversationId);
|
||||
}
|
||||
|
||||
// Update conversation list (move to top, update last message)
|
||||
setConversations(prev => {
|
||||
const updated = prev.map(conv => {
|
||||
if (conv.id === message.conversationId) {
|
||||
return {
|
||||
...conv,
|
||||
lastMessage: message,
|
||||
updatedAt: message.createdAt,
|
||||
unreadCount: activeConversation?.id === message.conversationId ? 0 : conv.unreadCount + 1
|
||||
};
|
||||
}
|
||||
return conv;
|
||||
});
|
||||
|
||||
// Sort by updated_at
|
||||
return updated.sort((a, b) =>
|
||||
new Date(b.updatedAt) - new Date(a.updatedAt)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Handle message edited
|
||||
const handleMessageEdited = (data) => {
|
||||
const { messageId, content, editedAt } = data;
|
||||
|
||||
setMessages(prev => prev.map(msg =>
|
||||
msg.id === messageId
|
||||
? { ...msg, content: content, editedAt: editedAt }
|
||||
: msg
|
||||
));
|
||||
};
|
||||
|
||||
// Handle message deleted
|
||||
const handleMessageDeleted = (data) => {
|
||||
const { messageId } = data;
|
||||
setMessages(prev => prev.filter(msg => msg.id !== messageId));
|
||||
};
|
||||
|
||||
// Handle reaction added
|
||||
const handleReactionAdded = (data) => {
|
||||
const { messageId, emoji, userId } = data;
|
||||
|
||||
setMessages(prev => prev.map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
const reactions = [...(msg.reactions || [])];
|
||||
const existing = reactions.find(r => r.emoji === emoji);
|
||||
|
||||
if (existing) {
|
||||
if (!existing.users.includes(userId)) {
|
||||
existing.users.push(userId);
|
||||
}
|
||||
} else {
|
||||
reactions.push({
|
||||
emoji: emoji,
|
||||
users: [userId]
|
||||
});
|
||||
}
|
||||
|
||||
return { ...msg, reactions: reactions };
|
||||
}
|
||||
return msg;
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle reaction removed
|
||||
const handleReactionRemoved = (data) => {
|
||||
const { messageId, emoji, userId } = data;
|
||||
|
||||
setMessages(prev => prev.map(msg => {
|
||||
if (msg.id === messageId) {
|
||||
const reactions = (msg.reactions || [])
|
||||
.map(r => {
|
||||
if (r.emoji === emoji) {
|
||||
return {
|
||||
...r,
|
||||
users: r.users.filter(u => u !== userId)
|
||||
};
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.filter(r => r.users.length > 0);
|
||||
|
||||
return { ...msg, reactions: reactions };
|
||||
}
|
||||
return msg;
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle typing start
|
||||
const handleTypingStart = (data) => {
|
||||
const { conversationId, userId } = data;
|
||||
|
||||
if (activeConversation && activeConversation.id === conversationId) {
|
||||
setTypingUsers(prev => new Set([...prev, userId]));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle typing stop
|
||||
const handleTypingStop = (data) => {
|
||||
const { conversationId, userId } = data;
|
||||
|
||||
if (activeConversation && activeConversation.id === conversationId) {
|
||||
setTypingUsers(prev => {
|
||||
const updated = new Set(prev);
|
||||
updated.delete(userId);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle user status change
|
||||
const handleStatusChange = (data) => {
|
||||
const { userId, status } = data;
|
||||
|
||||
// Update conversation participants
|
||||
setConversations(prev => prev.map(conv => ({
|
||||
...conv,
|
||||
participants: conv.participants?.map(p =>
|
||||
p.id === userId ? { ...p, status: status } : p
|
||||
)
|
||||
})));
|
||||
|
||||
// Update active conversation
|
||||
if (activeConversation) {
|
||||
setActiveConversation(prev => ({
|
||||
...prev,
|
||||
participants: prev.participants?.map(p =>
|
||||
p.id === userId ? { ...p, status: status } : p
|
||||
)
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Send message
|
||||
const sendMessage = async (content) => {
|
||||
if (!activeConversation || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/messaging/conversations/${activeConversation.id}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: content,
|
||||
contentType: 'text'
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Message will be received via socket event
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
setError('Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
// Start typing indicator
|
||||
const startTyping = () => {
|
||||
if (!activeConversation || !socket) return;
|
||||
|
||||
socket.emit('typing_start', {
|
||||
conversationId: activeConversation.id
|
||||
});
|
||||
|
||||
// Auto-stop after 3 seconds
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
stopTyping();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Stop typing indicator
|
||||
const stopTyping = () => {
|
||||
if (!activeConversation || !socket) return;
|
||||
|
||||
socket.emit('typing_stop', {
|
||||
conversationId: activeConversation.id
|
||||
});
|
||||
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
typingTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Mark conversation as read
|
||||
const markAsRead = async (conversationId) => {
|
||||
try {
|
||||
await fetch(
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/messaging/conversations/${conversationId}/read`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update local state
|
||||
setConversations(prev => prev.map(conv =>
|
||||
conv.id === conversationId ? { ...conv, unreadCount: 0 } : conv
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="chat-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading conversations...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && conversations.length === 0) {
|
||||
return (
|
||||
<div className="chat-error">
|
||||
<p>⚠️ {error}</p>
|
||||
<button onClick={loadConversations}>Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<div className="chat-status">
|
||||
{connected ? (
|
||||
<span className="status-indicator online">● Connected</span>
|
||||
) : (
|
||||
<span className="status-indicator offline">○ Disconnected</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConversationList
|
||||
conversations={conversations}
|
||||
activeConversation={activeConversation}
|
||||
onSelectConversation={selectConversation}
|
||||
/>
|
||||
|
||||
<div className="chat-main">
|
||||
{activeConversation ? (
|
||||
<>
|
||||
<div className="chat-header">
|
||||
<div className="conversation-info">
|
||||
<div className="conversation-avatar">
|
||||
{activeConversation.title?.[0] || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h3>{activeConversation.title || 'Direct Message'}</h3>
|
||||
<p className="participant-info">
|
||||
{activeConversation.otherParticipants?.length || 0} participants
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageList
|
||||
messages={messages}
|
||||
typingUsers={Array.from(typingUsers)}
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
onSend={sendMessage}
|
||||
onTyping={startTyping}
|
||||
onStopTyping={stopTyping}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="no-conversation-selected">
|
||||
<p>Select a conversation to start messaging</p>
|
||||
<p className="hint">or create a new conversation</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
astro-site/src/react-app/components/Chat/ConversationList.css
Normal file
201
astro-site/src/react-app/components/Chat/ConversationList.css
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/* Conversation List Sidebar - Dark Gaming Theme */
|
||||
.conversation-list {
|
||||
width: 320px;
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border-right: 1px solid rgba(0, 217, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.conversation-list-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(0, 217, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conversation-list-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-new-conversation {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
color: #000000;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-new-conversation:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 217, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Conversation Items */
|
||||
.conversation-list-items {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.no-conversations {
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.no-conversations p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.no-conversations .hint {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
}
|
||||
|
||||
/* Conversation Avatar */
|
||||
.conversation-avatar-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-avatar-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.conversation-avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.online-indicator {
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #10b981;
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* Conversation Details */
|
||||
.conversation-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.conversation-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conversation-time {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.conversation-last-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.last-message-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.conversation-list-items::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.conversation-list-items::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.conversation-list-items::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.conversation-list-items::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
110
astro-site/src/react-app/components/Chat/ConversationList.jsx
Normal file
110
astro-site/src/react-app/components/Chat/ConversationList.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* ConversationList Component
|
||||
* Displays list of conversations in sidebar
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import './ConversationList.css';
|
||||
|
||||
export default function ConversationList({ conversations, activeConversation, onSelectConversation }) {
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now - date;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const getConversationTitle = (conv) => {
|
||||
if (conv.title) return conv.title;
|
||||
|
||||
// For direct conversations, show other participant's domain
|
||||
if (conv.otherParticipants && conv.otherParticipants.length > 0) {
|
||||
return conv.otherParticipants[0].verified_domain || conv.otherParticipants[0].username;
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
};
|
||||
|
||||
const getConversationAvatar = (conv) => {
|
||||
if (conv.avatarUrl) return conv.avatarUrl;
|
||||
|
||||
// For direct conversations, show other participant's avatar
|
||||
if (conv.otherParticipants && conv.otherParticipants.length > 0) {
|
||||
return conv.otherParticipants[0].avatar_url;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="conversation-list">
|
||||
<div className="conversation-list-header">
|
||||
<h2>Messages</h2>
|
||||
<button className="btn-new-conversation" title="New Conversation">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="conversation-list-items">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="no-conversations">
|
||||
<p>No conversations yet</p>
|
||||
<p className="hint">Start a new conversation to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map(conv => (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={`conversation-item ${activeConversation?.id === conv.id ? 'active' : ''}`}
|
||||
onClick={() => onSelectConversation(conv)}
|
||||
>
|
||||
<div className="conversation-avatar-container">
|
||||
{getConversationAvatar(conv) ? (
|
||||
<img
|
||||
src={getConversationAvatar(conv)}
|
||||
alt="Avatar"
|
||||
className="conversation-avatar-img"
|
||||
/>
|
||||
) : (
|
||||
<div className="conversation-avatar-placeholder">
|
||||
{getConversationTitle(conv)[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{conv.otherParticipants?.[0]?.status === 'online' && (
|
||||
<span className="online-indicator"></span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="conversation-details">
|
||||
<div className="conversation-header-row">
|
||||
<h3 className="conversation-title">{getConversationTitle(conv)}</h3>
|
||||
<span className="conversation-time">
|
||||
{formatTime(conv.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="conversation-last-message">
|
||||
<p className="last-message-text">
|
||||
{conv.lastMessage?.content || 'No messages yet'}
|
||||
</p>
|
||||
{conv.unreadCount > 0 && (
|
||||
<span className="unread-badge">{conv.unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
astro-site/src/react-app/components/Chat/MessageInput.css
Normal file
117
astro-site/src/react-app/components/Chat/MessageInput.css
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/* Message Input Container - Dark Gaming Theme */
|
||||
.message-input {
|
||||
padding: 1rem 1.5rem;
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(0, 217, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn-attach,
|
||||
.btn-emoji {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
color: #00d9ff;
|
||||
}
|
||||
|
||||
.btn-attach:hover,
|
||||
.btn-emoji:hover {
|
||||
background: rgba(0, 217, 255, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-attach:disabled,
|
||||
.btn-emoji:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Textarea */
|
||||
.message-textarea {
|
||||
flex: 1;
|
||||
min-height: 36px;
|
||||
max-height: 120px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid rgba(0, 217, 255, 0.2);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #ffffff;
|
||||
border-radius: 18px;
|
||||
font-size: 0.9375rem;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.message-textarea:focus {
|
||||
border-color: #00d9ff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
.message-textarea:disabled {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.message-textarea::placeholder {
|
||||
color: #71717a;
|
||||
}
|
||||
|
||||
/* Send Button */
|
||||
.btn-send {
|
||||
padding: 0.5rem 1.5rem;
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
color: #000000;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-send:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 217, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-send:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.message-input {
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-attach,
|
||||
.btn-emoji {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
134
astro-site/src/react-app/components/Chat/MessageInput.jsx
Normal file
134
astro-site/src/react-app/components/Chat/MessageInput.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* MessageInput Component
|
||||
* Input field for sending messages
|
||||
*/
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import './MessageInput.css';
|
||||
|
||||
export default function MessageInput({ onSend, onTyping, onStopTyping }) {
|
||||
const [message, setMessage] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
const typingTimeoutRef = useRef(null);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setMessage(e.target.value);
|
||||
|
||||
// Trigger typing indicator
|
||||
if (onTyping) onTyping();
|
||||
|
||||
// Reset stop-typing timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
if (onStopTyping) onStopTyping();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!message.trim()) return;
|
||||
|
||||
onSend(message);
|
||||
setMessage('');
|
||||
|
||||
if (onStopTyping) onStopTyping();
|
||||
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3000'}/api/files/upload`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Send message with file attachment
|
||||
onSend(`📎 ${file.name}`, [data.file]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error);
|
||||
alert('Failed to upload file');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="message-input" onSubmit={handleSubmit}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-attach"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
title="Attach file"
|
||||
>
|
||||
{uploading ? '⏳' : '📎'}
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
rows={1}
|
||||
disabled={uploading}
|
||||
className="message-textarea"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn-emoji"
|
||||
title="Add emoji"
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-send"
|
||||
disabled={!message.trim() || uploading}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
310
astro-site/src/react-app/components/Chat/MessageList.css
Normal file
310
astro-site/src/react-app/components/Chat/MessageList.css
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
/* Message List Container - Dark Gaming Theme */
|
||||
.message-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: 1rem;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.message-list.empty {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.no-messages p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.no-messages .hint {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Message Timestamp Divider */
|
||||
.message-timestamp-divider {
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-timestamp-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.message-timestamp-divider span,
|
||||
.message-timestamp-divider::after {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 1rem;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: #71717a;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: 1px solid rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Message */
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message.own {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message.other {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
/* Message Avatar */
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000000;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Message Content */
|
||||
.message-content-wrapper {
|
||||
max-width: 70%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message.own .message-content-wrapper {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message.other .message-content-wrapper {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-sender {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #a1a1aa;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.verified-badge {
|
||||
color: #00d9ff;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Message Bubble */
|
||||
.message-bubble {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.own .message-bubble {
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff88 100%);
|
||||
color: #000000;
|
||||
font-weight: 500;
|
||||
border-bottom-right-radius: 0.25rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.message.other .message-bubble {
|
||||
background: rgba(10, 10, 15, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(0, 217, 255, 0.2);
|
||||
border-bottom-left-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.message-reply-reference {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 2px solid currentColor;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-attachments {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.attachment {
|
||||
padding: 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.message.own .attachment {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Message Footer */
|
||||
.message-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.edited-indicator {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sending-indicator {
|
||||
font-style: italic;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Message Reactions */
|
||||
.message-reactions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.message.own .reaction {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.reaction:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.message.own .reaction:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Typing Indicator */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.typing-dots {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.typing-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #9ca3af;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-dots span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.typing-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
.message-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.message-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #9ca3af;
|
||||
}
|
||||
139
astro-site/src/react-app/components/Chat/MessageList.jsx
Normal file
139
astro-site/src/react-app/components/Chat/MessageList.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/**
|
||||
* MessageList Component
|
||||
* Displays messages in a conversation
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import './MessageList.css';
|
||||
|
||||
export default function MessageList({ messages, typingUsers }) {
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const formatTime = (timestamp) => {
|
||||
const date = new Date(timestamp);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
};
|
||||
|
||||
const getCurrentUserId = () => {
|
||||
// In a real app, get this from auth context
|
||||
return localStorage.getItem('userId');
|
||||
};
|
||||
|
||||
const isOwnMessage = (message) => {
|
||||
return message.senderId === getCurrentUserId();
|
||||
};
|
||||
|
||||
if (messages.length === 0 && typingUsers.length === 0) {
|
||||
return (
|
||||
<div className="message-list empty">
|
||||
<div className="no-messages">
|
||||
<p>No messages yet</p>
|
||||
<p className="hint">Send a message to start the conversation</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.map((message, index) => {
|
||||
const showAvatar = index === messages.length - 1 ||
|
||||
messages[index + 1]?.senderId !== message.senderId;
|
||||
|
||||
const showTimestamp = index === 0 ||
|
||||
new Date(message.createdAt) - new Date(messages[index - 1].createdAt) > 300000; // 5 mins
|
||||
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{showTimestamp && (
|
||||
<div className="message-timestamp-divider">
|
||||
{new Date(message.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`message ${isOwnMessage(message) ? 'own' : 'other'}`}>
|
||||
{!isOwnMessage(message) && showAvatar && (
|
||||
<div className="message-avatar">
|
||||
{message.senderAvatar ? (
|
||||
<img src={message.senderAvatar} alt={message.senderUsername} />
|
||||
) : (
|
||||
<div className="avatar-placeholder">
|
||||
{message.senderUsername?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-content-wrapper">
|
||||
{!isOwnMessage(message) && (
|
||||
<div className="message-sender">
|
||||
{message.senderDomain || message.senderUsername}
|
||||
{message.senderDomain && <span className="verified-badge">✓</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-bubble">
|
||||
{message.replyToId && (
|
||||
<div className="message-reply-reference">
|
||||
Replying to a message
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-text">{message.content}</div>
|
||||
|
||||
{message.metadata?.attachments && message.metadata.attachments.length > 0 && (
|
||||
<div className="message-attachments">
|
||||
{message.metadata.attachments.map((attachment, i) => (
|
||||
<div key={i} className="attachment">
|
||||
📎 {attachment.filename}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-footer">
|
||||
<span className="message-time">{formatTime(message.createdAt)}</span>
|
||||
{message.editedAt && <span className="edited-indicator">edited</span>}
|
||||
{message._sending && <span className="sending-indicator">sending...</span>}
|
||||
</div>
|
||||
|
||||
{message.reactions && message.reactions.length > 0 && (
|
||||
<div className="message-reactions">
|
||||
{message.reactions.map((reaction, i) => (
|
||||
<span key={i} className="reaction" title={`${reaction.users.length} reaction(s)`}>
|
||||
{reaction.emoji} {reaction.users.length > 1 && reaction.users.length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{typingUsers.length > 0 && (
|
||||
<div className="typing-indicator">
|
||||
<div className="typing-dots">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
<span className="typing-text">Someone is typing...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
316
astro-site/src/react-app/components/DomainVerification.css
Normal file
316
astro-site/src/react-app/components/DomainVerification.css
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
.domain-verification {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
background: #ffffff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.domain-verification h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1a1a1a;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.domain-verification .description {
|
||||
margin: 0 0 24px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Error message */
|
||||
.error-message {
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
background: #fee;
|
||||
border: 1px solid #fcc;
|
||||
border-radius: 6px;
|
||||
color: #c33;
|
||||
}
|
||||
|
||||
/* Input section */
|
||||
.input-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.domain-input,
|
||||
.wallet-input {
|
||||
padding: 12px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.domain-input:focus,
|
||||
.wallet-input:focus {
|
||||
outline: none;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
|
||||
.domain-input:disabled,
|
||||
.wallet-input:disabled {
|
||||
background: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.primary-button:hover:not(:disabled) {
|
||||
background: #4338ca;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.secondary-button:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Instructions section */
|
||||
.instructions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.instructions-section h4 {
|
||||
margin: 0;
|
||||
color: #1a1a1a;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* DNS record display */
|
||||
.dns-record {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.record-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.record-field strong {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.record-field span {
|
||||
font-size: 14px;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.value-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value-container code {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: #f3f4f6;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
/* Help section */
|
||||
.help-section {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.help-section p {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1e40af;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.help-section ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 24px;
|
||||
color: #1e3a8a;
|
||||
}
|
||||
|
||||
.help-section li {
|
||||
margin: 4px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.expires-note {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #bfdbfe;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
/* Status message */
|
||||
.status-message {
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-message.success {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #6ee7b7;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-message.error {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Blockchain verification */
|
||||
.blockchain-verification {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.blockchain-verification p {
|
||||
margin: 0;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Verified container */
|
||||
.verified-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.verified-container h3 {
|
||||
color: #059669;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.verified-domain-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
padding: 16px;
|
||||
background: #d1fae5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.verified-domain-display strong {
|
||||
font-size: 20px;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.verification-type {
|
||||
padding: 4px 8px;
|
||||
background: #059669;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.verified-date {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.domain-verification {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.value-container {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
313
astro-site/src/react-app/components/DomainVerification.jsx
Normal file
313
astro-site/src/react-app/components/DomainVerification.jsx
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import './DomainVerification.css';
|
||||
|
||||
/**
|
||||
* Domain verification UI component
|
||||
* Allows users to verify domain ownership via DNS TXT records or blockchain
|
||||
*/
|
||||
export default function DomainVerification({ apiBaseUrl = 'https://api.aethex.cloud/api/passport/domain' }) {
|
||||
const [domain, setDomain] = useState('');
|
||||
const [walletAddress, setWalletAddress] = useState('');
|
||||
const [verificationInstructions, setVerificationInstructions] = useState(null);
|
||||
const [verificationStatus, setVerificationStatus] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentStatus, setCurrentStatus] = useState(null);
|
||||
|
||||
// Load current verification status on mount
|
||||
useEffect(() => {
|
||||
loadCurrentStatus();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load current verification status
|
||||
*/
|
||||
async function loadCurrentStatus() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${apiBaseUrl}/status`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setCurrentStatus(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load status:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request verification token from backend
|
||||
*/
|
||||
async function requestVerification() {
|
||||
if (!domain) {
|
||||
setError('Please enter a domain');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const response = await fetch(`${apiBaseUrl}/request-verification`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setVerificationInstructions(data.verification);
|
||||
} else {
|
||||
setError(data.error || 'Failed to request verification');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
console.error('Failed to request verification:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify domain ownership by checking DNS or blockchain
|
||||
*/
|
||||
async function verifyDomain() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const body = { domain };
|
||||
|
||||
// Add wallet address if verifying .aethex domain
|
||||
if (domain.endsWith('.aethex')) {
|
||||
body.walletAddress = walletAddress;
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/verify`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setVerificationStatus(data);
|
||||
|
||||
if (data.verified) {
|
||||
// Refresh status and reload after short delay
|
||||
setTimeout(() => {
|
||||
loadCurrentStatus();
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
setError(data.error || 'Verification failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error. Please try again.');
|
||||
console.error('Verification failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
// You could add a toast notification here
|
||||
alert('Copied to clipboard!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form
|
||||
*/
|
||||
function resetForm() {
|
||||
setVerificationInstructions(null);
|
||||
setVerificationStatus(null);
|
||||
setDomain('');
|
||||
setWalletAddress('');
|
||||
setError(null);
|
||||
}
|
||||
|
||||
// Show current verified domain if exists
|
||||
if (currentStatus?.hasVerifiedDomain) {
|
||||
return (
|
||||
<div className="domain-verification verified-container">
|
||||
<h3>✓ Domain Verified</h3>
|
||||
<div className="verified-domain-display">
|
||||
<strong>{currentStatus.domain}</strong>
|
||||
<span className="verification-type">
|
||||
{currentStatus.verificationType === 'blockchain' ? 'Blockchain' : 'DNS'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="verified-date">
|
||||
Verified on {new Date(currentStatus.verifiedAt).toLocaleDateString()}
|
||||
</p>
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setCurrentStatus(null)}
|
||||
>
|
||||
Verify Another Domain
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="domain-verification">
|
||||
<h3>Verify Your Domain</h3>
|
||||
<p className="description">
|
||||
Prove ownership of a domain to display it on your profile
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<span>⚠️ {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!verificationInstructions ? (
|
||||
<div className="input-section">
|
||||
<div className="form-group">
|
||||
<label htmlFor="domain">Domain Name</label>
|
||||
<input
|
||||
id="domain"
|
||||
type="text"
|
||||
placeholder="yourdomain.com or anderson.aethex"
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value.toLowerCase().trim())}
|
||||
disabled={loading}
|
||||
className="domain-input"
|
||||
/>
|
||||
<small className="help-text">
|
||||
Enter a traditional domain (e.g., dev.aethex.dev) or a .aethex blockchain domain
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={requestVerification}
|
||||
disabled={!domain || loading}
|
||||
className="primary-button"
|
||||
>
|
||||
{loading ? 'Generating...' : 'Request Verification'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="instructions-section">
|
||||
<h4>
|
||||
{domain.endsWith('.aethex')
|
||||
? 'Connect Your Wallet'
|
||||
: `Add this DNS record to ${verificationInstructions.domain}:`
|
||||
}
|
||||
</h4>
|
||||
|
||||
{domain.endsWith('.aethex') ? (
|
||||
// Blockchain verification
|
||||
<div className="blockchain-verification">
|
||||
<p>Connect the wallet that owns <strong>{domain}</strong></p>
|
||||
<div className="form-group">
|
||||
<label htmlFor="wallet">Wallet Address</label>
|
||||
<input
|
||||
id="wallet"
|
||||
type="text"
|
||||
placeholder="0x..."
|
||||
value={walletAddress}
|
||||
onChange={(e) => setWalletAddress(e.target.value.trim())}
|
||||
disabled={loading}
|
||||
className="wallet-input"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={verifyDomain}
|
||||
disabled={!walletAddress || loading}
|
||||
className="primary-button verify-button"
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify Ownership'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// DNS verification
|
||||
<div className="dns-verification">
|
||||
<div className="dns-record">
|
||||
<div className="record-field">
|
||||
<strong>Type:</strong>
|
||||
<span>{verificationInstructions.recordType}</span>
|
||||
</div>
|
||||
<div className="record-field">
|
||||
<strong>Name:</strong>
|
||||
<span>{verificationInstructions.recordName}</span>
|
||||
</div>
|
||||
<div className="record-field">
|
||||
<strong>Value:</strong>
|
||||
<div className="value-container">
|
||||
<code>{verificationInstructions.recordValue}</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(verificationInstructions.recordValue)}
|
||||
className="copy-button"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="help-section">
|
||||
<p><strong>How to add DNS records:</strong></p>
|
||||
<ol>
|
||||
<li>Go to your domain's DNS settings (Google Domains, Cloudflare, etc.)</li>
|
||||
<li>Add a new TXT record with the values above</li>
|
||||
<li>Wait 5-10 minutes for DNS to propagate</li>
|
||||
<li>Click "Verify Domain" below</li>
|
||||
</ol>
|
||||
<p className="expires-note">
|
||||
⏱️ This verification expires on {new Date(verificationInstructions.expiresAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={verifyDomain}
|
||||
disabled={loading}
|
||||
className="primary-button verify-button"
|
||||
>
|
||||
{loading ? 'Verifying...' : 'Verify Domain'}
|
||||
</button>
|
||||
|
||||
{verificationStatus && (
|
||||
<div className={`status-message ${verificationStatus.verified ? 'success' : 'error'}`}>
|
||||
{verificationStatus.verified ? (
|
||||
<span>✓ Domain verified successfully!</span>
|
||||
) : (
|
||||
<span>✗ {verificationStatus.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className="secondary-button cancel-button"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
.channel-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.channel-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.channel-group-header {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary, #999);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.channel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.channel-item:hover {
|
||||
background: var(--hover-bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.channel-item.active {
|
||||
background: var(--active-bg, #e3f2fd);
|
||||
border-left: 3px solid var(--primary-color, #2196F3);
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.channel-item.unread {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-icon {
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #333);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
background: var(--error-color, #f44336);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.channel-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.channel-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.channel-list::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb, #ccc);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.channel-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover, #999);
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from 'react';
|
||||
import './ChannelList.css';
|
||||
|
||||
export default function ChannelList({ channels, activeChannel, onSelectChannel }) {
|
||||
// Group channels by type
|
||||
const defaultChannels = channels.filter(c =>
|
||||
['general', 'announcements', 'dev', 'art', 'design', 'testing'].includes(c.name)
|
||||
);
|
||||
const customChannels = channels.filter(c =>
|
||||
!['general', 'announcements', 'dev', 'art', 'design', 'testing'].includes(c.name)
|
||||
);
|
||||
|
||||
const renderChannel = (channel) => {
|
||||
const isActive = activeChannel?.id === channel.id;
|
||||
const hasUnread = channel.unreadCount > 0;
|
||||
|
||||
// Channel icons
|
||||
const icons = {
|
||||
'general': '💬',
|
||||
'announcements': '📢',
|
||||
'dev': '💻',
|
||||
'art': '🎨',
|
||||
'design': '✏️',
|
||||
'testing': '🧪',
|
||||
'playtesting': '🎮'
|
||||
};
|
||||
|
||||
const icon = icons[channel.name] || '#';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`channel-item ${isActive ? 'active' : ''} ${hasUnread ? 'unread' : ''}`}
|
||||
onClick={() => onSelectChannel(channel)}
|
||||
>
|
||||
<span className="channel-icon">{icon}</span>
|
||||
<span className="channel-name">{channel.name}</span>
|
||||
{hasUnread && (
|
||||
<span className="unread-badge">{channel.unreadCount}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="channel-list">
|
||||
{defaultChannels.length > 0 && (
|
||||
<div className="channel-group">
|
||||
<div className="channel-group-header">Channels</div>
|
||||
{defaultChannels.map(renderChannel)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customChannels.length > 0 && (
|
||||
<div className="channel-group">
|
||||
<div className="channel-group-header">Custom Channels</div>
|
||||
{customChannels.map(renderChannel)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
.channel-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
}
|
||||
|
||||
.channel-view.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.channel-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-secondary, #fafafa);
|
||||
}
|
||||
|
||||
.channel-header h3 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.channel-description {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.restricted-badge {
|
||||
display: inline-block;
|
||||
margin-top: 8px;
|
||||
padding: 4px 8px;
|
||||
background: var(--warning-bg, #fff3cd);
|
||||
color: var(--warning-text, #856404);
|
||||
border: 1px solid var(--warning-border, #ffeeba);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Reuse MessageList and MessageInput from Chat component */
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../../contexts/SocketContext';
|
||||
import MessageList from '../Chat/MessageList';
|
||||
import MessageInput from '../Chat/MessageInput';
|
||||
import { encryptMessage, decryptMessage } from '../../utils/crypto';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import './ChannelView.css';
|
||||
|
||||
export default function ChannelView({ channel, projectId }) {
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuth();
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (channel) {
|
||||
loadMessages();
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
// Socket listeners
|
||||
useEffect(() => {
|
||||
if (!socket || !channel) return;
|
||||
|
||||
const handleNewMessage = async (data) => {
|
||||
if (data.conversationId === channel.id) {
|
||||
const message = data.message;
|
||||
|
||||
// Decrypt if not system message
|
||||
if (message.contentType !== 'system') {
|
||||
try {
|
||||
const decrypted = await decryptMessage(
|
||||
JSON.parse(message.content),
|
||||
user.password
|
||||
);
|
||||
message.content = decrypted;
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt message:', error);
|
||||
message.content = '[Failed to decrypt]';
|
||||
}
|
||||
}
|
||||
|
||||
setMessages(prev => [message, ...prev]);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('message:new', handleNewMessage);
|
||||
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMessage);
|
||||
};
|
||||
}, [socket, channel]);
|
||||
|
||||
const loadMessages = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`/api/conversations/${channel.id}/messages`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Decrypt messages
|
||||
const decryptedMessages = await Promise.all(
|
||||
data.messages.map(async (msg) => {
|
||||
// System messages are not encrypted
|
||||
if (msg.contentType === 'system') {
|
||||
return msg;
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptMessage(
|
||||
JSON.parse(msg.content),
|
||||
user.password
|
||||
);
|
||||
return {
|
||||
...msg,
|
||||
content: decrypted
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to decrypt message:', error);
|
||||
return {
|
||||
...msg,
|
||||
content: '[Failed to decrypt]'
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setMessages(decryptedMessages);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load messages:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (content) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
try {
|
||||
// Get recipient public keys (all channel participants)
|
||||
const participantsResponse = await fetch(`/api/conversations/${channel.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const participantsData = await participantsResponse.json();
|
||||
|
||||
if (!participantsData.success) {
|
||||
throw new Error('Failed to get participants');
|
||||
}
|
||||
|
||||
const recipientKeys = participantsData.conversation.participants
|
||||
.map(p => p.publicKey)
|
||||
.filter(Boolean);
|
||||
|
||||
// Encrypt message
|
||||
const encrypted = await encryptMessage(content, recipientKeys);
|
||||
|
||||
// Send via WebSocket
|
||||
socket.emit('message:send', {
|
||||
conversationId: channel.id,
|
||||
content: JSON.stringify(encrypted),
|
||||
contentType: 'text',
|
||||
clientId: `temp-${Date.now()}`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="channel-view loading">Loading messages...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="channel-view">
|
||||
<div className="channel-header">
|
||||
<h3>#{channel.name}</h3>
|
||||
<p className="channel-description">{channel.description}</p>
|
||||
{channel.permissions && !channel.permissions.includes('all') && (
|
||||
<span className="restricted-badge">
|
||||
Restricted to: {channel.permissions.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MessageList
|
||||
messages={messages}
|
||||
currentUserId={user.id}
|
||||
typingUsers={new Set()}
|
||||
showSystemMessages={true}
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
onSend={sendMessage}
|
||||
onTyping={() => {}}
|
||||
onStopTyping={() => {}}
|
||||
placeholder={`Message #${channel.name}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
.gameforge-chat {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
background: var(--bg-primary, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.gameforge-chat.embedded {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.gameforge-chat-sidebar {
|
||||
width: 260px;
|
||||
background: var(--bg-secondary, #ffffff);
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gameforge-chat-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary, #f5f5f5);
|
||||
}
|
||||
|
||||
.project-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-accent, #fafafa);
|
||||
}
|
||||
|
||||
.project-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.error {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error button {
|
||||
padding: 8px 16px;
|
||||
background: var(--primary-color, #4CAF50);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.error button:hover {
|
||||
background: var(--primary-hover, #45a049);
|
||||
}
|
||||
|
||||
.no-channel-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.gameforge-chat-sidebar {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.gameforge-chat {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.gameforge-chat-sidebar {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
}
|
||||
139
astro-site/src/react-app/components/GameForgeChat/index.jsx
Normal file
139
astro-site/src/react-app/components/GameForgeChat/index.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSocket } from '../../contexts/SocketContext';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ChannelList from './ChannelList';
|
||||
import ChannelView from './ChannelView';
|
||||
import './GameForgeChat.css';
|
||||
|
||||
/**
|
||||
* Embedded chat component for GameForge projects
|
||||
* Can be embedded in GameForge UI via iframe or direct integration
|
||||
*/
|
||||
export default function GameForgeChat({ projectId: propProjectId, embedded = false }) {
|
||||
const { projectId: paramProjectId } = useParams();
|
||||
const projectId = propProjectId || paramProjectId;
|
||||
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuth();
|
||||
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [activeChannel, setActiveChannel] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load channels
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadChannels();
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Socket listeners for system notifications
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
socket.on('gameforge:notification', handleNotification);
|
||||
|
||||
return () => {
|
||||
socket.off('gameforge:notification', handleNotification);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/gameforge/projects/${projectId}/channels`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setChannels(data.channels);
|
||||
|
||||
// Auto-select general channel
|
||||
const generalChannel = data.channels.find(c => c.name === 'general');
|
||||
if (generalChannel) {
|
||||
setActiveChannel(generalChannel);
|
||||
}
|
||||
} else {
|
||||
setError(data.error);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to load channels:', err);
|
||||
setError('Failed to load project channels');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotification = (notification) => {
|
||||
// Update channel with new system message
|
||||
const { channelId, message } = notification;
|
||||
|
||||
setChannels(prev => prev.map(channel => {
|
||||
if (channel.id === channelId) {
|
||||
return {
|
||||
...channel,
|
||||
lastMessage: message,
|
||||
unreadCount: activeChannel?.id === channelId ? 0 : channel.unreadCount + 1
|
||||
};
|
||||
}
|
||||
return channel;
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`gameforge-chat ${embedded ? 'embedded' : ''}`}>
|
||||
<div className="loading">Loading project chat...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`gameforge-chat ${embedded ? 'embedded' : ''}`}>
|
||||
<div className="error">
|
||||
<p>{error}</p>
|
||||
<button onClick={loadChannels}>Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`gameforge-chat ${embedded ? 'embedded' : ''}`}>
|
||||
<div className="gameforge-chat-sidebar">
|
||||
<div className="project-header">
|
||||
<h3>Project Channels</h3>
|
||||
</div>
|
||||
|
||||
<ChannelList
|
||||
channels={channels}
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={setActiveChannel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="gameforge-chat-main">
|
||||
{activeChannel ? (
|
||||
<ChannelView
|
||||
channel={activeChannel}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : (
|
||||
<div className="no-channel-selected">
|
||||
<p>Select a channel to start chatting</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
astro-site/src/react-app/components/Overlay/Overlay.css
Normal file
257
astro-site/src/react-app/components/Overlay/Overlay.css
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
.in-game-overlay {
|
||||
position: fixed;
|
||||
width: 320px;
|
||||
height: 480px;
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: rgba(30, 30, 40, 0.8);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.overlay-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.overlay-tabs button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.overlay-tabs button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overlay-tabs button.active {
|
||||
background: rgba(88, 101, 242, 0.3);
|
||||
color: #5865f2;
|
||||
}
|
||||
|
||||
.overlay-tabs .badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: #ed4245;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn-minimize {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #aaa;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 12px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-minimize:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.overlay-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.overlay-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overlay-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.friends-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.friend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.friend-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.friend-item img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.friend-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.friend-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.friend-game {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-indicator.online {
|
||||
background: #23a55a;
|
||||
box-shadow: 0 0 8px rgba(35, 165, 90, 0.6);
|
||||
}
|
||||
|
||||
.status-indicator.away {
|
||||
background: #f0b232;
|
||||
}
|
||||
|
||||
.status-indicator.offline {
|
||||
background: #80848e;
|
||||
}
|
||||
|
||||
.messages-preview {
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Minimized overlay */
|
||||
.overlay-minimized {
|
||||
position: fixed;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: rgba(88, 101, 242, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
z-index: 999999;
|
||||
}
|
||||
|
||||
.overlay-minimized:hover {
|
||||
transform: scale(1.1);
|
||||
background: rgba(88, 101, 242, 1);
|
||||
}
|
||||
|
||||
.minimized-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.minimized-badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
background: #ed4245;
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* In-game notification */
|
||||
.aethex-notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 320px;
|
||||
background: rgba(20, 20, 30, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
animation: slideIn 0.3s ease-out;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notif-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notif-body {
|
||||
font-size: 13px;
|
||||
color: #aaa;
|
||||
}
|
||||
270
astro-site/src/react-app/components/Overlay/index.jsx
Normal file
270
astro-site/src/react-app/components/Overlay/index.jsx
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import './Overlay.css';
|
||||
|
||||
/**
|
||||
* In-game overlay component
|
||||
* Runs in iframe embedded in games via Nexus Engine
|
||||
*/
|
||||
export default function InGameOverlay() {
|
||||
const [minimized, setMinimized] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('friends');
|
||||
const [friends, setFriends] = useState([]);
|
||||
const [unreadMessages, setUnreadMessages] = useState(0);
|
||||
const [inCall, setInCall] = useState(false);
|
||||
const [socket, setSocket] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
initializeOverlay();
|
||||
loadFriends();
|
||||
setupWebSocket();
|
||||
|
||||
// Listen for messages from game
|
||||
window.addEventListener('message', handleGameMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleGameMessage);
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const initializeOverlay = () => {
|
||||
// Get session ID from URL params
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sessionId = params.get('session');
|
||||
|
||||
if (sessionId) {
|
||||
localStorage.setItem('overlay_session', sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const setupWebSocket = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const ws = new WebSocket(`ws://localhost:5000?token=${token}`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[Overlay] WebSocket connected');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
handleSocketMessage(data);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[Overlay] WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[Overlay] WebSocket disconnected');
|
||||
// Reconnect after 3 seconds
|
||||
setTimeout(setupWebSocket, 3000);
|
||||
};
|
||||
|
||||
setSocket(ws);
|
||||
};
|
||||
|
||||
const loadFriends = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const response = await fetch('/api/friends', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setFriends(data.friends);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Overlay] Failed to load friends:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSocketMessage = (data) => {
|
||||
switch (data.type) {
|
||||
case 'presence:updated':
|
||||
handlePresenceUpdate(data);
|
||||
break;
|
||||
case 'message:new':
|
||||
handleNewMessage(data);
|
||||
break;
|
||||
case 'friend:request':
|
||||
showNotification({
|
||||
icon: '👋',
|
||||
title: 'Friend Request',
|
||||
body: `${data.username} wants to be friends`
|
||||
});
|
||||
break;
|
||||
case 'friend:accepted':
|
||||
showNotification({
|
||||
icon: '✅',
|
||||
title: 'Friend Request Accepted',
|
||||
body: `${data.username} accepted your friend request`
|
||||
});
|
||||
loadFriends(); // Refresh friends list
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresenceUpdate = (data) => {
|
||||
setFriends(prev => prev.map(friend =>
|
||||
friend.userId === data.userId
|
||||
? {
|
||||
...friend,
|
||||
status: data.status,
|
||||
lastSeen: data.lastSeenAt,
|
||||
currentGame: data.currentGame
|
||||
}
|
||||
: friend
|
||||
));
|
||||
};
|
||||
|
||||
const handleNewMessage = (data) => {
|
||||
setUnreadMessages(prev => prev + 1);
|
||||
|
||||
// Show notification
|
||||
showNotification({
|
||||
icon: '💬',
|
||||
title: 'New Message',
|
||||
body: `${data.senderDisplayName}: ${data.content.substring(0, 50)}${data.content.length > 50 ? '...' : ''}`
|
||||
});
|
||||
};
|
||||
|
||||
const showNotification = (notification) => {
|
||||
// Notify parent window (game)
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'notification',
|
||||
data: notification
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// Also show in overlay if not minimized
|
||||
if (!minimized) {
|
||||
// Could add in-overlay toast here
|
||||
}
|
||||
};
|
||||
|
||||
const handleGameMessage = (event) => {
|
||||
// Handle messages from game
|
||||
if (event.data.type === 'auto_mute') {
|
||||
if (event.data.mute && inCall) {
|
||||
console.log('[Overlay] Auto-muting for in-game match');
|
||||
// Auto-mute logic would trigger call service here
|
||||
} else {
|
||||
console.log('[Overlay] Auto-unmuting');
|
||||
// Auto-unmute logic
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMinimize = () => {
|
||||
setMinimized(!minimized);
|
||||
|
||||
// Notify parent
|
||||
if (window.parent !== window) {
|
||||
window.parent.postMessage({
|
||||
type: 'minimize',
|
||||
minimized: !minimized
|
||||
}, '*');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFriendClick = (friend) => {
|
||||
// Open quick actions menu for friend
|
||||
console.log('[Overlay] Friend clicked:', friend.username);
|
||||
// Could show: Send message, Join game, Voice call, etc.
|
||||
};
|
||||
|
||||
if (minimized) {
|
||||
return (
|
||||
<div className="overlay-minimized" onClick={toggleMinimize}>
|
||||
<div className="minimized-icon">💬</div>
|
||||
{unreadMessages > 0 && (
|
||||
<div className="minimized-badge">{unreadMessages}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="in-game-overlay">
|
||||
<div className="overlay-header">
|
||||
<div className="overlay-tabs">
|
||||
<button
|
||||
className={activeTab === 'friends' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('friends')}
|
||||
>
|
||||
Friends ({friends.filter(f => f.status === 'online').length})
|
||||
</button>
|
||||
<button
|
||||
className={activeTab === 'messages' ? 'active' : ''}
|
||||
onClick={() => setActiveTab('messages')}
|
||||
>
|
||||
Messages
|
||||
{unreadMessages > 0 && (
|
||||
<span className="badge">{unreadMessages}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button className="btn-minimize" onClick={toggleMinimize}>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overlay-content">
|
||||
{activeTab === 'friends' && (
|
||||
<div className="friends-list">
|
||||
{friends.length === 0 ? (
|
||||
<div className="messages-preview">
|
||||
<p>No friends yet</p>
|
||||
</div>
|
||||
) : (
|
||||
friends.map(friend => (
|
||||
<div
|
||||
key={friend.userId}
|
||||
className="friend-item"
|
||||
onClick={() => handleFriendClick(friend)}
|
||||
>
|
||||
<img
|
||||
src={friend.avatar || 'https://via.placeholder.com/40'}
|
||||
alt={friend.username}
|
||||
/>
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{friend.username}</div>
|
||||
{friend.currentGame && (
|
||||
<div className="friend-game">
|
||||
🎮 {friend.currentGame.gameName}
|
||||
</div>
|
||||
)}
|
||||
{!friend.currentGame && friend.status === 'online' && (
|
||||
<div className="friend-game">Online</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`status-indicator ${friend.status || 'offline'}`} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'messages' && (
|
||||
<div className="messages-preview">
|
||||
<p>Recent messages appear here</p>
|
||||
<p style={{ fontSize: '12px', marginTop: '8px', color: '#666' }}>
|
||||
Click a friend to start chatting
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
astro-site/src/react-app/components/Premium/UpgradeFlow.css
Normal file
217
astro-site/src/react-app/components/Premium/UpgradeFlow.css
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
.upgrade-flow {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.upgrade-flow h1 {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
margin-bottom: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tier-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.tier-card {
|
||||
background: rgba(30, 30, 40, 0.8);
|
||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tier-card:hover {
|
||||
border-color: rgba(88, 101, 242, 0.5);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.tier-card.selected {
|
||||
border-color: #5865f2;
|
||||
background: rgba(88, 101, 242, 0.1);
|
||||
}
|
||||
|
||||
.tier-card h3 {
|
||||
font-size: 24px;
|
||||
margin-bottom: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.tier-card .price {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #5865f2;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.tier-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tier-card li {
|
||||
padding: 8px 0;
|
||||
color: #aaa;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.domain-selection {
|
||||
background: rgba(30, 30, 40, 0.8);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.domain-selection h3 {
|
||||
margin-bottom: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.domain-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.domain-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.domain-input:focus {
|
||||
outline: none;
|
||||
border-color: #5865f2;
|
||||
}
|
||||
|
||||
.domain-suffix {
|
||||
color: #aaa;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.domain-input-group button {
|
||||
padding: 12px 24px;
|
||||
background: #5865f2;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.domain-input-group button:hover {
|
||||
background: #4752c4;
|
||||
}
|
||||
|
||||
.domain-input-group button:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.domain-status {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.domain-status.available {
|
||||
background: rgba(35, 165, 90, 0.1);
|
||||
border: 1px solid rgba(35, 165, 90, 0.3);
|
||||
color: #23a55a;
|
||||
}
|
||||
|
||||
.domain-status.unavailable {
|
||||
background: rgba(237, 66, 69, 0.1);
|
||||
border: 1px solid rgba(237, 66, 69, 0.3);
|
||||
color: #ed4245;
|
||||
}
|
||||
|
||||
.domain-status ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.domain-status li {
|
||||
padding: 8px 12px;
|
||||
margin: 4px 0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.domain-status li:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.checkout-form {
|
||||
background: rgba(30, 30, 40, 0.8);
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.card-element-wrapper {
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ed4245;
|
||||
padding: 12px;
|
||||
background: rgba(237, 66, 69, 0.1);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
background: #666;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tier-selection {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.upgrade-flow h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
307
astro-site/src/react-app/components/Premium/index.jsx
Normal file
307
astro-site/src/react-app/components/Premium/index.jsx
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
import React, { useState } from 'react';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
|
||||
import './UpgradeFlow.css';
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || 'pk_test_51QTaIiRu6l8tVuJxtest_placeholder');
|
||||
|
||||
/**
|
||||
* Checkout form component
|
||||
*/
|
||||
function CheckoutForm({ tier, domain, onSuccess }) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create payment method
|
||||
const { error: pmError, paymentMethod } = await stripe.createPaymentMethod({
|
||||
type: 'card',
|
||||
card: elements.getElement(CardElement)
|
||||
});
|
||||
|
||||
if (pmError) {
|
||||
throw new Error(pmError.message);
|
||||
}
|
||||
|
||||
// Subscribe or register domain
|
||||
const endpoint = domain
|
||||
? '/api/premium/domains/register'
|
||||
: '/api/premium/subscribe';
|
||||
|
||||
const body = domain ? {
|
||||
domain: domain,
|
||||
walletAddress: window.ethereum?.selectedAddress || '0x0000000000000000000000000000000000000000',
|
||||
paymentMethodId: paymentMethod.id
|
||||
} : {
|
||||
tier: tier,
|
||||
paymentMethodId: paymentMethod.id,
|
||||
billingPeriod: 'yearly'
|
||||
};
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
onSuccess(data);
|
||||
} else {
|
||||
throw new Error(data.error || 'Subscription failed');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAmount = () => {
|
||||
if (domain) return '$100/year';
|
||||
if (tier === 'premium') return '$100/year';
|
||||
if (tier === 'enterprise') return '$500/month';
|
||||
return '$0';
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="checkout-form">
|
||||
<div className="card-element-wrapper">
|
||||
<CardElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#fff',
|
||||
'::placeholder': {
|
||||
color: '#9ca3af'
|
||||
}
|
||||
},
|
||||
invalid: {
|
||||
color: '#ed4245'
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || loading}
|
||||
className="btn-submit"
|
||||
>
|
||||
{loading ? 'Processing...' : `Subscribe - ${getAmount()}`}
|
||||
</button>
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: '16px', fontSize: '12px', color: '#aaa' }}>
|
||||
By subscribing, you agree to our Terms of Service and Privacy Policy
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main upgrade flow component
|
||||
*/
|
||||
export default function UpgradeFlow({ currentTier = 'free' }) {
|
||||
const [selectedTier, setSelectedTier] = useState('premium');
|
||||
const [domainName, setDomainName] = useState('');
|
||||
const [domainAvailable, setDomainAvailable] = useState(null);
|
||||
const [checkingDomain, setCheckingDomain] = useState(false);
|
||||
|
||||
const checkDomain = async () => {
|
||||
if (!domainName) {
|
||||
setError('Please enter a domain name');
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingDomain(true);
|
||||
setDomainAvailable(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/premium/domains/check-availability', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain: `${domainName}.aethex`
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setDomainAvailable(data);
|
||||
} else {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to check domain:', error);
|
||||
setDomainAvailable({
|
||||
available: false,
|
||||
domain: `${domainName}.aethex`,
|
||||
error: error.message
|
||||
});
|
||||
} finally {
|
||||
setCheckingDomain(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = (data) => {
|
||||
alert('Subscription successful! Welcome to premium!');
|
||||
// Redirect to dashboard or show success modal
|
||||
window.location.href = '/dashboard';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="upgrade-flow">
|
||||
<h1>Upgrade to Premium</h1>
|
||||
|
||||
<div className="tier-selection">
|
||||
<div
|
||||
className={`tier-card ${selectedTier === 'premium' ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTier('premium')}
|
||||
>
|
||||
<h3>Premium</h3>
|
||||
<div className="price">$100/year</div>
|
||||
<ul>
|
||||
<li>✓ Custom .aethex domain</li>
|
||||
<li>✓ Blockchain NFT ownership</li>
|
||||
<li>✓ Unlimited friends</li>
|
||||
<li>✓ HD voice/video calls (1080p)</li>
|
||||
<li>✓ 10 GB storage</li>
|
||||
<li>✓ Custom branding</li>
|
||||
<li>✓ Analytics dashboard</li>
|
||||
<li>✓ Priority support</li>
|
||||
<li>✓ Ad-free experience</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`tier-card ${selectedTier === 'enterprise' ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTier('enterprise')}
|
||||
>
|
||||
<h3>Enterprise</h3>
|
||||
<div className="price">$500+/month</div>
|
||||
<ul>
|
||||
<li>✓ Everything in Premium</li>
|
||||
<li>✓ White-label platform</li>
|
||||
<li>✓ Custom domain (chat.yoursite.com)</li>
|
||||
<li>✓ Unlimited team members</li>
|
||||
<li>✓ Dedicated infrastructure</li>
|
||||
<li>✓ 4K video quality</li>
|
||||
<li>✓ SLA guarantees (99.9% uptime)</li>
|
||||
<li>✓ Dedicated account manager</li>
|
||||
<li>✓ Custom integrations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedTier === 'premium' && (
|
||||
<div className="domain-selection">
|
||||
<h3>Choose Your .aethex Domain</h3>
|
||||
<p style={{ color: '#aaa', marginBottom: '16px' }}>
|
||||
Your premium blockchain domain with NFT ownership proof
|
||||
</p>
|
||||
|
||||
<div className="domain-input-group">
|
||||
<input
|
||||
type="text"
|
||||
value={domainName}
|
||||
onChange={(e) => setDomainName(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="yourname"
|
||||
className="domain-input"
|
||||
maxLength={50}
|
||||
/>
|
||||
<span className="domain-suffix">.aethex</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkDomain}
|
||||
disabled={checkingDomain || !domainName}
|
||||
>
|
||||
{checkingDomain ? 'Checking...' : 'Check'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{domainAvailable && (
|
||||
<div className={`domain-status ${domainAvailable.available ? 'available' : 'unavailable'}`}>
|
||||
{domainAvailable.available ? (
|
||||
<>
|
||||
<p><strong>✓ {domainAvailable.domain} is available!</strong></p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px', opacity: 0.8 }}>
|
||||
Price: ${domainAvailable.price}/year
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<p><strong>✗ {domainAvailable.domain} is taken</strong></p>
|
||||
{domainAvailable.error && (
|
||||
<p style={{ fontSize: '14px', marginTop: '4px' }}>{domainAvailable.error}</p>
|
||||
)}
|
||||
{domainAvailable.suggestedAlternatives && domainAvailable.suggestedAlternatives.length > 0 && (
|
||||
<>
|
||||
<p style={{ marginTop: '12px' }}>Try these alternatives:</p>
|
||||
<ul>
|
||||
{domainAvailable.suggestedAlternatives.map(alt => (
|
||||
<li
|
||||
key={alt}
|
||||
onClick={() => setDomainName(alt.replace('.aethex', ''))}
|
||||
>
|
||||
{alt}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(selectedTier === 'enterprise' || (selectedTier === 'premium' && domainAvailable?.available)) && (
|
||||
<Elements stripe={stripePromise}>
|
||||
<CheckoutForm
|
||||
tier={selectedTier}
|
||||
domain={domainAvailable?.available ? `${domainName}.aethex` : null}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
{selectedTier === 'enterprise' && !domainAvailable && (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#aaa' }}>
|
||||
<p>For Enterprise plans, please contact our sales team:</p>
|
||||
<p style={{ marginTop: '16px' }}>
|
||||
<a href="mailto:enterprise@aethex.app" style={{ color: '#5865f2' }}>
|
||||
enterprise@aethex.app
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
astro-site/src/react-app/components/VerifiedDomainBadge.css
Normal file
85
astro-site/src/react-app/components/VerifiedDomainBadge.css
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
.verified-domain-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
||||
border: 2px solid #6ee7b7;
|
||||
border-radius: 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #065f46;
|
||||
box-shadow: 0 2px 4px rgba(16, 185, 129, 0.2);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.verified-domain-badge:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.badge-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.domain-text {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.blockchain-indicator {
|
||||
font-size: 16px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.verified-info {
|
||||
font-size: 11px;
|
||||
color: #047857;
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Compact variant */
|
||||
.verified-domain-badge.compact {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.verified-domain-badge.compact .checkmark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.verified-domain-badge {
|
||||
background: linear-gradient(135deg, #064e3b 0%, #065f46 100%);
|
||||
border-color: #047857;
|
||||
color: #d1fae5;
|
||||
}
|
||||
|
||||
.domain-text {
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
.verified-info {
|
||||
color: #a7f3d0;
|
||||
}
|
||||
}
|
||||
41
astro-site/src/react-app/components/VerifiedDomainBadge.jsx
Normal file
41
astro-site/src/react-app/components/VerifiedDomainBadge.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import './VerifiedDomainBadge.css';
|
||||
|
||||
/**
|
||||
* Displays verified domain badge on user profile
|
||||
* @param {Object} props
|
||||
* @param {string} props.verifiedDomain - The verified domain name
|
||||
* @param {string} props.verificationType - Type of verification (dns or blockchain)
|
||||
* @param {Date} props.verifiedAt - When the domain was verified
|
||||
*/
|
||||
export default function VerifiedDomainBadge({
|
||||
verifiedDomain,
|
||||
verificationType = 'dns',
|
||||
verifiedAt
|
||||
}) {
|
||||
if (!verifiedDomain) return null;
|
||||
|
||||
return (
|
||||
<div className="verified-domain-badge">
|
||||
<div className="badge-content">
|
||||
<span className="domain-text">{verifiedDomain}</span>
|
||||
<span
|
||||
className="checkmark"
|
||||
title={`Verified via ${verificationType === 'blockchain' ? 'blockchain' : 'DNS'}`}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</div>
|
||||
{verificationType === 'blockchain' && (
|
||||
<span className="blockchain-indicator" title="Verified via blockchain">
|
||||
⛓️
|
||||
</span>
|
||||
)}
|
||||
{verifiedAt && (
|
||||
<div className="verified-info">
|
||||
Verified {new Date(verifiedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
astro-site/src/react-app/contexts/AuthContext.jsx
Normal file
51
astro-site/src/react-app/contexts/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const getSession = async () => {
|
||||
setLoading(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
setUser(session.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
getSession();
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user || null);
|
||||
});
|
||||
return () => {
|
||||
listener?.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = { user, loading, supabase };
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
74
astro-site/src/react-app/contexts/SocketContext.jsx
Normal file
74
astro-site/src/react-app/contexts/SocketContext.jsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Socket Context
|
||||
* Provides Socket.io connection to all components
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
export function SocketProvider({ children }) {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
console.log('No auth token, skipping socket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to Socket.io server
|
||||
const socketInstance = io(import.meta.env.VITE_API_URL || 'http://localhost:3000', {
|
||||
auth: {
|
||||
token: token
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 5
|
||||
});
|
||||
|
||||
socketInstance.on('connect', () => {
|
||||
console.log('✓ Connected to Socket.io server');
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
socketInstance.on('disconnect', () => {
|
||||
console.log('✗ Disconnected from Socket.io server');
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
socketInstance.on('connect_error', (error) => {
|
||||
console.error('Socket connection error:', error.message);
|
||||
});
|
||||
|
||||
socketInstance.on('error', (error) => {
|
||||
console.error('Socket error:', error);
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (socketInstance) {
|
||||
socketInstance.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{ socket, connected }}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSocket() {
|
||||
const context = useContext(SocketContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSocket must be used within SocketProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
43
astro-site/src/react-app/index.css
Normal file
43
astro-site/src/react-app/index.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #000000;
|
||||
color: #e4e4e7;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #18181b;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #e4e4e7;
|
||||
}
|
||||
13
astro-site/src/react-app/index.html
Normal file
13
astro-site/src/react-app/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>AeThex Passport - Domain Verification</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
11
astro-site/src/react-app/main.jsx
Normal file
11
astro-site/src/react-app/main.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import AppWrapper from './App';
|
||||
import './index.css';
|
||||
import './Demo.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
);
|
||||
65
astro-site/src/react-app/mockup/ChannelSidebar.jsx
Normal file
65
astro-site/src/react-app/mockup/ChannelSidebar.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from "react";
|
||||
|
||||
export default function ChannelSidebar() {
|
||||
return (
|
||||
<div className="channel-sidebar w-72 bg-[#0f0f0f] border-r border-[#1a1a1a] flex flex-col">
|
||||
{/* Server Header */}
|
||||
<div className="server-header p-4 border-b border-[#1a1a1a] font-bold text-base flex items-center justify-between">
|
||||
<span>AeThex Foundation</span>
|
||||
<span className="server-badge foundation text-xs px-2 py-1 rounded bg-red-900/20 text-red-500 border border-red-500 uppercase tracking-wider">Official</span>
|
||||
</div>
|
||||
{/* Channel List */}
|
||||
<div className="channel-list flex-1 overflow-y-auto py-2">
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Announcements</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">📢</span>
|
||||
<span className="channel-name flex-1">updates</span>
|
||||
<span className="channel-badge text-xs bg-red-600 text-white px-2 rounded-full">3</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">📜</span>
|
||||
<span className="channel-name flex-1">changelog</span>
|
||||
</div>
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Development</div>
|
||||
<div className="channel-item active flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm bg-[#1a1a1a]">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name flex-1">general</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name flex-1">api-discussion</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name flex-1">passport-development</span>
|
||||
</div>
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Support</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">❓</span>
|
||||
<span className="channel-name flex-1">help</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">🐛</span>
|
||||
<span className="channel-name flex-1">bug-reports</span>
|
||||
</div>
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Voice Channels</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">🔊</span>
|
||||
<span className="channel-name flex-1">Nexus Lounge</span>
|
||||
<span className="text-gray-500 text-xs">3</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* User Presence */}
|
||||
<div className="user-presence p-3 border-t border-[#1a1a1a] flex items-center gap-3 text-sm">
|
||||
<div className="user-avatar w-10 h-10 rounded-full flex items-center justify-center font-bold bg-gradient-to-tr from-red-600 via-blue-600 to-orange-400">A</div>
|
||||
<div className="user-info flex-1">
|
||||
<div className="user-name font-bold mb-0.5">Anderson</div>
|
||||
<div className="user-status flex items-center gap-1 text-xs text-gray-500">
|
||||
<span className="status-dot w-2 h-2 rounded-full bg-green-400 shadow-green-400/50 shadow" />
|
||||
<span>Building AeThex</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
astro-site/src/react-app/mockup/ChatArea.jsx
Normal file
41
astro-site/src/react-app/mockup/ChatArea.jsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from "react";
|
||||
import Message from "./Message";
|
||||
import MessageInput from "./MessageInput";
|
||||
|
||||
const messages = [
|
||||
{ type: "system", label: "FOUNDATION", text: "Foundation authentication services upgraded to v2.1.0. Enhanced security protocols now active across all AeThex infrastructure.", className: "foundation" },
|
||||
{ type: "user", author: "Trevor", badge: "Foundation", time: "10:34 AM", text: "Just pushed the authentication updates. All services should automatically migrate to the new protocols within 24 hours.", avatar: "T", avatarBg: "from-red-600 to-red-800" },
|
||||
{ type: "user", author: "Marcus", time: "10:41 AM", text: "Excellent work! I've been testing the new Passport integration and it's incredibly smooth. The Trinity color-coding in the UI makes it really clear which division is handling what.", avatar: "M", avatarBg: "from-blue-600 to-blue-900" },
|
||||
{ type: "system", label: "LABS", text: "Nexus Engine v2.0-beta now available for testing. New cross-platform sync reduces latency by 40%. Join #labs-testing to participate.", className: "labs" },
|
||||
{ type: "user", author: "Sarah", badge: "Labs", time: "11:15 AM", text: "The Nexus v2 parallel compilation is insane. Cut my build time from 3 minutes to under 2. Still some edge cases with complex state synchronization but wow... ⚠️", avatar: "S", avatarBg: "from-orange-400 to-orange-700" },
|
||||
{ type: "user", author: "Anderson", badge: "Founder", time: "11:47 AM", text: "Love seeing the Trinity infrastructure working in harmony. Foundation keeping everything secure, Labs pushing the boundaries, Corporation delivering production-ready tools. This is exactly the vision.", avatar: "A", avatarBg: "from-red-600 via-blue-600 to-orange-400" },
|
||||
{ type: "user", author: "DevUser_2847", time: "12:03 PM", text: "Quick question - when using AeThex Studio, does the Terminal automatically connect to all three Trinity divisions, or do I need to configure that?", avatar: "D", avatarBg: "bg-[#1a1a1a]" },
|
||||
{ type: "system", label: "CORPORATION", text: "AeThex Studio Pro users: New Railway deployment templates available. Optimized configurations for Foundation APIs, Corporation services, and Labs experiments.", className: "corporation" },
|
||||
];
|
||||
|
||||
export default function ChatArea() {
|
||||
return (
|
||||
<div className="chat-area flex flex-col flex-1 bg-[#0a0a0a]">
|
||||
{/* Chat Header */}
|
||||
<div className="chat-header px-5 py-4 border-b border-[#1a1a1a] flex items-center gap-3">
|
||||
<span className="channel-name-header flex-1 font-bold text-base"># general</span>
|
||||
<div className="chat-tools flex gap-4 text-sm text-gray-500">
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">🔔</span>
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">📌</span>
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">👥 128</span>
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="chat-messages flex-1 overflow-y-auto px-5 py-5">
|
||||
{messages.map((msg, i) => (
|
||||
<Message key={i} {...msg} />
|
||||
))}
|
||||
</div>
|
||||
{/* Message Input */}
|
||||
<div className="message-input-container px-5 py-5 border-t border-[#1a1a1a]">
|
||||
<MessageInput />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
astro-site/src/react-app/mockup/MainLayout.jsx
Normal file
17
astro-site/src/react-app/mockup/MainLayout.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React from "react";
|
||||
import ServerList from "./ServerList";
|
||||
import ChannelSidebar from "./ChannelSidebar";
|
||||
import ChatArea from "./ChatArea";
|
||||
import MemberSidebar from "./MemberSidebar";
|
||||
import "./global.css";
|
||||
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<div className="connect-container flex h-screen">
|
||||
<ServerList />
|
||||
<ChannelSidebar />
|
||||
<ChatArea />
|
||||
<MemberSidebar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
astro-site/src/react-app/mockup/MemberSidebar.jsx
Normal file
43
astro-site/src/react-app/mockup/MemberSidebar.jsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react";
|
||||
|
||||
const members = [
|
||||
{ section: "Foundation Team — 8", users: [
|
||||
{ name: "Anderson", avatar: "A", status: "online", avatarBg: "from-red-600 to-red-800" },
|
||||
{ name: "Trevor", avatar: "T", status: "online", avatarBg: "from-red-600 to-red-800" },
|
||||
]},
|
||||
{ section: "Labs Team — 12", users: [
|
||||
{ name: "Sarah", avatar: "S", status: "labs", avatarBg: "from-orange-400 to-orange-700", activity: "Testing v2.0" },
|
||||
]},
|
||||
{ section: "Developers — 47", users: [
|
||||
{ name: "Marcus", avatar: "M", status: "in-game", avatarBg: "bg-[#1a1a1a]", activity: "Building" },
|
||||
{ name: "DevUser_2847", avatar: "D", status: "online", avatarBg: "bg-[#1a1a1a]" },
|
||||
]},
|
||||
{ section: "Community — 61", users: [
|
||||
{ name: "JohnDev", avatar: "J", status: "offline", avatarBg: "bg-[#1a1a1a]" },
|
||||
]},
|
||||
];
|
||||
|
||||
export default function MemberSidebar() {
|
||||
return (
|
||||
<div className="member-sidebar w-72 bg-[#0f0f0f] border-l border-[#1a1a1a] flex flex-col">
|
||||
<div className="member-header p-4 border-b border-[#1a1a1a] text-xs uppercase tracking-widest text-gray-500">Members — 128</div>
|
||||
<div className="member-list flex-1 overflow-y-auto py-3">
|
||||
{members.map((section, i) => (
|
||||
<div key={i} className="member-section mb-4">
|
||||
<div className="member-section-title px-4 py-2 text-xs uppercase tracking-wider text-gray-500 font-bold">{section.section}</div>
|
||||
{section.users.map((user, j) => (
|
||||
<div key={j} className="member-item flex items-center gap-3 px-4 py-1.5 cursor-pointer hover:bg-[#1a1a1a]">
|
||||
<div className={`member-avatar-small w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm relative bg-gradient-to-tr ${user.avatarBg}`}>
|
||||
{user.avatar}
|
||||
<div className={`online-indicator absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-[#0f0f0f] ${user.status === "online" ? "bg-green-400" : user.status === "in-game" ? "bg-blue-500" : user.status === "labs" ? "bg-orange-400" : "bg-gray-700"}`}></div>
|
||||
</div>
|
||||
<div className="member-name flex-1 text-sm">{user.name}</div>
|
||||
{user.activity && <div className="member-activity text-xs text-gray-500">{user.activity}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
astro-site/src/react-app/mockup/Message.jsx
Normal file
27
astro-site/src/react-app/mockup/Message.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from "react";
|
||||
|
||||
export default function Message(props) {
|
||||
if (props.type === "system") {
|
||||
return (
|
||||
<div className={`message-system ${props.className} bg-[#0f0f0f] border-l-4 pl-4 pr-4 py-3 mb-4 text-sm`}>
|
||||
<div className={`system-label ${props.className} text-xs uppercase tracking-wider font-bold mb-1`}>[{props.label}] System Announcement</div>
|
||||
<div>{props.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="message flex gap-4 mb-5 p-3 rounded transition hover:bg-[#0f0f0f]">
|
||||
<div className={`message-avatar w-10 h-10 rounded-full flex items-center justify-center font-bold text-base flex-shrink-0 bg-gradient-to-tr ${props.avatarBg}`}>{props.avatar}</div>
|
||||
<div className="message-content flex-1">
|
||||
<div className="message-header flex items-baseline gap-3 mb-1">
|
||||
<span className="message-author font-bold">{props.author}</span>
|
||||
{props.badge && (
|
||||
<span className={`message-badge ${props.className} text-xs px-2 py-1 rounded uppercase tracking-wider font-bold`}>{props.badge}</span>
|
||||
)}
|
||||
<span className="message-time text-xs text-gray-500">{props.time}</span>
|
||||
</div>
|
||||
<div className="message-text leading-relaxed text-gray-300">{props.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
astro-site/src/react-app/mockup/MessageInput.jsx
Normal file
16
astro-site/src/react-app/mockup/MessageInput.jsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import React from "react";
|
||||
|
||||
export default function MessageInput() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="attachButton w-10 h-10 flex items-center justify-center rounded bg-[#1a1a1a] text-xl text-gray-400 mr-2">+</button>
|
||||
<input
|
||||
type="text"
|
||||
className="message-input flex-1 bg-[#0f0f0f] border border-[#1a1a1a] rounded-lg px-4 py-3 text-gray-200 text-sm focus:outline-none focus:border-blue-500 placeholder:text-gray-500"
|
||||
placeholder="Message #general (Foundation infrastructure channel)"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<button className="sendButton w-10 h-10 flex items-center justify-center rounded bg-blue-600 text-xl text-white ml-2">🎤</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
astro-site/src/react-app/mockup/ServerList.jsx
Normal file
30
astro-site/src/react-app/mockup/ServerList.jsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import React from "react";
|
||||
|
||||
const servers = [
|
||||
{ id: "foundation", label: "F", active: true, className: "foundation" },
|
||||
{ id: "corporation", label: "C", active: false, className: "corporation" },
|
||||
{ id: "labs", label: "L", active: false, className: "labs" },
|
||||
{ id: "divider" },
|
||||
{ id: "community1", label: "AG", active: false, className: "community" },
|
||||
{ id: "community2", label: "RD", active: false, className: "community" },
|
||||
{ id: "add", label: "+", active: false, className: "community" },
|
||||
];
|
||||
|
||||
export default function ServerList() {
|
||||
return (
|
||||
<div className="server-list flex flex-col items-center py-3 gap-3 w-20 bg-[#0d0d0d] border-r border-[#1a1a1a]">
|
||||
{servers.map((srv, i) =>
|
||||
srv.id === "divider" ? (
|
||||
<div key={i} className="server-divider w-10 h-0.5 bg-[#1a1a1a] my-1" />
|
||||
) : (
|
||||
<div
|
||||
key={srv.id}
|
||||
className={`server-icon ${srv.className} ${srv.active ? "active" : ""} w-14 h-14 rounded-xl flex items-center justify-center font-bold text-lg cursor-pointer transition-all relative`}
|
||||
>
|
||||
{srv.label}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
astro-site/src/react-app/mockup/global.css
Normal file
57
astro-site/src/react-app/mockup/global.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&display=swap');
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: repeating-linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.15),
|
||||
rgba(0, 0, 0, 0.15) 1px,
|
||||
transparent 1px,
|
||||
transparent 2px
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.connect-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.server-icon, .user-avatar, .member-avatar-small {
|
||||
background: rgba(26,26,26,0.85);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.channel-item.active, .channel-item:hover, .member-item:hover {
|
||||
background: rgba(26,26,26,0.85);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.message-input, .message-input-container {
|
||||
background: rgba(15,15,15,0.95);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: #111;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #222;
|
||||
border-radius: 4px;
|
||||
}
|
||||
13
astro-site/src/react-app/mockup/index.html
Normal file
13
astro-site/src/react-app/mockup/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AeThex Connect Mockup Web</title>
|
||||
<link rel="stylesheet" href="./global.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
astro-site/src/react-app/mockup/index.jsx
Normal file
13
astro-site/src/react-app/mockup/index.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import MainLayout from "./MainLayout";
|
||||
import "./global.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<MainLayout />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
2175
astro-site/src/react-app/package-lock.json
generated
Normal file
2175
astro-site/src/react-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
astro-site/src/react-app/package.json
Normal file
26
astro-site/src/react-app/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "aethex-passport-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.1",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
316
astro-site/src/react-app/utils/crypto.js
vendored
Normal file
316
astro-site/src/react-app/utils/crypto.js
vendored
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* End-to-End Encryption Utilities
|
||||
* Client-side encryption using Web Crypto API
|
||||
* - RSA-OAEP for key exchange
|
||||
* - AES-256-GCM for message content
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate RSA key pair for user
|
||||
* @returns {Promise<{publicKey: string, privateKey: string}>}
|
||||
*/
|
||||
export async function generateKeyPair() {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true, // extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export keys
|
||||
const publicKey = await window.crypto.subtle.exportKey('spki', keyPair.publicKey);
|
||||
const privateKey = await window.crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
|
||||
|
||||
return {
|
||||
publicKey: arrayBufferToBase64(publicKey),
|
||||
privateKey: arrayBufferToBase64(privateKey)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Store private key encrypted with user's password
|
||||
* @param {string} privateKey - Base64 encoded private key
|
||||
* @param {string} password - User's password
|
||||
*/
|
||||
export async function storePrivateKey(privateKey, password) {
|
||||
// Derive encryption key from password
|
||||
const passwordKey = await deriveKeyFromPassword(password);
|
||||
|
||||
// Encrypt private key
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
passwordKey,
|
||||
base64ToArrayBuffer(privateKey)
|
||||
);
|
||||
|
||||
// Store in localStorage
|
||||
localStorage.setItem('encryptedPrivateKey', arrayBufferToBase64(encrypted));
|
||||
localStorage.setItem('privateKeyIV', arrayBufferToBase64(iv));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and decrypt private key
|
||||
* @param {string} password - User's password
|
||||
* @returns {Promise<CryptoKey>} Decrypted private key
|
||||
*/
|
||||
export async function getPrivateKey(password) {
|
||||
const encryptedKey = localStorage.getItem('encryptedPrivateKey');
|
||||
const iv = localStorage.getItem('privateKeyIV');
|
||||
|
||||
if (!encryptedKey || !iv) {
|
||||
throw new Error('No stored private key found');
|
||||
}
|
||||
|
||||
// Derive decryption key from password
|
||||
const passwordKey = await deriveKeyFromPassword(password);
|
||||
|
||||
// Decrypt private key
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: base64ToArrayBuffer(iv)
|
||||
},
|
||||
passwordKey,
|
||||
base64ToArrayBuffer(encryptedKey)
|
||||
);
|
||||
|
||||
// Import as CryptoKey
|
||||
return await window.crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
decrypted,
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true,
|
||||
['decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive encryption key from password using PBKDF2
|
||||
* @param {string} password - User's password
|
||||
* @returns {Promise<CryptoKey>} Derived key
|
||||
*/
|
||||
async function deriveKeyFromPassword(password) {
|
||||
const encoder = new TextEncoder();
|
||||
const passwordBuffer = encoder.encode(password);
|
||||
|
||||
// Import password as key material
|
||||
const passwordKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
passwordBuffer,
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
// Get or generate salt
|
||||
let salt = localStorage.getItem('keySalt');
|
||||
if (!salt) {
|
||||
const saltBuffer = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
salt = arrayBufferToBase64(saltBuffer);
|
||||
localStorage.setItem('keySalt', salt);
|
||||
}
|
||||
|
||||
// Derive AES key
|
||||
return await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: base64ToArrayBuffer(salt),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
passwordKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt message content
|
||||
* @param {string} message - Plain text message
|
||||
* @param {string[]} recipientPublicKeys - Array of recipient public keys (base64)
|
||||
* @returns {Promise<Object>} Encrypted message bundle
|
||||
*/
|
||||
export async function encryptMessage(message, recipientPublicKeys) {
|
||||
// Generate random AES key for this message
|
||||
const messageKey = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Encrypt message with AES key
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoder = new TextEncoder();
|
||||
const messageBuffer = encoder.encode(message);
|
||||
|
||||
const encryptedMessage = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
},
|
||||
messageKey,
|
||||
messageBuffer
|
||||
);
|
||||
|
||||
// Export AES key
|
||||
const exportedMessageKey = await window.crypto.subtle.exportKey('raw', messageKey);
|
||||
|
||||
// Encrypt AES key for each recipient with their RSA public key
|
||||
const encryptedKeys = {};
|
||||
|
||||
for (const recipientKeyB64 of recipientPublicKeys) {
|
||||
try {
|
||||
// Import recipient's public key
|
||||
const recipientKey = await window.crypto.subtle.importKey(
|
||||
'spki',
|
||||
base64ToArrayBuffer(recipientKeyB64),
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
true,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Encrypt message key with recipient's public key
|
||||
const encryptedKey = await window.crypto.subtle.encrypt(
|
||||
{
|
||||
name: 'RSA-OAEP'
|
||||
},
|
||||
recipientKey,
|
||||
exportedMessageKey
|
||||
);
|
||||
|
||||
encryptedKeys[recipientKeyB64] = arrayBufferToBase64(encryptedKey);
|
||||
} catch (error) {
|
||||
console.error('Failed to encrypt for recipient:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ciphertext: arrayBufferToBase64(encryptedMessage),
|
||||
iv: arrayBufferToBase64(iv),
|
||||
encryptedKeys: encryptedKeys // Map of publicKey -> encrypted AES key
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt message content
|
||||
* @param {Object} encryptedBundle - Encrypted message bundle
|
||||
* @param {string} userPassword - User's password (to decrypt private key)
|
||||
* @param {string} userPublicKey - User's public key (to find correct encrypted key)
|
||||
* @returns {Promise<string>} Decrypted message
|
||||
*/
|
||||
export async function decryptMessage(encryptedBundle, userPassword, userPublicKey) {
|
||||
// Get user's private key
|
||||
const privateKey = await getPrivateKey(userPassword);
|
||||
|
||||
// Find the encrypted key for this user
|
||||
const encryptedKeyB64 = encryptedBundle.encryptedKeys[userPublicKey];
|
||||
|
||||
if (!encryptedKeyB64) {
|
||||
throw new Error('No encrypted key found for this user');
|
||||
}
|
||||
|
||||
// Decrypt the AES key with user's private key
|
||||
const decryptedKeyBuffer = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'RSA-OAEP'
|
||||
},
|
||||
privateKey,
|
||||
base64ToArrayBuffer(encryptedKeyB64)
|
||||
);
|
||||
|
||||
// Import AES key
|
||||
const messageKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
decryptedKeyBuffer,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 256
|
||||
},
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
|
||||
// Decrypt message
|
||||
const decryptedBuffer = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: base64ToArrayBuffer(encryptedBundle.iv)
|
||||
},
|
||||
messageKey,
|
||||
base64ToArrayBuffer(encryptedBundle.ciphertext)
|
||||
);
|
||||
|
||||
// Convert to text
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decryptedBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to Base64 string
|
||||
* @param {ArrayBuffer} buffer
|
||||
* @returns {string}
|
||||
*/
|
||||
function arrayBufferToBase64(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Base64 string to ArrayBuffer
|
||||
* @param {string} base64
|
||||
* @returns {ArrayBuffer}
|
||||
*/
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = window.atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption keys exist
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasEncryptionKeys() {
|
||||
return !!(
|
||||
localStorage.getItem('encryptedPrivateKey') &&
|
||||
localStorage.getItem('privateKeyIV') &&
|
||||
localStorage.getItem('keySalt')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored encryption keys
|
||||
*/
|
||||
export function clearEncryptionKeys() {
|
||||
localStorage.removeItem('encryptedPrivateKey');
|
||||
localStorage.removeItem('privateKeyIV');
|
||||
localStorage.removeItem('keySalt');
|
||||
}
|
||||
39
astro-site/src/react-app/utils/socket.js
vendored
Normal file
39
astro-site/src/react-app/utils/socket.js
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// socket.js
|
||||
// Example Socket.io client for AeThex Connect (Supabase JWT auth)
|
||||
|
||||
import { io } from 'socket.io-client';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
// Initialize Supabase client
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
export async function connectSocket() {
|
||||
// Get current session (JWT)
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Not authenticated');
|
||||
|
||||
// Connect to signaling server
|
||||
const socket = io(import.meta.env.PUBLIC_SIGNALING_SERVER_URL, {
|
||||
auth: { token: session.access_token }
|
||||
});
|
||||
|
||||
// Example: handle connection
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to signaling server');
|
||||
});
|
||||
|
||||
// Example: join a voice room
|
||||
function joinVoice(roomId) {
|
||||
socket.emit('voice:join', roomId);
|
||||
}
|
||||
|
||||
// Example: send/receive offers, answers, ICE
|
||||
socket.on('voice:offer', (data) => { /* handle offer */ });
|
||||
socket.on('voice:answer', (data) => { /* handle answer */ });
|
||||
socket.on('voice:ice-candidate', (data) => { /* handle candidate */ });
|
||||
|
||||
return { socket, joinVoice };
|
||||
}
|
||||
532
astro-site/src/react-app/utils/webrtc.js
vendored
Normal file
532
astro-site/src/react-app/utils/webrtc.js
vendored
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
/**
|
||||
* WebRTC Manager
|
||||
* Handles all WebRTC peer connection logic for voice and video calls
|
||||
*/
|
||||
|
||||
class WebRTCManager {
|
||||
constructor(socket) {
|
||||
this.socket = socket;
|
||||
this.peerConnections = new Map(); // Map of userId -> RTCPeerConnection
|
||||
this.localStream = null;
|
||||
this.screenStream = null;
|
||||
this.remoteStreams = new Map(); // Map of userId -> MediaStream
|
||||
this.currentCallId = null;
|
||||
this.isInitiator = false;
|
||||
|
||||
// WebRTC configuration with STUN/TURN servers
|
||||
this.configuration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||
]
|
||||
};
|
||||
|
||||
// Media constraints
|
||||
this.audioConstraints = {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
};
|
||||
|
||||
this.videoConstraints = {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
frameRate: { ideal: 30 }
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
this.onRemoteStream = null;
|
||||
this.onRemoteStreamRemoved = null;
|
||||
this.onConnectionStateChange = null;
|
||||
this.onIceConnectionStateChange = null;
|
||||
|
||||
this.setupSocketListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Socket.io listeners for WebRTC signaling
|
||||
*/
|
||||
setupSocketListeners() {
|
||||
this.socket.on('call:offer', async (data) => {
|
||||
await this.handleOffer(data);
|
||||
});
|
||||
|
||||
this.socket.on('call:answer', async (data) => {
|
||||
await this.handleAnswer(data);
|
||||
});
|
||||
|
||||
this.socket.on('call:ice-candidate', async (data) => {
|
||||
await this.handleIceCandidate(data);
|
||||
});
|
||||
|
||||
this.socket.on('call:ended', () => {
|
||||
this.cleanup();
|
||||
});
|
||||
|
||||
this.socket.on('call:participant-joined', async (data) => {
|
||||
console.log('Participant joined:', data);
|
||||
// For group calls, establish connection with new participant
|
||||
if (this.isInitiator) {
|
||||
await this.initiateCallToUser(data.userId);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('call:participant-left', (data) => {
|
||||
console.log('Participant left:', data);
|
||||
this.removePeerConnection(data.userId);
|
||||
});
|
||||
|
||||
this.socket.on('call:media-state-changed', (data) => {
|
||||
console.log('Media state changed:', data);
|
||||
// Update UI to reflect remote user's media state
|
||||
if (this.onMediaStateChanged) {
|
||||
this.onMediaStateChanged(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set TURN server credentials
|
||||
*/
|
||||
async setTurnCredentials(turnCredentials) {
|
||||
if (turnCredentials && turnCredentials.urls) {
|
||||
const turnServer = {
|
||||
urls: turnCredentials.urls,
|
||||
username: turnCredentials.username,
|
||||
credential: turnCredentials.credential
|
||||
};
|
||||
|
||||
this.configuration.iceServers.push(turnServer);
|
||||
console.log('TURN server configured');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize local media stream (audio and/or video)
|
||||
*/
|
||||
async initializeLocalStream(audioEnabled = true, videoEnabled = true) {
|
||||
try {
|
||||
const constraints = {
|
||||
audio: audioEnabled ? this.audioConstraints : false,
|
||||
video: videoEnabled ? this.videoConstraints : false
|
||||
};
|
||||
|
||||
this.localStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
console.log('Local stream initialized:', {
|
||||
audio: audioEnabled,
|
||||
video: videoEnabled,
|
||||
tracks: this.localStream.getTracks().length
|
||||
});
|
||||
|
||||
return this.localStream;
|
||||
} catch (error) {
|
||||
console.error('Error accessing media devices:', error);
|
||||
throw new Error(`Failed to access camera/microphone: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a peer connection for a user
|
||||
*/
|
||||
createPeerConnection(userId) {
|
||||
if (this.peerConnections.has(userId)) {
|
||||
return this.peerConnections.get(userId);
|
||||
}
|
||||
|
||||
const peerConnection = new RTCPeerConnection(this.configuration);
|
||||
|
||||
// Add local stream tracks to peer connection
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => {
|
||||
peerConnection.addTrack(track, this.localStream);
|
||||
});
|
||||
}
|
||||
|
||||
// Handle incoming remote stream
|
||||
peerConnection.ontrack = (event) => {
|
||||
console.log('Received remote track from', userId, event.track.kind);
|
||||
|
||||
const [remoteStream] = event.streams;
|
||||
this.remoteStreams.set(userId, remoteStream);
|
||||
|
||||
if (this.onRemoteStream) {
|
||||
this.onRemoteStream(userId, remoteStream);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle ICE candidates
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
console.log('Sending ICE candidate to', userId);
|
||||
this.socket.emit('call:ice-candidate', {
|
||||
callId: this.currentCallId,
|
||||
targetUserId: userId,
|
||||
candidate: event.candidate
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle connection state changes
|
||||
peerConnection.onconnectionstatechange = () => {
|
||||
console.log(`Connection state with ${userId}:`, peerConnection.connectionState);
|
||||
|
||||
if (this.onConnectionStateChange) {
|
||||
this.onConnectionStateChange(userId, peerConnection.connectionState);
|
||||
}
|
||||
|
||||
// Cleanup if connection fails or closes
|
||||
if (peerConnection.connectionState === 'failed' ||
|
||||
peerConnection.connectionState === 'closed') {
|
||||
this.removePeerConnection(userId);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle ICE connection state changes
|
||||
peerConnection.oniceconnectionstatechange = () => {
|
||||
console.log(`ICE connection state with ${userId}:`, peerConnection.iceConnectionState);
|
||||
|
||||
if (this.onIceConnectionStateChange) {
|
||||
this.onIceConnectionStateChange(userId, peerConnection.iceConnectionState);
|
||||
}
|
||||
};
|
||||
|
||||
this.peerConnections.set(userId, peerConnection);
|
||||
return peerConnection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a call to a user (create offer)
|
||||
*/
|
||||
async initiateCallToUser(userId) {
|
||||
try {
|
||||
const peerConnection = this.createPeerConnection(userId);
|
||||
|
||||
// Create offer
|
||||
const offer = await peerConnection.createOffer({
|
||||
offerToReceiveAudio: true,
|
||||
offerToReceiveVideo: true
|
||||
});
|
||||
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
|
||||
// Send offer through signaling server
|
||||
this.socket.emit('call:offer', {
|
||||
callId: this.currentCallId,
|
||||
targetUserId: userId,
|
||||
offer: offer
|
||||
});
|
||||
|
||||
console.log('Call offer sent to', userId);
|
||||
} catch (error) {
|
||||
console.error('Error initiating call:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming call offer
|
||||
*/
|
||||
async handleOffer(data) {
|
||||
const { callId, fromUserId, offer } = data;
|
||||
|
||||
try {
|
||||
console.log('Received call offer from', fromUserId);
|
||||
|
||||
this.currentCallId = callId;
|
||||
const peerConnection = this.createPeerConnection(fromUserId);
|
||||
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
|
||||
// Create answer
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
// Send answer back
|
||||
this.socket.emit('call:answer', {
|
||||
callId: callId,
|
||||
targetUserId: fromUserId,
|
||||
answer: answer
|
||||
});
|
||||
|
||||
console.log('Call answer sent to', fromUserId);
|
||||
} catch (error) {
|
||||
console.error('Error handling offer:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming call answer
|
||||
*/
|
||||
async handleAnswer(data) {
|
||||
const { fromUserId, answer } = data;
|
||||
|
||||
try {
|
||||
console.log('Received call answer from', fromUserId);
|
||||
|
||||
const peerConnection = this.peerConnections.get(fromUserId);
|
||||
if (!peerConnection) {
|
||||
throw new Error(`No peer connection found for user ${fromUserId}`);
|
||||
}
|
||||
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
console.log('Remote description set for', fromUserId);
|
||||
} catch (error) {
|
||||
console.error('Error handling answer:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming ICE candidate
|
||||
*/
|
||||
async handleIceCandidate(data) {
|
||||
const { fromUserId, candidate } = data;
|
||||
|
||||
try {
|
||||
const peerConnection = this.peerConnections.get(fromUserId);
|
||||
if (!peerConnection) {
|
||||
console.warn(`No peer connection found for user ${fromUserId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
|
||||
console.log('ICE candidate added for', fromUserId);
|
||||
} catch (error) {
|
||||
console.error('Error adding ICE candidate:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove peer connection for a user
|
||||
*/
|
||||
removePeerConnection(userId) {
|
||||
const peerConnection = this.peerConnections.get(userId);
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
this.peerConnections.delete(userId);
|
||||
}
|
||||
|
||||
const remoteStream = this.remoteStreams.get(userId);
|
||||
if (remoteStream) {
|
||||
remoteStream.getTracks().forEach(track => track.stop());
|
||||
this.remoteStreams.delete(userId);
|
||||
|
||||
if (this.onRemoteStreamRemoved) {
|
||||
this.onRemoteStreamRemoved(userId);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Peer connection removed for', userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle audio track enabled/disabled
|
||||
*/
|
||||
toggleAudio(enabled) {
|
||||
if (this.localStream) {
|
||||
const audioTrack = this.localStream.getAudioTracks()[0];
|
||||
if (audioTrack) {
|
||||
audioTrack.enabled = enabled;
|
||||
console.log('Audio', enabled ? 'enabled' : 'disabled');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle video track enabled/disabled
|
||||
*/
|
||||
toggleVideo(enabled) {
|
||||
if (this.localStream) {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
videoTrack.enabled = enabled;
|
||||
console.log('Video', enabled ? 'enabled' : 'disabled');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start screen sharing
|
||||
*/
|
||||
async startScreenShare() {
|
||||
try {
|
||||
this.screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
cursor: 'always'
|
||||
},
|
||||
audio: false
|
||||
});
|
||||
|
||||
const screenTrack = this.screenStream.getVideoTracks()[0];
|
||||
|
||||
// Replace video track in all peer connections
|
||||
this.peerConnections.forEach((peerConnection) => {
|
||||
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video');
|
||||
if (sender) {
|
||||
sender.replaceTrack(screenTrack);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle screen share stop
|
||||
screenTrack.onended = () => {
|
||||
this.stopScreenShare();
|
||||
};
|
||||
|
||||
console.log('Screen sharing started');
|
||||
return this.screenStream;
|
||||
} catch (error) {
|
||||
console.error('Error starting screen share:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop screen sharing and restore camera
|
||||
*/
|
||||
stopScreenShare() {
|
||||
if (this.screenStream) {
|
||||
this.screenStream.getTracks().forEach(track => track.stop());
|
||||
this.screenStream = null;
|
||||
|
||||
// Restore camera track
|
||||
if (this.localStream) {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
this.peerConnections.forEach((peerConnection) => {
|
||||
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'video');
|
||||
if (sender) {
|
||||
sender.replaceTrack(videoTrack);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Screen sharing stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
async getConnectionStats(userId) {
|
||||
const peerConnection = this.peerConnections.get(userId);
|
||||
if (!peerConnection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stats = await peerConnection.getStats();
|
||||
const result = {
|
||||
audio: {},
|
||||
video: {},
|
||||
connection: {}
|
||||
};
|
||||
|
||||
stats.forEach(report => {
|
||||
if (report.type === 'inbound-rtp') {
|
||||
if (report.kind === 'audio') {
|
||||
result.audio.bytesReceived = report.bytesReceived;
|
||||
result.audio.packetsLost = report.packetsLost;
|
||||
result.audio.jitter = report.jitter;
|
||||
} else if (report.kind === 'video') {
|
||||
result.video.bytesReceived = report.bytesReceived;
|
||||
result.video.packetsLost = report.packetsLost;
|
||||
result.video.framesDecoded = report.framesDecoded;
|
||||
result.video.frameWidth = report.frameWidth;
|
||||
result.video.frameHeight = report.frameHeight;
|
||||
}
|
||||
} else if (report.type === 'candidate-pair' && report.state === 'succeeded') {
|
||||
result.connection.roundTripTime = report.currentRoundTripTime;
|
||||
result.connection.availableOutgoingBitrate = report.availableOutgoingBitrate;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all connections and streams
|
||||
*/
|
||||
cleanup() {
|
||||
console.log('Cleaning up WebRTC resources');
|
||||
|
||||
// Stop screen share if active
|
||||
this.stopScreenShare();
|
||||
|
||||
// Close all peer connections
|
||||
this.peerConnections.forEach((peerConnection, userId) => {
|
||||
this.removePeerConnection(userId);
|
||||
});
|
||||
|
||||
// Stop local stream
|
||||
if (this.localStream) {
|
||||
this.localStream.getTracks().forEach(track => track.stop());
|
||||
this.localStream = null;
|
||||
}
|
||||
|
||||
// Clear remote streams
|
||||
this.remoteStreams.forEach((stream) => {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
});
|
||||
this.remoteStreams.clear();
|
||||
|
||||
this.currentCallId = null;
|
||||
this.isInitiator = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get local stream
|
||||
*/
|
||||
getLocalStream() {
|
||||
return this.localStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remote stream for a user
|
||||
*/
|
||||
getRemoteStream(userId) {
|
||||
return this.remoteStreams.get(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all remote streams
|
||||
*/
|
||||
getAllRemoteStreams() {
|
||||
return Array.from(this.remoteStreams.entries());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if audio is enabled
|
||||
*/
|
||||
isAudioEnabled() {
|
||||
if (this.localStream) {
|
||||
const audioTrack = this.localStream.getAudioTracks()[0];
|
||||
return audioTrack ? audioTrack.enabled : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if video is enabled
|
||||
*/
|
||||
isVideoEnabled() {
|
||||
if (this.localStream) {
|
||||
const videoTrack = this.localStream.getVideoTracks()[0];
|
||||
return videoTrack ? videoTrack.enabled : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if screen sharing is active
|
||||
*/
|
||||
isScreenSharing() {
|
||||
return this.screenStream !== null;
|
||||
}
|
||||
}
|
||||
|
||||
export default WebRTCManager;
|
||||
16
astro-site/src/react-app/vite.config.js
Normal file
16
astro-site/src/react-app/vite.config.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ const path = require('path');
|
|||
|
||||
const app = express();
|
||||
const httpServer = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const PORT = 3000;
|
||||
|
||||
// Trust proxy for Codespaces/containers
|
||||
app.set('trust proxy', 1);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ require('dotenv').config();
|
|||
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const PORT = 3000;
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import DomainVerification from './components/DomainVerification';
|
||||
import VerifiedDomainBadge from './components/VerifiedDomainBadge';
|
||||
import './App.css';
|
||||
|
|
@ -8,35 +9,25 @@ import './App.css';
|
|||
* Demo of domain verification feature
|
||||
*/
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showVerification, setShowVerification] = useState(false);
|
||||
const { user, loading } = useAuth();
|
||||
const [showVerification, setShowVerification] = React.useState(false);
|
||||
|
||||
// Mock user data - in production, fetch from API
|
||||
useEffect(() => {
|
||||
// Simulate fetching user data
|
||||
const mockUser = {
|
||||
id: 'user-123',
|
||||
name: 'Demo User',
|
||||
email: 'demo@aethex.dev',
|
||||
verifiedDomain: null, // Will be populated after verification
|
||||
domainVerifiedAt: null
|
||||
};
|
||||
setUser(mockUser);
|
||||
}, []);
|
||||
if (loading) {
|
||||
return <div className="loading-screen">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>AeThex Passport</h1>
|
||||
<p>Domain Verification Demo</p>
|
||||
<p>Domain Verification</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{user && (
|
||||
<div className="user-profile">
|
||||
<div className="profile-header">
|
||||
<h2>{user.name}</h2>
|
||||
<p>{user.email}</p>
|
||||
<h2>{user.email}</h2>
|
||||
{user.verifiedDomain && (
|
||||
<VerifiedDomainBadge
|
||||
verifiedDomain={user.verifiedDomain}
|
||||
|
|
@ -84,4 +75,10 @@ function App() {
|
|||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default function AppWrapper() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,8 +31,7 @@ export default function ChannelView({ channel, projectId }) {
|
|||
try {
|
||||
const decrypted = await decryptMessage(
|
||||
JSON.parse(message.content),
|
||||
user.password,
|
||||
user.publicKey
|
||||
user.password
|
||||
);
|
||||
message.content = decrypted;
|
||||
} catch (error) {
|
||||
|
|
@ -76,10 +75,8 @@ export default function ChannelView({ channel, projectId }) {
|
|||
try {
|
||||
const decrypted = await decryptMessage(
|
||||
JSON.parse(msg.content),
|
||||
user.password,
|
||||
user.publicKey
|
||||
user.password
|
||||
);
|
||||
|
||||
return {
|
||||
...msg,
|
||||
content: decrypted
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
|
|
@ -15,47 +21,37 @@ export function AuthProvider({ children }) {
|
|||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize with demo user for development
|
||||
const demoUser = {
|
||||
id: 'demo-user-123',
|
||||
name: 'Demo User',
|
||||
email: 'demo@aethex.dev',
|
||||
verifiedDomain: 'demo.aethex',
|
||||
domainVerifiedAt: new Date().toISOString(),
|
||||
isPremium: false,
|
||||
avatar: null
|
||||
const getSession = async () => {
|
||||
setLoading(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
setUser(session.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
getSession();
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user || null);
|
||||
});
|
||||
return () => {
|
||||
listener?.subscription.unsubscribe();
|
||||
};
|
||||
|
||||
setUser(demoUser);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (email, password) => {
|
||||
// Mock login - in production, call actual API
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${apiUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
localStorage.setItem('token', data.token);
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Login failed' };
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setUser(null);
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
const updateUser = (updates) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import Demo from './Demo';
|
||||
import AppWrapper from './App';
|
||||
import './index.css';
|
||||
import './Demo.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Demo />
|
||||
<AppWrapper />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react';
|
|||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ project_id = "AeThex-Connect"
|
|||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
port = 3000
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
|
|
|
|||
|
|
@ -5,33 +5,49 @@
|
|||
-- CONVERSATIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Ensure all required columns exist for index creation
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS type VARCHAR(20);
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS created_by VARCHAR;
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS gameforge_project_id VARCHAR;
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT false;
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW();
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS title VARCHAR(200);
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS avatar_url VARCHAR(500);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('direct', 'group', 'channel')),
|
||||
title VARCHAR(200),
|
||||
description TEXT,
|
||||
avatar_url VARCHAR(500),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
gameforge_project_id UUID, -- For GameForge integration (future)
|
||||
created_by VARCHAR REFERENCES users(id) ON DELETE SET NULL,
|
||||
gameforge_project_id VARCHAR, -- For GameForge integration (future)
|
||||
is_archived BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index on type column
|
||||
CREATE INDEX idx_conversations_type ON conversations(type);
|
||||
-- Create index on creator column
|
||||
CREATE INDEX idx_conversations_creator ON conversations(created_by);
|
||||
-- Create index on project column
|
||||
CREATE INDEX idx_conversations_project ON conversations(gameforge_project_id);
|
||||
-- Create index on updated_at column
|
||||
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- CONVERSATION PARTICIPANTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Update conversation_participants to match actual types and remove reference to identities
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
identity_id UUID REFERENCES identities(id) ON DELETE SET NULL,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
identity_id VARCHAR,
|
||||
role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('admin', 'moderator', 'member')),
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
last_read_at TIMESTAMP,
|
||||
|
|
@ -39,8 +55,11 @@ CREATE TABLE IF NOT EXISTS conversation_participants (
|
|||
UNIQUE(conversation_id, user_id)
|
||||
);
|
||||
|
||||
-- Create index on conversation column
|
||||
CREATE INDEX idx_participants_conversation ON conversation_participants(conversation_id);
|
||||
-- Create index on user column
|
||||
CREATE INDEX idx_participants_user ON conversation_participants(user_id);
|
||||
-- Create index on identity column
|
||||
CREATE INDEX idx_participants_identity ON conversation_participants(identity_id);
|
||||
|
||||
-- ============================================================================
|
||||
|
|
@ -61,25 +80,35 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ensure reply_to_id column exists for index creation
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS reply_to_id UUID;
|
||||
|
||||
-- Create index on conversation and created_at columns
|
||||
CREATE INDEX idx_messages_conversation ON messages(conversation_id, created_at DESC);
|
||||
-- Create index on sender column
|
||||
CREATE INDEX idx_messages_sender ON messages(sender_id);
|
||||
-- Create index on reply_to column
|
||||
CREATE INDEX idx_messages_reply_to ON messages(reply_to_id);
|
||||
-- Create index on created_at column
|
||||
CREATE INDEX idx_messages_created ON messages(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- MESSAGE REACTIONS
|
||||
-- ============================================================================
|
||||
|
||||
-- Update message_reactions to match actual types
|
||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
);
|
||||
|
||||
-- Create index on message column
|
||||
CREATE INDEX idx_reactions_message ON message_reactions(message_id);
|
||||
-- Create index on user column
|
||||
CREATE INDEX idx_reactions_user ON message_reactions(user_id);
|
||||
|
||||
-- ============================================================================
|
||||
|
|
@ -101,19 +130,29 @@ CREATE TABLE IF NOT EXISTS files (
|
|||
expires_at TIMESTAMP -- For temporary files
|
||||
);
|
||||
|
||||
-- Ensure uploader_id column exists for index creation
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS uploader_id VARCHAR;
|
||||
|
||||
-- Ensure conversation_id column exists for index creation
|
||||
ALTER TABLE files ADD COLUMN IF NOT EXISTS conversation_id UUID;
|
||||
|
||||
-- Create index on uploader column
|
||||
CREATE INDEX idx_files_uploader ON files(uploader_id);
|
||||
-- Create index on conversation column
|
||||
CREATE INDEX idx_files_conversation ON files(conversation_id);
|
||||
-- Create index on created_at column
|
||||
CREATE INDEX idx_files_created ON files(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- CALLS
|
||||
-- ============================================================================
|
||||
|
||||
-- Update calls to match actual types
|
||||
CREATE TABLE IF NOT EXISTS calls (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
type VARCHAR(20) NOT NULL CHECK (type IN ('voice', 'video')),
|
||||
initiator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
initiator_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) DEFAULT 'ringing' CHECK (status IN ('ringing', 'active', 'ended', 'missed', 'declined')),
|
||||
started_at TIMESTAMP,
|
||||
ended_at TIMESTAMP,
|
||||
|
|
@ -121,26 +160,33 @@ CREATE TABLE IF NOT EXISTS calls (
|
|||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create index on conversation column
|
||||
CREATE INDEX idx_calls_conversation ON calls(conversation_id);
|
||||
-- Create index on initiator column
|
||||
CREATE INDEX idx_calls_initiator ON calls(initiator_id);
|
||||
-- Create index on status column
|
||||
CREATE INDEX idx_calls_status ON calls(status);
|
||||
-- Create index on created_at column
|
||||
CREATE INDEX idx_calls_created ON calls(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- CALL PARTICIPANTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Update call_participants to match actual types
|
||||
CREATE TABLE IF NOT EXISTS call_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
call_id UUID NOT NULL REFERENCES calls(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP,
|
||||
left_at TIMESTAMP,
|
||||
media_state JSONB DEFAULT '{"audio": true, "video": false, "screen_share": false}'::jsonb,
|
||||
UNIQUE(call_id, user_id)
|
||||
);
|
||||
|
||||
-- Create index on call column
|
||||
CREATE INDEX idx_call_participants_call ON call_participants(call_id);
|
||||
-- Create index on user column
|
||||
CREATE INDEX idx_call_participants_user ON call_participants(user_id);
|
||||
|
||||
-- ============================================================================
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ CREATE TABLE IF NOT EXISTS audit_logs (
|
|||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Ensure resource_type column exists for index creation
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS resource_type VARCHAR;
|
||||
ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS resource_id VARCHAR;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_gameforge_integrations_project_id ON gameforge_integrations(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_gameforge_project ON conversations(gameforge_project_id) WHERE gameforge_project_id IS NOT NULL;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ ADD COLUMN IF NOT EXISTS connection_quality VARCHAR(20) DEFAULT 'good'; -- excel
|
|||
-- Create turn_credentials table for temporary TURN server credentials
|
||||
CREATE TABLE IF NOT EXISTS turn_credentials (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
credential VARCHAR(100) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
-- Migration 005: Nexus Cross-Platform Integration
|
||||
-- Adds friend system, game sessions, lobbies, and enhanced Nexus features
|
||||
|
||||
-- Ensure nexus_integrations table exists before ALTER TABLE
|
||||
CREATE TABLE IF NOT EXISTS nexus_integrations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
|
||||
);
|
||||
|
||||
-- Extend nexus_integrations table with session and overlay config
|
||||
ALTER TABLE nexus_integrations
|
||||
ADD COLUMN IF NOT EXISTS current_game_session_id UUID,
|
||||
|
|
@ -12,8 +17,8 @@ ADD COLUMN IF NOT EXISTS overlay_position VARCHAR(20) DEFAULT 'top-right';
|
|||
-- Friend requests table
|
||||
CREATE TABLE IF NOT EXISTS friend_requests (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
from_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
from_user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
to_user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status VARCHAR(20) DEFAULT 'pending', -- pending, accepted, rejected
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
responded_at TIMESTAMP,
|
||||
|
|
@ -26,8 +31,8 @@ CREATE INDEX IF NOT EXISTS idx_friend_requests_from ON friend_requests(from_user
|
|||
-- Friendships table
|
||||
CREATE TABLE IF NOT EXISTS friendships (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user1_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user2_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user1_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user2_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
CHECK (user1_id < user2_id), -- Prevent duplicates
|
||||
UNIQUE(user1_id, user2_id)
|
||||
|
|
@ -50,6 +55,15 @@ CREATE TABLE IF NOT EXISTS game_sessions (
|
|||
metadata JSONB -- {mapName, gameMode, score, etc.}
|
||||
);
|
||||
|
||||
-- Ensure started_at column exists for index creation
|
||||
ALTER TABLE game_sessions ADD COLUMN IF NOT EXISTS started_at TIMESTAMP;
|
||||
|
||||
-- Ensure session_state column exists for index creation
|
||||
ALTER TABLE game_sessions ADD COLUMN IF NOT EXISTS session_state VARCHAR;
|
||||
|
||||
-- Ensure nexus_player_id column exists for index creation
|
||||
ALTER TABLE game_sessions ADD COLUMN IF NOT EXISTS nexus_player_id VARCHAR;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_game_sessions_user ON game_sessions(user_id, started_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_sessions_active ON game_sessions(user_id, session_state);
|
||||
CREATE INDEX IF NOT EXISTS idx_game_sessions_nexus_player ON game_sessions(nexus_player_id);
|
||||
|
|
@ -59,7 +73,7 @@ CREATE TABLE IF NOT EXISTS game_lobbies (
|
|||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
game_id VARCHAR(100) NOT NULL,
|
||||
lobby_code VARCHAR(50) UNIQUE,
|
||||
host_user_id UUID NOT NULL REFERENCES users(id),
|
||||
host_user_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
conversation_id UUID REFERENCES conversations(id), -- Auto-created chat
|
||||
max_players INTEGER DEFAULT 8,
|
||||
is_public BOOLEAN DEFAULT false,
|
||||
|
|
@ -77,7 +91,7 @@ CREATE INDEX IF NOT EXISTS idx_game_lobbies_code ON game_lobbies(lobby_code);
|
|||
CREATE TABLE IF NOT EXISTS game_lobby_participants (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
lobby_id UUID NOT NULL REFERENCES game_lobbies(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
team_id VARCHAR(20), -- For team-based games
|
||||
ready BOOLEAN DEFAULT false,
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ ADD COLUMN IF NOT EXISTS premium_tier VARCHAR(20) DEFAULT 'free'; -- free, premi
|
|||
-- Premium subscriptions table
|
||||
CREATE TABLE IF NOT EXISTS premium_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tier VARCHAR(20) NOT NULL, -- free, premium, enterprise
|
||||
status VARCHAR(20) DEFAULT 'active', -- active, cancelled, expired, suspended
|
||||
stripe_subscription_id VARCHAR(100),
|
||||
|
|
@ -29,7 +29,7 @@ CREATE INDEX IF NOT EXISTS idx_premium_subscriptions_status ON premium_subscript
|
|||
CREATE TABLE IF NOT EXISTS blockchain_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
domain VARCHAR(100) NOT NULL UNIQUE, -- e.g., "anderson.aethex"
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||
owner_user_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
nft_token_id VARCHAR(100), -- Token ID from Freename contract
|
||||
wallet_address VARCHAR(100), -- Owner's wallet address
|
||||
verified BOOLEAN DEFAULT false,
|
||||
|
|
@ -51,8 +51,8 @@ CREATE INDEX IF NOT EXISTS idx_blockchain_domains_domain ON blockchain_domains(d
|
|||
CREATE TABLE IF NOT EXISTS domain_transfers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
domain_id UUID NOT NULL REFERENCES blockchain_domains(id),
|
||||
from_user_id UUID REFERENCES users(id),
|
||||
to_user_id UUID REFERENCES users(id),
|
||||
from_user_id VARCHAR REFERENCES users(id),
|
||||
to_user_id VARCHAR REFERENCES users(id),
|
||||
transfer_type VARCHAR(20), -- sale, gift, transfer
|
||||
price_usd DECIMAL(10, 2),
|
||||
transaction_hash VARCHAR(100), -- Blockchain tx hash
|
||||
|
|
@ -68,7 +68,7 @@ CREATE INDEX IF NOT EXISTS idx_domain_transfers_status ON domain_transfers(statu
|
|||
CREATE TABLE IF NOT EXISTS enterprise_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_name VARCHAR(200) NOT NULL,
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||
owner_user_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
custom_domain VARCHAR(200), -- e.g., chat.yourgame.com
|
||||
custom_domain_verified BOOLEAN DEFAULT false,
|
||||
dns_txt_record VARCHAR(100), -- For domain verification
|
||||
|
|
@ -90,7 +90,7 @@ CREATE INDEX IF NOT EXISTS idx_enterprise_accounts_subscription ON enterprise_ac
|
|||
CREATE TABLE IF NOT EXISTS enterprise_team_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
enterprise_id UUID NOT NULL REFERENCES enterprise_accounts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) DEFAULT 'member', -- admin, member
|
||||
joined_at TIMESTAMP DEFAULT NOW(),
|
||||
UNIQUE(enterprise_id, user_id)
|
||||
|
|
@ -102,7 +102,7 @@ CREATE INDEX IF NOT EXISTS idx_enterprise_team_members_user ON enterprise_team_m
|
|||
-- Usage analytics table
|
||||
CREATE TABLE IF NOT EXISTS usage_analytics (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
messages_sent INTEGER DEFAULT 0,
|
||||
messages_received INTEGER DEFAULT 0,
|
||||
|
|
@ -145,7 +145,7 @@ ON CONFLICT (tier) DO NOTHING;
|
|||
-- Payment transactions table (for audit trail)
|
||||
CREATE TABLE IF NOT EXISTS payment_transactions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
user_id VARCHAR NOT NULL REFERENCES users(id),
|
||||
transaction_type VARCHAR(50), -- subscription, domain_purchase, domain_sale, etc.
|
||||
amount_usd DECIMAL(10, 2) NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'usd',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
-- Fix for missing 'type' column in conversations table
|
||||
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS type VARCHAR(20);
|
||||
-- Now create the index
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type);
|
||||
Loading…
Reference in a new issue