deleted: astro-site/src/pages/mockup.jsx
This commit is contained in:
parent
7f4107c13f
commit
770d0e38ec
186 changed files with 1910 additions and 35847 deletions
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1768189288502
|
||||
"lastUpdateCheck": 1770417750117
|
||||
}
|
||||
}
|
||||
|
|
@ -5,4 +5,8 @@ import react from '@astrojs/react';
|
|||
export default defineConfig({
|
||||
integrations: [tailwind(), react()],
|
||||
site: 'https://aethex-connect.com',
|
||||
server: {
|
||||
port: 4321,
|
||||
host: 'localhost'
|
||||
}
|
||||
});
|
||||
|
|
|
|||
275
astro-site/src/components/aethex/AeThexProvider.jsx
Normal file
275
astro-site/src/components/aethex/AeThexProvider.jsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
/**
|
||||
* AeThex Provider
|
||||
* Main context provider for AeThex Connect - handles auth, chat, and real-time features
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
const API_URL = import.meta.env?.VITE_API_URL || 'http://localhost:3000';
|
||||
const API_BASE = `${API_URL}/api`;
|
||||
|
||||
const AeThexContext = createContext(null);
|
||||
|
||||
export function useAeThex() {
|
||||
return useContext(AeThexContext);
|
||||
}
|
||||
|
||||
export function AeThexProvider({ children }) {
|
||||
// Auth state
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setTokenState] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Socket state
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
// Chat state
|
||||
const [servers, setServers] = useState([]);
|
||||
const [channels, setChannels] = useState([]);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [currentServer, setCurrentServer] = useState(null);
|
||||
const [currentChannel, setCurrentChannel] = useState(null);
|
||||
const [onlineUsers, setOnlineUsers] = useState([]);
|
||||
|
||||
// Token management
|
||||
const setToken = useCallback((newToken) => {
|
||||
setTokenState(newToken);
|
||||
if (newToken) {
|
||||
localStorage.setItem('aethex_token', newToken);
|
||||
} else {
|
||||
localStorage.removeItem('aethex_token');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// API request helper
|
||||
const apiRequest = useCallback(async (endpoint, options = {}) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const currentToken = token || localStorage.getItem('aethex_token');
|
||||
if (currentToken) {
|
||||
headers['Authorization'] = `Bearer ${currentToken}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}, [token]);
|
||||
|
||||
// Auth functions
|
||||
const login = useCallback(async (email, password) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (response.success) {
|
||||
setToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(response.error || 'Login failed');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
const register = useCallback(async (email, password, username, displayName) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, username, displayName }),
|
||||
});
|
||||
if (response.success) {
|
||||
setToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(response.error || 'Registration failed');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
const demoLogin = useCallback(async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiRequest('/auth/demo', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.success) {
|
||||
setToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
return { success: true };
|
||||
}
|
||||
throw new Error(response.error || 'Demo login failed');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setSocket(null);
|
||||
}
|
||||
setConnected(false);
|
||||
}, [socket, setToken]);
|
||||
|
||||
// Socket connection
|
||||
const connectSocket = useCallback((authToken) => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
const newSocket = io(API_URL, {
|
||||
auth: { token: authToken },
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionAttempts: 10,
|
||||
transports: ['websocket', 'polling']
|
||||
});
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('✓ Connected to AeThex Connect');
|
||||
setConnected(true);
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
console.log('✗ Disconnected from AeThex Connect');
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on('message:new', (message) => {
|
||||
setMessages(prev => [...prev, message]);
|
||||
});
|
||||
|
||||
newSocket.on('presence:online', (users) => {
|
||||
setOnlineUsers(users);
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
return newSocket;
|
||||
}, [socket]);
|
||||
|
||||
// Chat functions
|
||||
const sendMessage = useCallback((content) => {
|
||||
if (!socket || !connected || !currentChannel) return;
|
||||
socket.emit('channel:message', {
|
||||
channelId: currentChannel.id,
|
||||
content
|
||||
});
|
||||
}, [socket, connected, currentChannel]);
|
||||
|
||||
const joinChannel = useCallback((channelId) => {
|
||||
if (!socket || !connected) return;
|
||||
socket.emit('channel:join', { channelId });
|
||||
}, [socket, connected]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
const storedToken = localStorage.getItem('aethex_token');
|
||||
if (storedToken) {
|
||||
setTokenState(storedToken);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/me`, {
|
||||
headers: { 'Authorization': `Bearer ${storedToken}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setUser(data.data);
|
||||
connectSocket(storedToken);
|
||||
} else {
|
||||
localStorage.removeItem('aethex_token');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
localStorage.removeItem('aethex_token');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Connect socket when user logs in
|
||||
useEffect(() => {
|
||||
if (user && token && !socket) {
|
||||
connectSocket(token);
|
||||
}
|
||||
}, [user, token]);
|
||||
|
||||
const value = {
|
||||
// Auth
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
register,
|
||||
demoLogin,
|
||||
logout,
|
||||
|
||||
// Socket
|
||||
socket,
|
||||
connected,
|
||||
|
||||
// Chat
|
||||
servers,
|
||||
channels,
|
||||
messages,
|
||||
currentServer,
|
||||
currentChannel,
|
||||
onlineUsers,
|
||||
setCurrentServer,
|
||||
setCurrentChannel,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
joinChannel,
|
||||
|
||||
// API helper
|
||||
apiRequest
|
||||
};
|
||||
|
||||
return (
|
||||
<AeThexContext.Provider value={value}>
|
||||
{children}
|
||||
</AeThexContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,13 @@
|
|||
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useMatrix } from "../matrix/MatrixProvider.jsx";
|
||||
|
||||
import { useAeThex } from "../aethex/AeThexProvider.jsx";
|
||||
|
||||
/**
|
||||
* UI for linking AeThex and Matrix accounts.
|
||||
* 1. User logs in with AeThex credentials (simulate for now)
|
||||
* 2. User logs in with Matrix credentials
|
||||
* 3. Store mapping in localStorage (or call backend in real app)
|
||||
* UI for AeThex account login/registration
|
||||
*/
|
||||
export default function AccountLinker({ onLinked }) {
|
||||
const matrixCtx = useMatrix();
|
||||
if (!matrixCtx) {
|
||||
const aethex = useAeThex();
|
||||
|
||||
if (!aethex) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-[#1a1a2e] via-[#23234a] to-[#0f3460]">
|
||||
<div className="relative flex flex-col items-center">
|
||||
|
|
@ -24,7 +19,7 @@ export default function AccountLinker({ onLinked }) {
|
|||
</div>
|
||||
<div className="toast bg-[#23234a] text-white px-6 py-4 rounded-xl shadow-lg border border-pink-400 animate-fade-in">
|
||||
<span className="font-bold text-pink-400">Hang tight!</span> <br />
|
||||
<span className="text-sm text-blue-200">We’re prepping your login experience…</span>
|
||||
<span className="text-sm text-blue-200">We're prepping your login experience…</span>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
|
|
@ -42,61 +37,60 @@ export default function AccountLinker({ onLinked }) {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
const { login } = matrixCtx;
|
||||
const [aethexUser, setAethexUser] = useState("");
|
||||
const [aethexPass, setAethexPass] = useState("");
|
||||
const [matrixUser, setMatrixUser] = useState("");
|
||||
const [matrixPass, setMatrixPass] = useState("");
|
||||
const [step, setStep] = useState(1);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const { login, register, demoLogin, loading, error, isAuthenticated, user } = aethex;
|
||||
|
||||
const [mode, setMode] = useState('login');
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [formError, setFormError] = useState(null);
|
||||
|
||||
// Simulate AeThex auth (replace with real API call)
|
||||
const handleAethexLogin = (e) => {
|
||||
// Already authenticated
|
||||
if (isAuthenticated && user) {
|
||||
if (onLinked) onLinked({ user });
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-[#1a1a2e] via-[#23234a] to-[#0f3460]">
|
||||
<div className="text-white text-lg">Welcome, {user.displayName || user.username}!</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (aethexUser && aethexPass) {
|
||||
setStep(2);
|
||||
setError(null);
|
||||
} else {
|
||||
setError("Enter AeThex username and password.");
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (mode === 'login') {
|
||||
result = await login(email, password);
|
||||
} else {
|
||||
result = await register(email, password, username, displayName);
|
||||
}
|
||||
|
||||
if (result.success && onLinked) {
|
||||
onLinked({ user: result.user });
|
||||
} else if (!result.success) {
|
||||
setFormError(result.error || 'Authentication failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(err.message || 'An error occurred');
|
||||
}
|
||||
};
|
||||
|
||||
// Simulate Matrix auth (let MatrixProvider handle real login)
|
||||
const handleMatrixLogin = (e) => {
|
||||
e.preventDefault();
|
||||
if (matrixUser && matrixPass) {
|
||||
// Store mapping (simulate)
|
||||
localStorage.setItem("aethex-matrix-link", JSON.stringify({ aethexUser, matrixUser }));
|
||||
setError(null);
|
||||
if (onLinked) onLinked({ aethexUser, matrixUser, matrixPass });
|
||||
} else {
|
||||
setError("Enter Matrix username and password.");
|
||||
}
|
||||
};
|
||||
|
||||
// Demo login handler
|
||||
const handleDemoLogin = async () => {
|
||||
setAethexUser('demo');
|
||||
setAethexPass('demo123');
|
||||
setStep(2);
|
||||
setTimeout(async () => {
|
||||
setMatrixUser('@mrpiglr:matrix.org');
|
||||
setMatrixPass('Max!FTW2023!');
|
||||
setTimeout(async () => {
|
||||
localStorage.setItem("aethex-matrix-link", JSON.stringify({ aethexUser: 'demo', matrixUser: '@mrpiglr:matrix.org' }));
|
||||
setError(null);
|
||||
if (login) {
|
||||
try {
|
||||
await login('@mrpiglr:matrix.org', 'Max!FTW2023!');
|
||||
} catch (e) {
|
||||
setError(e.message || 'Login failed.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (onLinked) onLinked({ aethexUser: 'demo', matrixUser: '@mrpiglr:matrix.org', matrixPass: 'Max!FTW2023!' });
|
||||
// window.location.href = '/';
|
||||
}, 500);
|
||||
}, 500);
|
||||
setFormError(null);
|
||||
try {
|
||||
const result = await demoLogin();
|
||||
if (result.success && onLinked) {
|
||||
onLinked({ user: result.user });
|
||||
} else if (!result.success) {
|
||||
setFormError(result.error || 'Demo login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(err.message || 'Demo login failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -104,54 +98,94 @@ export default function AccountLinker({ onLinked }) {
|
|||
<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>
|
||||
<button onClick={handleDemoLogin} className="mb-4 w-full bg-gradient-to-r from-yellow-400 to-pink-500 text-white rounded-lg py-2 font-bold shadow hover:from-yellow-500 hover:to-pink-600 transition">Demo Login</button>
|
||||
{error && <div className="mb-2 text-red-400 text-center w-full">{error}</div>}
|
||||
{/* Show MatrixProvider error if present */}
|
||||
{matrixCtx && matrixCtx.error && (
|
||||
<div className="mb-2 text-red-400 text-center w-full">Matrix error: {matrixCtx.error}</div>
|
||||
<h2 className="text-lg font-semibold mb-6 text-blue-300 text-center">
|
||||
{mode === 'login' ? 'Sign in to your account' : 'Create your account'}
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={handleDemoLogin}
|
||||
disabled={loading}
|
||||
className="mb-4 w-full bg-gradient-to-r from-yellow-400 to-pink-500 text-white rounded-lg py-2 font-bold shadow hover:from-yellow-500 hover:to-pink-600 transition disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Loading...' : '🚀 Try Demo'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center w-full mb-4">
|
||||
<div className="flex-1 border-t border-gray-600"></div>
|
||||
<span className="px-3 text-gray-400 text-sm">or</span>
|
||||
<div className="flex-1 border-t border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{(formError || error) && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-center w-full text-sm">
|
||||
{formError || error}
|
||||
</div>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<form onSubmit={handleAethexLogin} className="flex flex-col gap-4 w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="AeThex Username"
|
||||
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={aethexUser}
|
||||
onChange={e => setAethexUser(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="AeThex 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={aethexPass}
|
||||
onChange={e => setAethexPass(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">Continue to Matrix</button>
|
||||
</form>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<form onSubmit={handleMatrixLogin} className="flex flex-col gap-4 w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Matrix Username (@user:matrix.org)"
|
||||
className="px-4 py-3 rounded-lg bg-[#23234a] text-white border border-[#2d2d5a] focus:outline-none focus:ring-2 focus:ring-green-500 transition"
|
||||
value={matrixUser}
|
||||
onChange={e => setMatrixUser(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Matrix Password"
|
||||
className="px-4 py-3 rounded-lg bg-[#23234a] text-white border border-[#2d2d5a] focus:outline-none focus:ring-2 focus:ring-green-500 transition"
|
||||
value={matrixPass}
|
||||
onChange={e => setMatrixPass(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="bg-gradient-to-r from-green-500 to-blue-500 text-white rounded-lg py-3 font-bold mt-2 shadow-lg hover:from-green-600 hover:to-blue-600 transition">Link Accounts & Login</button>
|
||||
</form>
|
||||
)}
|
||||
<div className="mt-6 text-center text-gray-400 text-xs w-full">
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
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={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display Name (optional)"
|
||||
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={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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)}
|
||||
required
|
||||
/>
|
||||
<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)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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:opacity-50"
|
||||
>
|
||||
{loading ? 'Please wait...' : (mode === 'login' ? 'Sign In' : 'Create Account')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-gray-400 text-sm">
|
||||
{mode === 'login' ? (
|
||||
<span>
|
||||
Don't have an account?{' '}
|
||||
<button onClick={() => setMode('register')} className="text-blue-400 hover:text-blue-300 underline">
|
||||
Sign up
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Already have an account?{' '}
|
||||
<button onClick={() => setMode('login')} className="text-blue-400 hover:text-blue-300 underline">
|
||||
Sign in
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-gray-500 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>
|
||||
|
|
|
|||
215
astro-site/src/components/auth/LoginForm.jsx
Normal file
215
astro-site/src/components/auth/LoginForm.jsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/**
|
||||
* LoginForm Component
|
||||
* Handles user authentication for AeThex Connect
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useAeThex } from "../aethex/AeThexProvider.jsx";
|
||||
import { PRESET_IMAGES, getAvatarImage } from "../../utils/unsplash.js";
|
||||
|
||||
export default function LoginForm() {
|
||||
const context = useAeThex();
|
||||
|
||||
const [mode, setMode] = useState('login'); // 'login' or 'register'
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Loading state while checking auth
|
||||
if (!context) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center min-h-screen"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(26, 26, 46, 0.85), rgba(15, 52, 96, 0.9)), url(${PRESET_IMAGES.loginBackground})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="w-16 h-16 mb-6">
|
||||
<svg className="animate-spin" viewBox="0 0 50 50">
|
||||
<circle className="opacity-20" cx="25" cy="25" r="20" stroke="#fff" strokeWidth="5" fill="none" />
|
||||
<circle cx="25" cy="25" r="20" stroke="#ff6bcb" strokeWidth="5" fill="none" strokeDasharray="100" strokeDashoffset="60" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-white text-lg">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { login, register, demoLogin, loading, error, isAuthenticated, user } = context;
|
||||
|
||||
// If already authenticated, redirect to app
|
||||
if (isAuthenticated && user) {
|
||||
window.location.href = '/app';
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-[#1a1a2e] via-[#23234a] to-[#0f3460]">
|
||||
<div className="text-white text-lg">Welcome, {user.displayName || user.username}! Redirecting...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (mode === 'login') {
|
||||
result = await login(email, password);
|
||||
} else {
|
||||
result = await register(email, password, username, displayName);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
window.location.href = '/app';
|
||||
} else {
|
||||
setFormError(result.error || 'Authentication failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
setFormError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await demoLogin();
|
||||
if (result.success) {
|
||||
window.location.href = '/app';
|
||||
} else {
|
||||
setFormError(result.error || 'Demo login failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(err.message || 'Demo login failed');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="login-bg min-h-screen flex flex-col items-center justify-center"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(135deg, rgba(26, 26, 46, 0.88) 0%, rgba(22, 33, 62, 0.9) 50%, rgba(15, 52, 96, 0.85) 100%), url(${PRESET_IMAGES.loginBackground})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#181818cc] backdrop-blur-sm 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">
|
||||
{mode === 'login' ? 'Sign in to your account' : 'Create your account'}
|
||||
</h2>
|
||||
|
||||
{/* Demo Login Button */}
|
||||
<button
|
||||
onClick={handleDemoLogin}
|
||||
disabled={isSubmitting}
|
||||
className="mb-4 w-full bg-gradient-to-r from-yellow-400 to-pink-500 text-white rounded-lg py-2 font-bold shadow hover:from-yellow-500 hover:to-pink-600 transition disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Loading...' : '🚀 Try Demo'}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center w-full mb-4">
|
||||
<div className="flex-1 border-t border-gray-600"></div>
|
||||
<span className="px-3 text-gray-400 text-sm">or</span>
|
||||
<div className="flex-1 border-t border-gray-600"></div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{(formError || error) && (
|
||||
<div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400 text-center w-full text-sm">
|
||||
{formError || error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login/Register Form */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 w-full">
|
||||
{mode === 'register' && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
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={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Display Name (optional)"
|
||||
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={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<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)}
|
||||
required
|
||||
/>
|
||||
<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)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Please wait...' : (mode === 'login' ? 'Sign In' : 'Create Account')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Toggle Mode */}
|
||||
<div className="mt-6 text-center text-gray-400 text-sm">
|
||||
{mode === 'login' ? (
|
||||
<span>
|
||||
Don't have an account?{' '}
|
||||
<button onClick={() => setMode('register')} className="text-blue-400 hover:text-blue-300 underline">
|
||||
Sign up
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
Already have an account?{' '}
|
||||
<button onClick={() => setMode('login')} className="text-blue-400 hover:text-blue-300 underline">
|
||||
Sign in
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-center text-gray-500 text-xs">
|
||||
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>.
|
||||
</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,11 +1,11 @@
|
|||
import React from "react";
|
||||
import { MatrixProvider } from "../matrix/MatrixProvider.jsx";
|
||||
import AccountLinker from "./AccountLinker.jsx";
|
||||
import { AeThexProvider } from "../aethex/AeThexProvider.jsx";
|
||||
import LoginForm from "./LoginForm.jsx";
|
||||
|
||||
export default function LoginIsland() {
|
||||
return (
|
||||
<MatrixProvider>
|
||||
<AccountLinker onLinked={() => {}} />
|
||||
</MatrixProvider>
|
||||
<AeThexProvider>
|
||||
<LoginForm />
|
||||
</AeThexProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
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,30 +1,28 @@
|
|||
import React, { useEffect } from "react";
|
||||
import Message from "./Message";
|
||||
import MessageInput from "./MessageInput";
|
||||
import { useMatrix } from "../matrix/MatrixProvider.jsx";
|
||||
import { useAeThex } from "../aethex/AeThexProvider.jsx";
|
||||
import DemoLoginButton from "./DemoLoginButton.jsx";
|
||||
|
||||
// Default room to join (replace with your Matrix room ID)
|
||||
const DEFAULT_ROOM_ID = "!foundation:matrix.org";
|
||||
// Default channel to join
|
||||
const DEFAULT_CHANNEL_ID = "general";
|
||||
|
||||
export default function ChatArea() {
|
||||
const { messages, joinRoom, currentRoomId, user, login, loading } = useMatrix();
|
||||
const { messages, joinChannel, currentChannelId, user, demoLogin, loading, isAuthenticated } = useAeThex();
|
||||
|
||||
// Join the default room on login
|
||||
// Join the default channel on login
|
||||
useEffect(() => {
|
||||
if (user && !currentRoomId) {
|
||||
joinRoom(DEFAULT_ROOM_ID);
|
||||
if (isAuthenticated && user && !currentChannelId) {
|
||||
joinChannel(DEFAULT_CHANNEL_ID);
|
||||
}
|
||||
}, [user, currentRoomId, joinRoom]);
|
||||
}, [isAuthenticated, user, currentChannelId, joinChannel]);
|
||||
|
||||
// Demo login handler
|
||||
const handleDemoLogin = () => {
|
||||
// Use a public Matrix test account or a known demo account
|
||||
// You can change these credentials as needed
|
||||
login("@mrpiglr:matrix.org", "Max!FTW2023!", "https://matrix.org");
|
||||
const handleDemoLogin = async () => {
|
||||
await demoLogin();
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
if (!isAuthenticated || !user) {
|
||||
return (
|
||||
<div className="chat-area flex flex-col flex-1 bg-[#0a0a0a] items-center justify-center">
|
||||
<DemoLoginButton onDemoLogin={handleDemoLogin} />
|
||||
|
|
@ -49,7 +47,7 @@ export default function ChatArea() {
|
|||
<div className="chat-messages flex-1 overflow-y-auto px-5 py-5">
|
||||
{messages && messages.length > 0 ? (
|
||||
messages.map((msg, i) => (
|
||||
<Message key={i} {...matrixToMessageProps(msg)} />
|
||||
<Message key={msg.id || i} {...messageToProps(msg)} />
|
||||
))
|
||||
) : (
|
||||
<div className="text-gray-500 text-center">No messages yet.</div>
|
||||
|
|
@ -65,19 +63,15 @@ export default function ChatArea() {
|
|||
}
|
||||
|
||||
|
||||
// Helper to convert Matrix event to Message props
|
||||
function matrixToMessageProps(event) {
|
||||
if (!event) return {};
|
||||
if (event.type === "m.room.message") {
|
||||
return {
|
||||
type: "user",
|
||||
author: event.sender?.split(":")[0]?.replace("@", "") || "User",
|
||||
text: event.content?.body || "",
|
||||
time: new Date(event.origin_server_ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
avatar: event.sender?.charAt(1)?.toUpperCase() || "U",
|
||||
avatarBg: "from-blue-600 to-blue-900",
|
||||
};
|
||||
}
|
||||
// Add system message mapping if needed
|
||||
return { type: "system", label: "MATRIX", text: JSON.stringify(event), className: "foundation" };
|
||||
// Helper to convert AeThex message to Message props
|
||||
function messageToProps(msg) {
|
||||
if (!msg) return {};
|
||||
return {
|
||||
type: "user",
|
||||
author: msg.sender?.display_name || msg.sender?.username || "User",
|
||||
text: msg.content || "",
|
||||
time: new Date(msg.created_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
avatar: (msg.sender?.display_name || msg.sender?.username || "U").charAt(0).toUpperCase(),
|
||||
avatarBg: "from-blue-600 to-blue-900",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import React, { useState } from "react";
|
||||
import { useMatrix } from "../matrix/MatrixProvider.jsx";
|
||||
import { useAeThex } from "../aethex/AeThexProvider.jsx";
|
||||
|
||||
const DEFAULT_ROOM_ID = "!foundation:matrix.org";
|
||||
const DEFAULT_CHANNEL_ID = "general";
|
||||
|
||||
export default function MessageInput() {
|
||||
const [text, setText] = useState("");
|
||||
const { sendMessage, user, currentRoomId } = useMatrix();
|
||||
const { sendMessage, user, currentChannelId, isAuthenticated } = useAeThex();
|
||||
|
||||
const handleSend = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!text.trim() || !user) return;
|
||||
await sendMessage(currentRoomId || DEFAULT_ROOM_ID, text);
|
||||
if (!text.trim() || !user || !isAuthenticated) return;
|
||||
await sendMessage(currentChannelId || DEFAULT_CHANNEL_ID, text);
|
||||
setText("");
|
||||
};
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export default function MessageInput() {
|
|||
maxLength={2000}
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
disabled={!user}
|
||||
disabled={!user || !isAuthenticated}
|
||||
/>
|
||||
<button type="submit" className="sendButton w-10 h-10 flex items-center justify-center rounded bg-blue-600 text-xl text-white ml-2">3a4</button>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
import React, { createContext, useContext, useRef, useState, useEffect, useCallback } from "react";
|
||||
import Peer from "simple-peer";
|
||||
import { useMatrix } from "../matrix/MatrixProvider.jsx";
|
||||
import { useAeThex } from "../aethex/AeThexProvider.jsx";
|
||||
|
||||
const WebRTCContext = createContext(null);
|
||||
|
||||
|
|
@ -13,31 +13,32 @@ export function WebRTCProvider({ children }) {
|
|||
const [peers, setPeers] = useState([]); // [{ peerId, peer, stream }]
|
||||
const [localStream, setLocalStream] = useState(null);
|
||||
const [joined, setJoined] = useState(false);
|
||||
const [currentVoiceChannel, setCurrentVoiceChannel] = useState(null);
|
||||
const peersRef = useRef({});
|
||||
const matrix = useMatrix();
|
||||
if (!matrix) {
|
||||
// Optionally render a fallback or nothing if Matrix context is not available
|
||||
const aethex = useAeThex();
|
||||
|
||||
if (!aethex) {
|
||||
return null;
|
||||
}
|
||||
const { client, user, currentRoomId } = matrix;
|
||||
const SIGNAL_EVENT = "org.aethex.voice.signal";
|
||||
const VOICE_ROOM = currentRoomId || "!foundation:matrix.org";
|
||||
|
||||
const { socket, user, isAuthenticated } = aethex;
|
||||
|
||||
// Helper: Send signal via Matrix event
|
||||
// Helper: Send signal via Socket.io
|
||||
const sendSignal = useCallback((to, data) => {
|
||||
if (!client || !VOICE_ROOM) return;
|
||||
client.sendEvent(VOICE_ROOM, SIGNAL_EVENT, { to, from: user?.userId, data }, "");
|
||||
}, [client, VOICE_ROOM, user]);
|
||||
if (!socket || !currentVoiceChannel) return;
|
||||
socket.emit('voice:signal', { to, channelId: currentVoiceChannel, data });
|
||||
}, [socket, currentVoiceChannel]);
|
||||
|
||||
// Join a voice channel (start local audio, announce self)
|
||||
const joinVoice = async () => {
|
||||
if (localStream || !client || !user) return;
|
||||
const joinVoice = async (channelId) => {
|
||||
if (localStream || !socket || !user || !isAuthenticated) return;
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
setLocalStream(stream);
|
||||
setCurrentVoiceChannel(channelId);
|
||||
setJoined(true);
|
||||
// Announce self to others
|
||||
client.sendEvent(VOICE_ROOM, SIGNAL_EVENT, { join: true, from: user.userId }, "");
|
||||
socket.emit('voice:join', { channelId });
|
||||
} catch (err) {
|
||||
alert("Could not access microphone: " + err.message);
|
||||
}
|
||||
|
|
@ -53,57 +54,78 @@ export function WebRTCProvider({ children }) {
|
|||
peersRef.current = {};
|
||||
setPeers([]);
|
||||
setJoined(false);
|
||||
if (client && user) {
|
||||
client.sendEvent(VOICE_ROOM, SIGNAL_EVENT, { leave: true, from: user.userId }, "");
|
||||
if (socket && currentVoiceChannel) {
|
||||
socket.emit('voice:leave', { channelId: currentVoiceChannel });
|
||||
}
|
||||
setCurrentVoiceChannel(null);
|
||||
};
|
||||
|
||||
// Handle incoming Matrix signal events
|
||||
// Handle incoming Socket.io signal events
|
||||
useEffect(() => {
|
||||
if (!client || !user || !VOICE_ROOM) return;
|
||||
const handler = (event) => {
|
||||
if (event.getType() !== SIGNAL_EVENT) return;
|
||||
const { from, to, data, join, leave } = event.getContent();
|
||||
if (from === user.userId) return;
|
||||
// Handle join: create peer if not exists
|
||||
if (join) {
|
||||
if (!peersRef.current[from]) {
|
||||
const initiator = user.userId > from; // simple deterministic initiator
|
||||
const peer = new Peer({ initiator, trickle: false, stream: localStream });
|
||||
peer.on("signal", signal => sendSignal(from, signal));
|
||||
peer.on("stream", remoteStream => {
|
||||
setPeers(p => [...p, { peerId: from, peer, stream: remoteStream }]);
|
||||
});
|
||||
peer.on("close", () => {
|
||||
setPeers(p => p.filter(x => x.peerId !== from));
|
||||
delete peersRef.current[from];
|
||||
});
|
||||
peersRef.current[from] = { peer };
|
||||
}
|
||||
}
|
||||
// Handle leave: remove peer
|
||||
if (leave && peersRef.current[from]) {
|
||||
peersRef.current[from].peer.destroy();
|
||||
delete peersRef.current[from];
|
||||
setPeers(p => p.filter(x => x.peerId !== from));
|
||||
}
|
||||
// Handle signal: pass to peer
|
||||
if (data && peersRef.current[from]) {
|
||||
peersRef.current[from].peer.signal(data);
|
||||
if (!socket || !user || !isAuthenticated) return;
|
||||
|
||||
const handleUserJoined = ({ userId, channelId }) => {
|
||||
if (userId === user.id || channelId !== currentVoiceChannel || !localStream) return;
|
||||
|
||||
if (!peersRef.current[userId]) {
|
||||
const initiator = user.id > userId; // deterministic initiator
|
||||
const peer = new Peer({ initiator, trickle: false, stream: localStream });
|
||||
|
||||
peer.on("signal", signal => sendSignal(userId, signal));
|
||||
peer.on("stream", remoteStream => {
|
||||
setPeers(p => [...p, { peerId: userId, peer, stream: remoteStream }]);
|
||||
});
|
||||
peer.on("close", () => {
|
||||
setPeers(p => p.filter(x => x.peerId !== userId));
|
||||
delete peersRef.current[userId];
|
||||
});
|
||||
|
||||
peersRef.current[userId] = { peer };
|
||||
}
|
||||
};
|
||||
client.on("event", handler);
|
||||
return () => client.removeListener("event", handler);
|
||||
}, [client, user, VOICE_ROOM, localStream, sendSignal]);
|
||||
|
||||
// Announce self to new joiners
|
||||
useEffect(() => {
|
||||
if (!joined || !client || !user) return;
|
||||
client.sendEvent(VOICE_ROOM, SIGNAL_EVENT, { join: true, from: user.userId }, "");
|
||||
}, [joined, client, user, VOICE_ROOM]);
|
||||
|
||||
const handleUserLeft = ({ userId }) => {
|
||||
if (peersRef.current[userId]) {
|
||||
peersRef.current[userId].peer.destroy();
|
||||
delete peersRef.current[userId];
|
||||
setPeers(p => p.filter(x => x.peerId !== userId));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignal = ({ from, data }) => {
|
||||
if (peersRef.current[from]) {
|
||||
peersRef.current[from].peer.signal(data);
|
||||
} else if (localStream) {
|
||||
// Create peer for late joiners
|
||||
const peer = new Peer({ initiator: false, trickle: false, stream: localStream });
|
||||
|
||||
peer.on("signal", signal => sendSignal(from, signal));
|
||||
peer.on("stream", remoteStream => {
|
||||
setPeers(p => [...p, { peerId: from, peer, stream: remoteStream }]);
|
||||
});
|
||||
peer.on("close", () => {
|
||||
setPeers(p => p.filter(x => x.peerId !== from));
|
||||
delete peersRef.current[from];
|
||||
});
|
||||
|
||||
peer.signal(data);
|
||||
peersRef.current[from] = { peer };
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('voice:user_joined', handleUserJoined);
|
||||
socket.on('voice:user_left', handleUserLeft);
|
||||
socket.on('voice:signal', handleSignal);
|
||||
|
||||
return () => {
|
||||
socket.off('voice:user_joined', handleUserJoined);
|
||||
socket.off('voice:user_left', handleUserLeft);
|
||||
socket.off('voice:signal', handleSignal);
|
||||
};
|
||||
}, [socket, user, isAuthenticated, currentVoiceChannel, localStream, sendSignal]);
|
||||
|
||||
return (
|
||||
<WebRTCContext.Provider value={{ peers, localStream, joined, joinVoice, leaveVoice }}>
|
||||
<WebRTCContext.Provider value={{ peers, localStream, joined, joinVoice, leaveVoice, currentVoiceChannel }}>
|
||||
{children}
|
||||
</WebRTCContext.Provider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
// Removed: This page is deprecated. Use /app for the full platform UI.
|
||||
// Removed: This page is deprecated. Use /app for the full platform UI.
|
||||
|
|
|
|||
154
astro-site/src/pages/images.astro
Normal file
154
astro-site/src/pages/images.astro
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
---
|
||||
/**
|
||||
* Image Gallery Demo - Shows available Unsplash assets
|
||||
*/
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AeThex Image Assets</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
h1 { text-align: center; margin-bottom: 40px; font-size: 2.5rem; }
|
||||
h2 { margin: 30px 0 20px; color: #60a5fa; border-bottom: 1px solid #334155; padding-bottom: 10px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; max-width: 1400px; margin: 0 auto; }
|
||||
.card {
|
||||
background: rgba(30, 41, 59, 0.8);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.card img { width: 100%; height: 200px; object-fit: cover; }
|
||||
.card-body { padding: 16px; }
|
||||
.card-title { font-weight: 600; margin-bottom: 8px; }
|
||||
.card-url { font-size: 12px; color: #94a3b8; word-break: break-all; }
|
||||
.avatars { display: flex; gap: 16px; flex-wrap: wrap; justify-content: center; margin: 20px 0; }
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #60a5fa;
|
||||
background: #1e293b;
|
||||
}
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
color: #60a5fa;
|
||||
text-decoration: none;
|
||||
}
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a href="/" class="back-link">← Back to Home</a>
|
||||
<h1>🎨 AeThex Image Assets</h1>
|
||||
<p style="text-align: center; color: #94a3b8; margin-bottom: 40px;">
|
||||
Free stock images from Unsplash + DiceBear avatars
|
||||
</p>
|
||||
|
||||
<h2>📸 Preset Background Images</h2>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1538481199705-c710c4e965fc?w=600&q=80" alt="Hero" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Hero Background</div>
|
||||
<div class="card-url">Gaming setup - perfect for landing pages</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=600&q=80" alt="Login" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Login Background</div>
|
||||
<div class="card-url">Retro gaming aesthetic</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1614850523459-c2f4c699c52e?w=600&q=80" alt="Chat" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Chat Background</div>
|
||||
<div class="card-url">Dark abstract pattern</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1542751371-adc38448a05e?w=600&q=80" alt="Server" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Server Banner</div>
|
||||
<div class="card-url">Esports/competitive gaming</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1511512578047-dfb367046420?w=600&q=80" alt="Profile" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Profile Banner</div>
|
||||
<div class="card-url">Gaming aesthetic</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=600&q=80" alt="Voice" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Voice Channel</div>
|
||||
<div class="card-url">Audio waves visualization</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://images.unsplash.com/photo-1557682250-33bd709cbe85?w=600&q=80" alt="Premium" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Premium Banner</div>
|
||||
<div class="card-url">Purple gradient - perfect for upgrades</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>👤 DiceBear Avatars</h2>
|
||||
<p style="color: #94a3b8; margin-bottom: 20px;">Auto-generated based on username seed</p>
|
||||
<div class="avatars">
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/adventurer/svg?seed=player1&size=80" alt="Avatar 1" />
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/avataaars/svg?seed=gamer42&size=80" alt="Avatar 2" />
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/bottts/svg?seed=techuser&size=80" alt="Avatar 3" />
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/fun-emoji/svg?seed=happyface&size=80" alt="Avatar 4" />
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/lorelei/svg?seed=mystical&size=80" alt="Avatar 5" />
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/notionists/svg?seed=creative&size=80" alt="Avatar 6" />
|
||||
<img class="avatar" src="https://api.dicebear.com/7.x/personas/svg?seed=unique&size=80" alt="Avatar 7" />
|
||||
</div>
|
||||
|
||||
<h2>🎮 Dynamic Gaming Images</h2>
|
||||
<p style="color: #94a3b8; margin-bottom: 20px;">Random images from Unsplash (refreshes on reload)</p>
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<img src="https://source.unsplash.com/600x400/?gaming,neon" alt="Random Gaming" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Gaming + Neon</div>
|
||||
<div class="card-url">source.unsplash.com/600x400/?gaming,neon</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://source.unsplash.com/600x400/?technology,dark" alt="Random Tech" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Technology + Dark</div>
|
||||
<div class="card-url">source.unsplash.com/600x400/?technology,dark</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<img src="https://source.unsplash.com/600x400/?abstract,gradient" alt="Random Abstract" loading="lazy" />
|
||||
<div class="card-body">
|
||||
<div class="card-title">Abstract + Gradient</div>
|
||||
<div class="card-url">source.unsplash.com/600x400/?abstract,gradient</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin-top: 60px; color: #64748b;">
|
||||
<p>All images from <a href="https://unsplash.com" style="color: #60a5fa;">Unsplash</a> (free for commercial use)</p>
|
||||
<p>Avatars from <a href="https://dicebear.com" style="color: #60a5fa;">DiceBear</a> (free for commercial use)</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import SupabaseLogin from '../components/auth/SupabaseLogin.jsx';
|
||||
import LoginIsland from '../components/auth/LoginIsland.jsx';
|
||||
---
|
||||
|
||||
|
||||
<Layout title="AeThex Connect – Login">
|
||||
<SupabaseLogin client:load />
|
||||
<LoginIsland client:load />
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
|
||||
// Removed: This page is deprecated. Use /app for the full platform UI.
|
||||
|
|
@ -15,10 +15,47 @@ import './App.css';
|
|||
*/
|
||||
function DemoContent() {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const { user, loading } = useAuth();
|
||||
const { user, loading, isAuthenticated, demoLogin, error } = useAuth();
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
|
||||
// Handle demo login
|
||||
const handleDemoLogin = async () => {
|
||||
setLoginLoading(true);
|
||||
await demoLogin();
|
||||
setLoginLoading(false);
|
||||
};
|
||||
|
||||
// Show login screen when not authenticated
|
||||
if (!loading && !isAuthenticated) {
|
||||
return (
|
||||
<div className="loading-screen" style={{ flexDirection: 'column', gap: '20px' }}>
|
||||
<div className="loading-spinner">{String.fromCodePoint(0x1F680)}</div>
|
||||
<h2 style={{ color: '#fff', margin: 0 }}>AeThex Connect Demo</h2>
|
||||
<p style={{ color: '#aaa', margin: 0 }}>Sign in to explore all features</p>
|
||||
{error && <p style={{ color: '#ff6b6b' }}>{error}</p>}
|
||||
<button
|
||||
onClick={handleDemoLogin}
|
||||
disabled={loginLoading}
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #f7b42c, #fc575e)',
|
||||
border: 'none',
|
||||
padding: '12px 32px',
|
||||
borderRadius: '8px',
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px',
|
||||
cursor: loginLoading ? 'wait' : 'pointer',
|
||||
opacity: loginLoading ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
{loginLoading ? 'Loading...' : '🚀 Try Demo'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state while auth initializes
|
||||
if (loading || !user) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner">{String.fromCodePoint(0x1F680)}</div>
|
||||
|
|
@ -47,7 +84,7 @@ function DemoContent() {
|
|||
</div>
|
||||
<div className="user-section">
|
||||
<div className="user-info">
|
||||
<span className="user-name">{user.name}</span>
|
||||
<span className="user-name">{user.displayName || user.username}</span>
|
||||
<span className="user-email">{user.email}</span>
|
||||
</div>
|
||||
{user.verifiedDomain && (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.PUBLIC_SUPABASE_URL,
|
||||
import.meta.env.PUBLIC_SUPABASE_ANON_KEY
|
||||
);
|
||||
const API_URL = import.meta.env?.VITE_API_URL || 'http://localhost:3000';
|
||||
const API_BASE = `${API_URL}/api`;
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
|
|
@ -19,29 +16,164 @@ export function useAuth() {
|
|||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setTokenState] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Token management
|
||||
const setToken = useCallback((newToken) => {
|
||||
setTokenState(newToken);
|
||||
if (newToken) {
|
||||
localStorage.setItem('aethex_token', newToken);
|
||||
} else {
|
||||
localStorage.removeItem('aethex_token');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// API helper
|
||||
const apiRequest = useCallback(async (endpoint, options = {}) => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
const currentToken = token || localStorage.getItem('aethex_token');
|
||||
if (currentToken) {
|
||||
headers['Authorization'] = `Bearer ${currentToken}`;
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}, [token]);
|
||||
|
||||
// Check auth on mount
|
||||
useEffect(() => {
|
||||
const getSession = async () => {
|
||||
const checkAuth = async () => {
|
||||
setLoading(true);
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session?.user) {
|
||||
setUser(session.user);
|
||||
} else {
|
||||
setUser(null);
|
||||
const storedToken = localStorage.getItem('aethex_token');
|
||||
|
||||
if (storedToken) {
|
||||
setTokenState(storedToken);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${storedToken}`,
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (response.ok && data.success) {
|
||||
setUser(data.user);
|
||||
} else {
|
||||
// Token invalid, clear it
|
||||
localStorage.removeItem('aethex_token');
|
||||
setTokenState(null);
|
||||
setUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Auth check failed:', err);
|
||||
setUser(null);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
getSession();
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
setUser(session?.user || null);
|
||||
});
|
||||
return () => {
|
||||
listener?.subscription.unsubscribe();
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const value = { user, loading, supabase };
|
||||
// Login
|
||||
const login = useCallback(async (email, password) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
setToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
return { success: true, user: response.data.user };
|
||||
}
|
||||
return { success: false, error: response.error };
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
// Register
|
||||
const register = useCallback(async (email, password, username, displayName) => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, username, displayName }),
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
setToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
return { success: true, user: response.data.user };
|
||||
}
|
||||
return { success: false, error: response.error };
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
// Demo login
|
||||
const demoLogin = useCallback(async () => {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiRequest('/auth/demo', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (response.success && response.data) {
|
||||
setToken(response.data.token);
|
||||
setUser(response.data.user);
|
||||
return { success: true, user: response.data.user };
|
||||
}
|
||||
return { success: false, error: response.error };
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return { success: false, error: err.message };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
// Logout
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await apiRequest('/auth/logout', { method: 'POST' });
|
||||
} catch (err) {
|
||||
console.error('Logout API call failed:', err);
|
||||
}
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
}, [apiRequest, setToken]);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
register,
|
||||
demoLogin,
|
||||
logout,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
|
|
|
|||
|
|
@ -3,27 +3,31 @@
|
|||
* Provides Socket.io connection to all components
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const SocketContext = createContext(null);
|
||||
|
||||
export function SocketProvider({ children }) {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [typingUsers, setTypingUsers] = useState({});
|
||||
const { token, isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
const authToken = token || localStorage.getItem('aethex_token');
|
||||
|
||||
if (!token) {
|
||||
console.log('No auth token, skipping socket connection');
|
||||
if (!authToken || !isAuthenticated) {
|
||||
console.log('No auth, skipping socket connection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to Socket.io server
|
||||
const socketInstance = io(import.meta.env.VITE_API_URL || 'http://localhost:3000', {
|
||||
auth: {
|
||||
token: token
|
||||
token: authToken
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
|
|
@ -48,6 +52,28 @@ export function SocketProvider({ children }) {
|
|||
console.error('Socket error:', error);
|
||||
});
|
||||
|
||||
// Message events
|
||||
socketInstance.on('message', (msg) => {
|
||||
setMessages(prev => [...prev, msg]);
|
||||
});
|
||||
|
||||
socketInstance.on('user_typing', ({ conversationId, userId, username }) => {
|
||||
setTypingUsers(prev => ({
|
||||
...prev,
|
||||
[conversationId]: { ...prev[conversationId], [userId]: username }
|
||||
}));
|
||||
});
|
||||
|
||||
socketInstance.on('user_stopped_typing', ({ conversationId, userId }) => {
|
||||
setTypingUsers(prev => {
|
||||
const updated = { ...prev };
|
||||
if (updated[conversationId]) {
|
||||
delete updated[conversationId][userId];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
setSocket(socketInstance);
|
||||
|
||||
// Cleanup on unmount
|
||||
|
|
@ -56,10 +82,39 @@ export function SocketProvider({ children }) {
|
|||
socketInstance.disconnect();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, [token, isAuthenticated]);
|
||||
|
||||
// Send message
|
||||
const sendMessage = useCallback((channelId, content) => {
|
||||
if (socket && connected) {
|
||||
socket.emit('message', { channelId, content });
|
||||
}
|
||||
}, [socket, connected]);
|
||||
|
||||
// Join channel
|
||||
const joinChannel = useCallback((channelId) => {
|
||||
if (socket && connected) {
|
||||
socket.emit('join_conversation', { conversationId: channelId });
|
||||
}
|
||||
}, [socket, connected]);
|
||||
|
||||
// Send typing indicator
|
||||
const sendTyping = useCallback((channelId, isTyping) => {
|
||||
if (socket && connected) {
|
||||
socket.emit(isTyping ? 'typing_start' : 'typing_stop', { conversationId: channelId });
|
||||
}
|
||||
}, [socket, connected]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{ socket, connected }}>
|
||||
<SocketContext.Provider value={{
|
||||
socket,
|
||||
connected,
|
||||
messages,
|
||||
typingUsers,
|
||||
sendMessage,
|
||||
joinChannel,
|
||||
sendTyping
|
||||
}}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
|
|
|
|||
19
astro-site/src/react-app/utils/socket.js
vendored
19
astro-site/src/react-app/utils/socket.js
vendored
|
|
@ -1,23 +1,18 @@
|
|||
// socket.js
|
||||
// Example Socket.io client for AeThex Connect (Supabase JWT auth)
|
||||
// Socket.io client for AeThex Connect
|
||||
|
||||
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
|
||||
);
|
||||
const API_URL = import.meta.env?.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
export async function connectSocket() {
|
||||
// Get current session (JWT)
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) throw new Error('Not authenticated');
|
||||
// Get AeThex token from localStorage
|
||||
const token = localStorage.getItem('aethex_token');
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
|
||||
// Connect to signaling server
|
||||
const socket = io(import.meta.env.PUBLIC_SIGNALING_SERVER_URL, {
|
||||
auth: { token: session.access_token }
|
||||
const socket = io(API_URL, {
|
||||
auth: { token }
|
||||
});
|
||||
|
||||
// Example: handle connection
|
||||
|
|
|
|||
106
astro-site/src/utils/unsplash.js
Normal file
106
astro-site/src/utils/unsplash.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* Unsplash Image Utility
|
||||
* Provides free stock images for AeThex Connect
|
||||
* Uses Unsplash Source API (no key required for basic usage)
|
||||
*/
|
||||
|
||||
// Curated gaming/tech collection IDs from Unsplash
|
||||
const COLLECTIONS = {
|
||||
gaming: '1424240', // Gaming collection
|
||||
tech: '3582603', // Technology collection
|
||||
neon: '1459961', // Neon/cyberpunk aesthetic
|
||||
dark: '827743', // Dark moody shots
|
||||
abstract: '1065976', // Abstract patterns
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a random image URL from Unsplash
|
||||
* @param {Object} options
|
||||
* @param {number} options.width - Image width
|
||||
* @param {number} options.height - Image height
|
||||
* @param {string} options.query - Search query
|
||||
* @param {string} options.collection - Collection ID
|
||||
* @returns {string} Image URL
|
||||
*/
|
||||
export function getUnsplashImage({ width = 1920, height = 1080, query, collection } = {}) {
|
||||
const base = 'https://source.unsplash.com';
|
||||
|
||||
if (collection && COLLECTIONS[collection]) {
|
||||
return `${base}/collection/${COLLECTIONS[collection]}/${width}x${height}`;
|
||||
}
|
||||
|
||||
if (query) {
|
||||
return `${base}/${width}x${height}/?${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
return `${base}/random/${width}x${height}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a gaming-themed background
|
||||
*/
|
||||
export function getGamingBackground(width = 1920, height = 1080) {
|
||||
return getUnsplashImage({ width, height, query: 'gaming,neon,dark' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tech-themed background
|
||||
*/
|
||||
export function getTechBackground(width = 1920, height = 1080) {
|
||||
return getUnsplashImage({ width, height, query: 'technology,dark,abstract' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random avatar placeholder
|
||||
* Uses specific seeds for consistency
|
||||
*/
|
||||
export function getAvatarImage(seed, size = 200) {
|
||||
// Use DiceBear for avatars (more reliable than Unsplash for small images)
|
||||
const styles = ['adventurer', 'avataaars', 'bottts', 'fun-emoji', 'lorelei', 'notionists', 'personas'];
|
||||
const style = styles[Math.abs(hashCode(seed)) % styles.length];
|
||||
return `https://api.dicebear.com/7.x/${style}/svg?seed=${encodeURIComponent(seed)}&size=${size}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get abstract pattern for cards/backgrounds
|
||||
*/
|
||||
export function getAbstractPattern(width = 800, height = 600) {
|
||||
return getUnsplashImage({ width, height, query: 'abstract,gradient,dark' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prebuilt image URLs for specific use cases
|
||||
*/
|
||||
export const PRESET_IMAGES = {
|
||||
heroBackground: 'https://images.unsplash.com/photo-1538481199705-c710c4e965fc?w=1920&q=80', // Gaming setup
|
||||
loginBackground: 'https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=1920&q=80', // Retro gaming
|
||||
chatBackground: 'https://images.unsplash.com/photo-1614850523459-c2f4c699c52e?w=1920&q=80', // Dark abstract
|
||||
serverBanner: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=1200&q=80', // Esports
|
||||
profileBanner: 'https://images.unsplash.com/photo-1511512578047-dfb367046420?w=1200&q=80', // Gaming aesthetic
|
||||
defaultAvatar: 'https://images.unsplash.com/photo-1566577134770-3d85bb3a9cc4?w=400&q=80', // Abstract
|
||||
voiceChannel: 'https://images.unsplash.com/photo-1598488035139-bdbb2231ce04?w=800&q=80', // Audio waves
|
||||
premiumBanner: 'https://images.unsplash.com/photo-1557682250-33bd709cbe85?w=1200&q=80', // Purple gradient
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple hash function for consistent results
|
||||
*/
|
||||
function hashCode(str) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export default {
|
||||
getUnsplashImage,
|
||||
getGamingBackground,
|
||||
getTechBackground,
|
||||
getAvatarImage,
|
||||
getAbstractPattern,
|
||||
PRESET_IMAGES,
|
||||
COLLECTIONS,
|
||||
};
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"ethers": "^6.10.0",
|
||||
|
|
@ -5044,6 +5045,15 @@
|
|||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"bin": {
|
||||
"bcrypt": "bin/bcrypt"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@
|
|||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.90.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"ethers": "^6.10.0",
|
||||
|
|
|
|||
|
|
@ -1,153 +0,0 @@
|
|||
# AeThex Connect - Web PWA
|
||||
|
||||
Progressive Web App for AeThex Connect built with React, TypeScript, and Vite.
|
||||
|
||||
## Features
|
||||
|
||||
- **Real-time Messaging** - End-to-end encrypted conversations
|
||||
- **Voice & Video Calls** - WebRTC-powered calls with crystal-clear quality
|
||||
- **Offline Support** - Service worker enables offline messaging and call history
|
||||
- **Dark Gaming Theme** - Modern, dark UI optimized for long sessions
|
||||
- **Progressive Enhancement** - Works on desktop and mobile with native app-like experience
|
||||
- **Responsive Design** - Tailwind CSS for mobile-first design
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
packages/web/src/
|
||||
├── index.tsx # App entry point with PWA registration
|
||||
├── App.tsx # Main router and layout
|
||||
├── pages/
|
||||
│ ├── LoginPage.tsx # Authentication
|
||||
│ ├── HomePage.tsx # Dashboard
|
||||
│ ├── ChatPage.tsx # Messaging interface
|
||||
│ ├── CallsPage.tsx # Voice/video calls
|
||||
│ └── SettingsPage.tsx # User preferences
|
||||
├── layouts/
|
||||
│ └── MainLayout.tsx # Sidebar + header layout
|
||||
├── components/ # Reusable UI components
|
||||
├── hooks/ # Custom React hooks
|
||||
├── utils/
|
||||
│ ├── serviceWorker.ts # PWA registration
|
||||
│ └── webrtc.ts # WebRTC signaling
|
||||
└── styles/
|
||||
├── global.css # Tailwind + custom styles
|
||||
└── app.css # Component styles
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install --workspace=@aethex/web
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run dev -w @aethex/web
|
||||
```
|
||||
|
||||
Open http://localhost:5173 in your browser.
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
npm run build -w @aethex/web
|
||||
npm run preview -w @aethex/web
|
||||
```
|
||||
|
||||
## PWA Features
|
||||
|
||||
### Service Worker
|
||||
- **Cache First**: Static assets cached for instant loading
|
||||
- **Network First**: API requests fetch fresh data with cache fallback
|
||||
- **Background Sync**: Offline messages synced when connection restored
|
||||
|
||||
### Manifest
|
||||
- **Installable**: Add to homescreen on mobile devices
|
||||
- **Standalone**: Runs as full-screen app without browser UI
|
||||
- **Icons**: Adaptive icons for modern devices
|
||||
|
||||
### Offline Support
|
||||
- Browse offline messages and call history
|
||||
- Compose messages while offline (synced automatically)
|
||||
- Works without internet connection
|
||||
|
||||
## Redux State Management
|
||||
|
||||
- **Auth Slice**: User authentication, tokens, profile
|
||||
- **Messaging Slice**: Conversations, messages, read receipts
|
||||
- **Calls Slice**: Active calls, call history, voice state
|
||||
|
||||
## WebRTC Integration
|
||||
|
||||
- **Peer Connections**: Manage multiple simultaneous calls
|
||||
- **Signaling**: Socket.IO-based call negotiation
|
||||
- **Media Streams**: Audio/video track control
|
||||
- **ICE Candidates**: Automatic NAT traversal
|
||||
|
||||
## Styling
|
||||
|
||||
Uses **Tailwind CSS** with custom configuration:
|
||||
- Dark gaming theme (purple/pink accents)
|
||||
- Responsive breakpoints
|
||||
- Smooth animations and transitions
|
||||
- Custom components (@layer directives)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create `.env.local`:
|
||||
|
||||
```
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_SOCKET_URL=ws://localhost:3000
|
||||
VITE_SUPABASE_URL=your_supabase_url
|
||||
VITE_SUPABASE_KEY=your_supabase_key
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome/Edge 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Mobile browsers with Service Worker support
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
- Code splitting with React Router
|
||||
- Lazy component loading
|
||||
- Image optimization
|
||||
- CSS purging with Tailwind
|
||||
- Service worker caching strategies
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm run test -w @aethex/web
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Vercel
|
||||
```bash
|
||||
vercel deploy
|
||||
```
|
||||
|
||||
### Netlify
|
||||
```bash
|
||||
netlify deploy --prod --dir dist
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker build -t aethex-web .
|
||||
docker run -p 3000:80 aethex-web
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See main [CONTRIBUTING.md](../../CONTRIBUTING.md)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
{
|
||||
"name": "@aethex/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"lint": "eslint src --ext ts,tsx",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"react-redux": "^9.0.4",
|
||||
"socket.io-client": "^4.6.0",
|
||||
"workbox-precaching": "^7.0.0",
|
||||
"workbox-routing": "^7.0.0",
|
||||
"workbox-strategies": "^7.0.0",
|
||||
"workbox-background-sync": "^7.0.0",
|
||||
"workbox-expiration": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^0.17.4",
|
||||
"vitest": "^1.1.0",
|
||||
"typescript": "^5.3.3",
|
||||
"tailwindcss": "^3.3.7",
|
||||
"postcss": "^8.4.33",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.55.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||
"@typescript-eslint/parser": "^6.15.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#a855f7" />
|
||||
<meta name="description" content="AeThex Connect - Next-generation communication platform with blockchain identity verification" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="AeThex" />
|
||||
|
||||
<title>AeThex Connect</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="alternate icon" type="image/png" href="/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Preconnect to critical domains -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!-- PWA Support -->
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="supported-color-schemes" content="dark" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,124 +0,0 @@
|
|||
{
|
||||
"name": "AeThex Connect",
|
||||
"short_name": "AeThex",
|
||||
"description": "Next-generation communication platform with blockchain identity verification, real-time messaging, voice/video calls, and premium features",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#a855f7",
|
||||
"categories": ["communication", "productivity", "social"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshot-1.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"label": "Main chat interface"
|
||||
},
|
||||
{
|
||||
"src": "/screenshot-2.png",
|
||||
"sizes": "1280x720",
|
||||
"type": "image/png",
|
||||
"label": "Voice channel"
|
||||
}
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "media",
|
||||
"accept": ["image/*", "video/*", "audio/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New Message",
|
||||
"short_name": "Message",
|
||||
"description": "Start a new conversation",
|
||||
"url": "/new-message",
|
||||
"icons": [{ "src": "/icon-message.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Voice Channel",
|
||||
"short_name": "Voice",
|
||||
"description": "Join voice channel",
|
||||
"url": "/voice",
|
||||
"icons": [{ "src": "/icon-voice.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "Friends",
|
||||
"short_name": "Friends",
|
||||
"description": "View friends list",
|
||||
"url": "/friends",
|
||||
"icons": [{ "src": "/icon-friends.png", "sizes": "96x96" }]
|
||||
}
|
||||
],
|
||||
"protocol_handlers": [
|
||||
{
|
||||
"protocol": "web+aethex",
|
||||
"url": "/handle?url=%s"
|
||||
}
|
||||
],
|
||||
"file_handlers": [
|
||||
{
|
||||
"action": "/share",
|
||||
"accept": {
|
||||
"image/*": [".png", ".jpg", ".jpeg", ".gif", ".webp"],
|
||||
"video/*": [".mp4", ".webm"],
|
||||
"audio/*": [".mp3", ".wav", ".ogg"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import { precacheAndRoute } from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
||||
import { BackgroundSyncPlugin } from 'workbox-background-sync';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope;
|
||||
|
||||
// Precache all build assets
|
||||
precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
// API requests - Network first, cache fallback
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/'),
|
||||
new NetworkFirst({
|
||||
cacheName: 'api-cache',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 5 * 60, // 5 minutes
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Images - Cache first
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'image',
|
||||
new CacheFirst({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Fonts - Cache first
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'font',
|
||||
new CacheFirst({
|
||||
cacheName: 'fonts',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 20,
|
||||
maxAgeSeconds: 365 * 24 * 60 * 60, // 1 year
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Background sync for failed POST requests
|
||||
const bgSyncPlugin = new BackgroundSyncPlugin('message-queue', {
|
||||
maxRetentionTime: 24 * 60, // Retry for 24 hours
|
||||
});
|
||||
|
||||
registerRoute(
|
||||
({ url }) => url.pathname.startsWith('/api/messages'),
|
||||
new NetworkFirst({
|
||||
plugins: [bgSyncPlugin],
|
||||
}),
|
||||
'POST'
|
||||
);
|
||||
|
||||
// Push notifications
|
||||
self.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() || {};
|
||||
|
||||
const options: NotificationOptions = {
|
||||
body: data.body || 'You have a new message',
|
||||
icon: data.icon || '/icon-192.png',
|
||||
badge: '/badge-96.png',
|
||||
tag: data.tag || 'notification',
|
||||
data: data.data || {},
|
||||
actions: data.actions || [
|
||||
{ action: 'open', title: 'Open' },
|
||||
{ action: 'dismiss', title: 'Dismiss' },
|
||||
],
|
||||
vibrate: [200, 100, 200],
|
||||
requireInteraction: data.requireInteraction || false,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'AeThex Connect', options)
|
||||
);
|
||||
});
|
||||
|
||||
// Notification click handler
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'open' || !event.action) {
|
||||
const urlToOpen = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
// Check if there's already a window open
|
||||
for (const client of clientList) {
|
||||
if (client.url === urlToOpen && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
// Open new window if none exists
|
||||
if (self.clients.openWindow) {
|
||||
return self.clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle notification actions (reply, etc.)
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
if (event.action === 'reply' && event.reply) {
|
||||
// Send reply via background sync
|
||||
event.waitUntil(
|
||||
fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: event.notification.data.conversationId,
|
||||
content: event.reply,
|
||||
contentType: 'text',
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Periodic background sync (for checking new messages when offline)
|
||||
self.addEventListener('periodicsync', (event: any) => {
|
||||
if (event.tag === 'check-messages') {
|
||||
event.waitUntil(checkForNewMessages());
|
||||
}
|
||||
});
|
||||
|
||||
async function checkForNewMessages() {
|
||||
try {
|
||||
const response = await fetch('/api/messages/unread');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.count > 0) {
|
||||
self.registration.showNotification('New Messages', {
|
||||
body: `You have ${data.count} unread messages`,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/badge-96.png',
|
||||
tag: 'unread-messages',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking messages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip waiting and claim clients immediately
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// Log service worker version
|
||||
console.log('AeThex Connect Service Worker v1.0.0');
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
const CACHE_NAME = 'aethex-connect-v1';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
console.log('Caching app shell');
|
||||
return cache.addAll(ASSETS_TO_CACHE).catch(err => {
|
||||
console.log('Cache addAll error:', err);
|
||||
// Don't fail installation if some assets can't be cached
|
||||
});
|
||||
})
|
||||
);
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.map((cacheName) => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
console.log('Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - Network first, fallback to cache
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first
|
||||
if (request.url.includes('/api/')) {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (!response || response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(request);
|
||||
})
|
||||
);
|
||||
} else {
|
||||
// Static assets - cache first
|
||||
event.respondWith(
|
||||
caches.match(request).then((response) => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
return fetch(request).then((response) => {
|
||||
if (!response || response.status !== 200 || response.type === 'error') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(request, responseClone);
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Background sync for offline messages
|
||||
self.addEventListener('sync', (event) => {
|
||||
if (event.tag === 'sync-messages') {
|
||||
event.waitUntil(syncMessages());
|
||||
}
|
||||
});
|
||||
|
||||
async function syncMessages() {
|
||||
try {
|
||||
const db = await openIndexedDB();
|
||||
const messages = await getPendingMessages(db);
|
||||
|
||||
for (const message of messages) {
|
||||
try {
|
||||
await fetch('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
});
|
||||
await deletePendingMessage(db, message.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to sync message:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Sync error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function openIndexedDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('aethex-connect', 1);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingMessages(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['pendingMessages'], 'readonly');
|
||||
const store = transaction.objectStore('pendingMessages');
|
||||
const request = store.getAll();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePendingMessage(db, id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(['pendingMessages'], 'readwrite');
|
||||
const store = transaction.objectStore('pendingMessages');
|
||||
const request = store.delete(id);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAppSelector } from './store';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import HomePage from './pages/HomePage';
|
||||
import ChatPage from './pages/ChatPage';
|
||||
import CallsPage from './pages/CallsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import './styles/app.css';
|
||||
|
||||
export default function App() {
|
||||
const { user, loading } = useAppSelector(state => state.auth);
|
||||
|
||||
useEffect(() => {
|
||||
// Restore auth state on app load
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token && !user) {
|
||||
// Token exists but user not loaded - this would be handled by Redux persist
|
||||
console.log('Auth token found, waiting for hydration...');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-900">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-purple-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
user ? (
|
||||
<MainLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/chat/*" element={<ChatPage />} />
|
||||
<Route path="/calls/*" element={<CallsPage />} />
|
||||
<Route path="/settings/*" element={<SettingsPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import App from './App';
|
||||
import { store } from './store';
|
||||
import './styles/global.css';
|
||||
import { registerServiceWorker } from './utils/serviceWorker';
|
||||
|
||||
// Register service worker for PWA capabilities
|
||||
registerServiceWorker();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-900">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 bg-gray-800 border-r border-gray-700 overflow-y-auto">
|
||||
<nav className="p-4 space-y-2">
|
||||
<a
|
||||
href="/"
|
||||
className="flex items-center px-4 py-2 rounded-lg text-gray-100 hover:bg-gray-700 transition"
|
||||
>
|
||||
<span className="text-xl mr-3">🏠</span>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a
|
||||
href="/chat"
|
||||
className="flex items-center px-4 py-2 rounded-lg text-gray-100 hover:bg-gray-700 transition"
|
||||
>
|
||||
<span className="text-xl mr-3">💬</span>
|
||||
<span>Messages</span>
|
||||
</a>
|
||||
<a
|
||||
href="/calls"
|
||||
className="flex items-center px-4 py-2 rounded-lg text-gray-100 hover:bg-gray-700 transition"
|
||||
>
|
||||
<span className="text-xl mr-3">📞</span>
|
||||
<span>Calls</span>
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
className="flex items-center px-4 py-2 rounded-lg text-gray-100 hover:bg-gray-700 transition"
|
||||
>
|
||||
<span className="text-xl mr-3">⚙️</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="bg-gray-800 border-b border-gray-700 px-6 py-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-xl font-bold text-white">AeThex Connect</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-white">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10h.01M9 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-auto bg-gray-900">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useAppSelector } from '../store';
|
||||
|
||||
export default function CallsPage() {
|
||||
const { activeCall, callHistory } = useAppSelector(state => state.calls);
|
||||
const [selectedCallHistory, setSelectedCallHistory] = useState(0);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-6xl mx-auto">
|
||||
{activeCall ? (
|
||||
// Active Call View
|
||||
<div className="bg-gray-800 rounded-lg p-8 text-center">
|
||||
<div className="mb-8">
|
||||
<div className="w-24 h-24 mx-auto mb-4 bg-gradient-to-br from-purple-400 to-pink-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-4xl">👤</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-white mb-2">{activeCall.participantName}</h2>
|
||||
<p className="text-gray-400">Call in progress</p>
|
||||
<p className="text-2xl font-mono text-purple-400 mt-4">{activeCall.duration}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center gap-4 mb-8">
|
||||
<button
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center transition ${
|
||||
activeCall.isMuted ? 'bg-red-600 hover:bg-red-700' : 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`w-16 h-16 rounded-full flex items-center justify-center transition ${
|
||||
activeCall.isCameraOn ? 'bg-gray-700 hover:bg-gray-600' : 'bg-red-600 hover:bg-red-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="w-16 h-16 rounded-full bg-red-600 hover:bg-red-700 flex items-center justify-center transition">
|
||||
<svg className="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M16.5 1h-9C6.12 1 5 2.12 5 3.5v17C5 21.88 6.12 23 7.5 23h9c1.38 0 2.5-1.12 2.5-2.5v-17C19 2.12 17.88 1 16.5 1zm-4 21c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm4.5-4H7V4h9v14z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Call History
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Calls</h1>
|
||||
|
||||
{callHistory.length === 0 ? (
|
||||
<div className="bg-gray-800 rounded-lg p-12 text-center">
|
||||
<p className="text-gray-400 text-lg">No call history yet. Start a call to get begun!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{callHistory.map((call, index) => (
|
||||
<div key={index} className="bg-gray-800 rounded-lg p-4 flex items-center justify-between hover:bg-gray-700 transition">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-purple-400 to-pink-600 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg">👤</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-white font-semibold">{call.participantName}</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{call.type === 'voice' ? '📞 Voice Call' : '📹 Video Call'} • {call.duration}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<p className="text-gray-400 text-sm">
|
||||
{new Date(call.timestamp).toLocaleDateString()}
|
||||
</p>
|
||||
<button className="text-purple-400 hover:text-purple-300 transition">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useAppSelector } from '../store';
|
||||
|
||||
export default function ChatPage() {
|
||||
const { conversations, messages } = useAppSelector(state => state.messaging);
|
||||
const [selectedConversation, setSelectedConversation] = useState(conversations[0]?.id);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
const currentMessages = messages[selectedConversation] || [];
|
||||
|
||||
const handleSendMessage = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
console.log('Sending message:', inputValue);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-gray-900">
|
||||
{/* Conversations List */}
|
||||
<div className="w-64 bg-gray-800 border-r border-gray-700 flex flex-col">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Messages</h3>
|
||||
<button className="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition">
|
||||
+ New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{conversations.map((conversation) => (
|
||||
<button
|
||||
key={conversation.id}
|
||||
onClick={() => setSelectedConversation(conversation.id)}
|
||||
className={`w-full px-4 py-3 text-left border-l-4 transition ${
|
||||
selectedConversation === conversation.id
|
||||
? 'bg-gray-700 border-purple-500'
|
||||
: 'border-transparent hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-white text-sm">{conversation.participantName}</div>
|
||||
<div className="text-gray-400 text-xs truncate">{conversation.lastMessage}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedConversation ? (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="bg-gray-800 border-b border-gray-700 px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{conversations.find(c => c.id === selectedConversation)?.participantName}
|
||||
</h2>
|
||||
<div className="flex gap-4">
|
||||
<button className="text-gray-400 hover:text-white transition">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-white transition">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{currentMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<p>No messages yet. Start the conversation!</p>
|
||||
</div>
|
||||
) : (
|
||||
currentMessages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.senderId === 'self' ? 'justify-end' : 'justify-start'}`}>
|
||||
<div
|
||||
className={`max-w-xs px-4 py-2 rounded-lg ${
|
||||
msg.senderId === 'self'
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-700 text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<p>{msg.content}</p>
|
||||
<p className="text-xs opacity-70 mt-1">{new Date(msg.createdAt).toLocaleTimeString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={handleSendMessage} className="bg-gray-800 border-t border-gray-700 p-4">
|
||||
<div className="flex gap-4">
|
||||
<button type="button" className="text-gray-400 hover:text-white transition">
|
||||
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
className="flex-1 px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<p>Select a conversation to start messaging</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-4xl font-bold text-white mb-6">Welcome to AeThex Connect</h1>
|
||||
<p className="text-gray-300 text-lg mb-8">
|
||||
Your next-generation communication platform with blockchain identity verification,
|
||||
real-time messaging, voice/video calls, and premium features.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Feature Cards */}
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-purple-500 transition">
|
||||
<div className="text-3xl mb-4">💬</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Instant Messaging</h3>
|
||||
<p className="text-gray-400">
|
||||
End-to-end encrypted messages with real-time synchronization across all devices.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-purple-500 transition">
|
||||
<div className="text-3xl mb-4">📞</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Voice & Video</h3>
|
||||
<p className="text-gray-400">
|
||||
Crystal-clear voice calls and HD video conferencing with WebRTC technology.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-purple-500 transition">
|
||||
<div className="text-3xl mb-4">🎮</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">GameForge Integration</h3>
|
||||
<p className="text-gray-400">
|
||||
Connect with game communities and manage channels with GameForge.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-purple-500 transition">
|
||||
<div className="text-3xl mb-4">🔐</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Verified Identity</h3>
|
||||
<p className="text-gray-400">
|
||||
Blockchain-backed domain verification for authentic user identification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 p-8 bg-gradient-to-r from-purple-900 to-pink-900 rounded-lg">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Get Started Now</h2>
|
||||
<p className="text-gray-200 mb-6">
|
||||
Explore your messages, start a call, or customize your settings to unlock the full potential of AeThex Connect.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<button className="px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition">
|
||||
Start Messaging
|
||||
</button>
|
||||
<button className="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white font-semibold rounded-lg transition">
|
||||
Schedule a Call
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector, loginAsync } from '../store';
|
||||
|
||||
export default function LoginPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { loading, error } = useAppSelector(state => state.auth);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isSignUp) {
|
||||
// Sign up logic would go here
|
||||
console.log('Sign up:', email, password);
|
||||
} else {
|
||||
// Login
|
||||
dispatch(loginAsync({ email, password }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900 to-gray-900 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo/Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-600 bg-clip-text text-transparent mb-2">
|
||||
AeThex Connect
|
||||
</h1>
|
||||
<p className="text-gray-400">Next-generation communication platform</p>
|
||||
</div>
|
||||
|
||||
{/* Form Card */}
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-8 shadow-2xl">
|
||||
<h2 className="text-2xl font-bold text-white mb-6">
|
||||
{isSignUp ? 'Create Account' : 'Welcome Back'}
|
||||
</h2>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-900 border border-red-700 rounded text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white font-semibold rounded-lg hover:from-purple-700 hover:to-pink-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="animate-spin rounded-full h-4 w-4 border-t-2 border-white mr-2"></span>
|
||||
{isSignUp ? 'Creating Account...' : 'Signing In...'}
|
||||
</>
|
||||
) : isSignUp ? (
|
||||
'Create Account'
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSignUp(!isSignUp);
|
||||
}}
|
||||
className="w-full text-center text-gray-400 hover:text-gray-300 transition"
|
||||
>
|
||||
{isSignUp ? 'Already have an account? Sign in' : "Don't have an account? Sign up"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-2">Demo Credentials:</p>
|
||||
<p className="text-xs text-gray-500">Email: demo@aethex.dev</p>
|
||||
<p className="text-xs text-gray-500">Password: demo123</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-gray-500 text-sm mt-8">
|
||||
© 2026 AeThex Corporation. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useAppDispatch, useAppSelector, logoutAsync } from '../store';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const dispatch = useAppDispatch();
|
||||
const { user } = useAppSelector(state => state.auth);
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logoutAsync());
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', label: 'Profile', icon: '👤' },
|
||||
{ id: 'privacy', label: 'Privacy & Security', icon: '🔒' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: '🔔' },
|
||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||
{ id: 'about', label: 'About', icon: 'ℹ️' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Settings</h1>
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-48">
|
||||
<nav className="space-y-2">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg transition ${
|
||||
activeTab === tab.id
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-2">{tab.icon}</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1">
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Profile Settings</h2>
|
||||
<div className="bg-gray-800 rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Display Name</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={user?.email || ''}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
defaultValue={user?.email || ''}
|
||||
disabled
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-400 cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">Bio</label>
|
||||
<textarea
|
||||
placeholder="Tell us about yourself..."
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:border-purple-500"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<button className="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'privacy' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Privacy & Security</h2>
|
||||
<div className="bg-gray-800 rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">Two-Factor Authentication</h3>
|
||||
<p className="text-gray-400 text-sm">Add an extra layer of security to your account</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition">
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
<hr className="border-gray-700" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">E2E Encryption</h3>
|
||||
<p className="text-gray-400 text-sm">Your messages are end-to-end encrypted</p>
|
||||
</div>
|
||||
<span className="text-green-400">✓ Enabled</span>
|
||||
</div>
|
||||
<hr className="border-gray-700" />
|
||||
<div>
|
||||
<button className="text-red-400 hover:text-red-300 transition font-semibold">
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Notification Preferences</h2>
|
||||
<div className="bg-gray-800 rounded-lg p-6 space-y-4">
|
||||
{['Messages', 'Calls', 'Friend Requests', 'Community Updates'].map((item) => (
|
||||
<div key={item} className="flex items-center justify-between">
|
||||
<span className="text-gray-300">{item}</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" defaultChecked className="sr-only peer" />
|
||||
<div className="w-11 h-6 bg-gray-700 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-800 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Appearance</h2>
|
||||
<div className="bg-gray-800 rounded-lg p-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold mb-4">Theme</h3>
|
||||
<div className="space-y-2">
|
||||
{['Dark', 'Light', 'Auto'].map((theme) => (
|
||||
<label key={theme} className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="theme"
|
||||
defaultChecked={theme === 'Dark'}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-gray-300">{theme}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'about' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">About AeThex Connect</h2>
|
||||
<div className="bg-gray-800 rounded-lg p-6 space-y-4 text-gray-300">
|
||||
<div>
|
||||
<p className="font-semibold text-white mb-2">Version</p>
|
||||
<p>1.0.0</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white mb-2">Build Date</p>
|
||||
<p>February 3, 2026</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-white mb-2">Features</p>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>✓ Real-time messaging with E2E encryption</li>
|
||||
<li>✓ WebRTC voice and video calls</li>
|
||||
<li>✓ Blockchain domain verification</li>
|
||||
<li>✓ GameForge community integration</li>
|
||||
<li>✓ Premium subscriptions</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logout Button at Bottom */}
|
||||
<div className="mt-8 pt-8 border-t border-gray-700">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="px-6 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Auth Slice
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialAuthState: AuthState = {
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState: initialAuthState,
|
||||
reducers: {
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setUser: (state, action: PayloadAction<User | null>) => {
|
||||
state.user = action.payload;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string>) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.user = null;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setLoading, setUser, setError, logout } = authSlice.actions;
|
||||
|
||||
// Messaging Slice
|
||||
interface Conversation {
|
||||
id: string;
|
||||
participantName: string;
|
||||
lastMessage: string;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
content: string;
|
||||
senderId: string;
|
||||
timestamp: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface MessagingState {
|
||||
conversations: Conversation[];
|
||||
messages: Record<string, Message[]>;
|
||||
}
|
||||
|
||||
const initialMessagingState: MessagingState = {
|
||||
conversations: [],
|
||||
messages: {},
|
||||
};
|
||||
|
||||
const messagingSlice = createSlice({
|
||||
name: 'messaging',
|
||||
initialState: initialMessagingState,
|
||||
reducers: {
|
||||
setConversations: (state, action: PayloadAction<Conversation[]>) => {
|
||||
state.conversations = action.payload;
|
||||
},
|
||||
addMessage: (state, action: PayloadAction<Message>) => {
|
||||
const msg = action.payload;
|
||||
if (!state.messages[msg.conversationId]) {
|
||||
state.messages[msg.conversationId] = [];
|
||||
}
|
||||
state.messages[msg.conversationId].push(msg);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setConversations, addMessage } = messagingSlice.actions;
|
||||
|
||||
// Calls Slice
|
||||
interface Call {
|
||||
id: string;
|
||||
participantName: string;
|
||||
type: 'audio' | 'video' | 'voice';
|
||||
status: 'active' | 'ended' | 'missed';
|
||||
duration?: string;
|
||||
timestamp: string;
|
||||
isMuted?: boolean;
|
||||
isCameraOn?: boolean;
|
||||
}
|
||||
|
||||
interface CallsState {
|
||||
activeCall: Call | null;
|
||||
callHistory: Call[];
|
||||
}
|
||||
|
||||
const initialCallsState: CallsState = {
|
||||
activeCall: null,
|
||||
callHistory: [],
|
||||
};
|
||||
|
||||
const callsSlice = createSlice({
|
||||
name: 'calls',
|
||||
initialState: initialCallsState,
|
||||
reducers: {
|
||||
setActiveCall: (state, action: PayloadAction<Call | null>) => {
|
||||
state.activeCall = action.payload;
|
||||
},
|
||||
addCallToHistory: (state, action: PayloadAction<Call>) => {
|
||||
state.callHistory.unshift(action.payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setActiveCall, addCallToHistory } = callsSlice.actions;
|
||||
|
||||
// Async thunks
|
||||
export const loginAsync = (credentials: { email: string; password: string }) => async (dispatch: AppDispatch) => {
|
||||
dispatch(setLoading(true));
|
||||
try {
|
||||
// TODO: Integrate with Supabase auth
|
||||
const mockUser: User = {
|
||||
id: '1',
|
||||
email: credentials.email,
|
||||
username: credentials.email.split('@')[0],
|
||||
};
|
||||
dispatch(setUser(mockUser));
|
||||
} catch (error) {
|
||||
dispatch(setError((error as Error).message));
|
||||
}
|
||||
};
|
||||
|
||||
export const logoutAsync = () => async (dispatch: AppDispatch) => {
|
||||
dispatch(setLoading(true));
|
||||
try {
|
||||
// TODO: Integrate with Supabase auth
|
||||
dispatch(logout());
|
||||
} catch (error) {
|
||||
dispatch(setError((error as Error).message));
|
||||
}
|
||||
};
|
||||
|
||||
// Store
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authSlice.reducer,
|
||||
messaging: messagingSlice.reducer,
|
||||
calls: callsSlice.reducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
a, button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
input, textarea, select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Remove default button styling */
|
||||
button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Links */
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Ensure images are responsive */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--color-bg-primary: #0a0a0f;
|
||||
--color-bg-secondary: #1a1a2e;
|
||||
--color-bg-tertiary: #2d2d44;
|
||||
--color-border: #404060;
|
||||
--color-text-primary: #ffffff;
|
||||
--color-text-secondary: #a0a0b0;
|
||||
--color-accent-primary: #a855f7;
|
||||
--color-accent-secondary: #ec4899;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply selection:bg-purple-600 selection:text-white;
|
||||
}
|
||||
|
||||
html, body {
|
||||
@apply bg-gray-900 text-white;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white font-semibold rounded-lg transition disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white font-semibold rounded-lg transition;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-gray-800 border border-gray-700 rounded-lg p-6;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:border-purple-500 transition;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.float {
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Loading Spinner */
|
||||
.spinner {
|
||||
border: 3px solid rgba(168, 85, 247, 0.1);
|
||||
border-top: 3px solid #a855f7;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #4b5563;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a6b7a;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
export function registerServiceWorker() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('ServiceWorker registered:', registration);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('ServiceWorker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function requestNotificationPermission() {
|
||||
if ('Notification' in window) {
|
||||
if (Notification.permission === 'granted') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (Notification.permission !== 'denied') {
|
||||
return Notification.requestPermission();
|
||||
}
|
||||
}
|
||||
return Promise.reject('Notifications not supported');
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { Socket } from 'socket.io-client';
|
||||
|
||||
export class WebRTCService {
|
||||
private socket: Socket | null = null;
|
||||
private peerConnections: Map<string, RTCPeerConnection> = new Map();
|
||||
|
||||
constructor(socket: Socket) {
|
||||
this.socket = socket;
|
||||
this.setupSocketListeners();
|
||||
}
|
||||
|
||||
private setupSocketListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
this.socket.on('offer', (data: any) => {
|
||||
console.log('Received offer from:', data.from);
|
||||
this.handleOffer(data);
|
||||
});
|
||||
|
||||
this.socket.on('answer', (data: any) => {
|
||||
console.log('Received answer from:', data.from);
|
||||
this.handleAnswer(data);
|
||||
});
|
||||
|
||||
this.socket.on('ice-candidate', (data: any) => {
|
||||
console.log('Received ICE candidate from:', data.from);
|
||||
this.handleIceCandidate(data);
|
||||
});
|
||||
}
|
||||
|
||||
async startCall(recipientId: string): Promise<void> {
|
||||
try {
|
||||
const peerConnection = this.createPeerConnection(recipientId);
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
|
||||
stream.getTracks().forEach((track) => {
|
||||
peerConnection.addTrack(track, stream);
|
||||
});
|
||||
|
||||
const offer = await peerConnection.createOffer();
|
||||
await peerConnection.setLocalDescription(offer);
|
||||
|
||||
this.socket?.emit('offer', {
|
||||
to: recipientId,
|
||||
offer: offer,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error starting call:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private createPeerConnection(peerId: string): RTCPeerConnection {
|
||||
if (this.peerConnections.has(peerId)) {
|
||||
return this.peerConnections.get(peerId)!;
|
||||
}
|
||||
|
||||
const iceServers = [
|
||||
{ urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'] },
|
||||
];
|
||||
|
||||
const peerConnection = new RTCPeerConnection({
|
||||
iceServers,
|
||||
});
|
||||
|
||||
peerConnection.onicecandidate = (event) => {
|
||||
if (event.candidate) {
|
||||
this.socket?.emit('ice-candidate', {
|
||||
to: peerId,
|
||||
candidate: event.candidate,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
peerConnection.ontrack = (event) => {
|
||||
console.log('Received remote track:', event.track);
|
||||
};
|
||||
|
||||
this.peerConnections.set(peerId, peerConnection);
|
||||
return peerConnection;
|
||||
}
|
||||
|
||||
private async handleOffer(data: any) {
|
||||
const peerConnection = this.createPeerConnection(data.from);
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||
|
||||
const answer = await peerConnection.createAnswer();
|
||||
await peerConnection.setLocalDescription(answer);
|
||||
|
||||
this.socket?.emit('answer', {
|
||||
to: data.from,
|
||||
answer: answer,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleAnswer(data: any) {
|
||||
const peerConnection = this.peerConnections.get(data.from);
|
||||
if (peerConnection) {
|
||||
await peerConnection.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIceCandidate(data: any) {
|
||||
const peerConnection = this.peerConnections.get(data.from);
|
||||
if (peerConnection && data.candidate) {
|
||||
await peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
}
|
||||
}
|
||||
|
||||
endCall(peerId: string): void {
|
||||
const peerConnection = this.peerConnections.get(peerId);
|
||||
if (peerConnection) {
|
||||
peerConnection.close();
|
||||
this.peerConnections.delete(peerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./public/index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#0f172a',
|
||||
},
|
||||
purple: {
|
||||
50: '#f9f5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7e22ce',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
},
|
||||
pink: {
|
||||
600: '#ec4899',
|
||||
700: '#be185d',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-10px)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
float: 'float 3s ease-in-out infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
clientsClaim: true,
|
||||
skipWaiting: true,
|
||||
cleanupOutdatedCaches: true,
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^https:\/\/api\.aethex\.dev\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
name: 'AeThex Connect',
|
||||
short_name: 'AeThex',
|
||||
description: 'Next-generation communication platform',
|
||||
theme_color: '#a855f7',
|
||||
background_color: '#0a0a0f',
|
||||
display: 'standalone',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
orientation: 'portrait-primary',
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
navigateFallback: 'index.html',
|
||||
suppressWarnings: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2020',
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
},
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vendor-core': ['react', 'react-dom', 'react-router-dom'],
|
||||
'vendor-state': ['@reduxjs/toolkit', 'react-redux'],
|
||||
'vendor-webrtc': ['socket.io-client'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
362
src/backend/routes/authRoutes.js
Normal file
362
src/backend/routes/authRoutes.js
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* Authentication Routes
|
||||
* Handles user registration, login, and session management
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const db = require('../database/db');
|
||||
const { authenticateUser } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Register a new user
|
||||
*/
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, username, displayName } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Password must be at least 8 characters'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await db.query(
|
||||
'SELECT id FROM users WHERE email = $1',
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'An account with this email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username is taken (if provided)
|
||||
if (username) {
|
||||
const existingUsername = await db.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[username.toLowerCase()]
|
||||
);
|
||||
|
||||
if (existingUsername.rows.length > 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'This username is already taken'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
const passwordHash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Create user
|
||||
const result = await db.query(
|
||||
`INSERT INTO users (email, password_hash, username, display_name, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING id, email, username, display_name, created_at`,
|
||||
[email.toLowerCase(), passwordHash, username?.toLowerCase() || null, displayName || username || email.split('@')[0]]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name
|
||||
},
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create account'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Login with email and password
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Find user
|
||||
const result = await db.query(
|
||||
`SELECT id, email, password_hash, username, display_name, verified_domain, avatar_url, is_premium
|
||||
FROM users WHERE email = $1`,
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
// Update last login
|
||||
await db.query(
|
||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
verifiedDomain: user.verified_domain,
|
||||
avatarUrl: user.avatar_url,
|
||||
isPremium: user.is_premium
|
||||
},
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Login failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/demo
|
||||
* Demo login - creates or returns a demo user
|
||||
*/
|
||||
router.post('/demo', async (req, res) => {
|
||||
try {
|
||||
const demoEmail = 'demo@aethex.dev';
|
||||
|
||||
// Check if demo user exists
|
||||
let result = await db.query(
|
||||
`SELECT id, email, username, display_name, verified_domain, avatar_url, is_premium
|
||||
FROM users WHERE email = $1`,
|
||||
[demoEmail]
|
||||
);
|
||||
|
||||
let user;
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// Create demo user
|
||||
const salt = await bcrypt.genSalt(12);
|
||||
const passwordHash = await bcrypt.hash('demo123456', salt);
|
||||
|
||||
result = await db.query(
|
||||
`INSERT INTO users (email, password_hash, username, display_name, verified_domain, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
||||
RETURNING id, email, username, display_name, verified_domain, avatar_url, is_premium`,
|
||||
[demoEmail, passwordHash, 'demo', 'Demo User', 'demo.aethex']
|
||||
);
|
||||
}
|
||||
|
||||
user = result.rows[0];
|
||||
|
||||
// Generate JWT
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
verifiedDomain: user.verified_domain,
|
||||
avatarUrl: user.avatar_url,
|
||||
isPremium: user.is_premium
|
||||
},
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Demo login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Demo login failed'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Get current user info
|
||||
*/
|
||||
router.get('/me', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const result = await db.query(
|
||||
`SELECT id, email, username, display_name, verified_domain, avatar_url, is_premium, created_at
|
||||
FROM users WHERE id = $1`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
verifiedDomain: user.verified_domain,
|
||||
avatarUrl: user.avatar_url,
|
||||
isPremium: user.is_premium,
|
||||
createdAt: user.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get user info'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/auth/profile
|
||||
* Update user profile
|
||||
*/
|
||||
router.put('/profile', authenticateUser, async (req, res) => {
|
||||
try {
|
||||
const { displayName, username, avatarUrl } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if username is taken
|
||||
if (username) {
|
||||
const existingUsername = await db.query(
|
||||
'SELECT id FROM users WHERE username = $1 AND id != $2',
|
||||
[username.toLowerCase(), userId]
|
||||
);
|
||||
|
||||
if (existingUsername.rows.length > 0) {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
error: 'This username is already taken'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await db.query(
|
||||
`UPDATE users
|
||||
SET display_name = COALESCE($1, display_name),
|
||||
username = COALESCE($2, username),
|
||||
avatar_url = COALESCE($3, avatar_url),
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id, email, username, display_name, verified_domain, avatar_url, is_premium`,
|
||||
[displayName, username?.toLowerCase(), avatarUrl, userId]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
verifiedDomain: user.verified_domain,
|
||||
avatarUrl: user.avatar_url,
|
||||
isPremium: user.is_premium
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update profile'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Logout (client-side token removal, but we can track it)
|
||||
*/
|
||||
router.post('/logout', authenticateUser, (req, res) => {
|
||||
// In a more complex system, you'd invalidate the token here
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -5,6 +5,7 @@ const helmet = require('helmet');
|
|||
const rateLimit = require('express-rate-limit');
|
||||
require('dotenv').config();
|
||||
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const domainRoutes = require('./routes/domainRoutes');
|
||||
const messagingRoutes = require('./routes/messagingRoutes');
|
||||
const gameforgeRoutes = require('./routes/gameforgeRoutes');
|
||||
|
|
@ -52,6 +53,7 @@ const frontendPath = path.join(__dirname, '../frontend/dist');
|
|||
app.use(express.static(frontendPath));
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/passport/domain', domainRoutes);
|
||||
app.use('/api/messaging', messagingRoutes);
|
||||
app.use('/api/gameforge', gameforgeRoutes);
|
||||
|
|
|
|||
|
|
@ -160,6 +160,34 @@ class SocketService {
|
|||
}
|
||||
});
|
||||
|
||||
// Voice channel events
|
||||
socket.on('voice:join', async (data) => {
|
||||
try {
|
||||
await this.handleVoiceJoin(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling voice:join:', error);
|
||||
socket.emit('error', { event: 'voice:join', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('voice:leave', async (data) => {
|
||||
try {
|
||||
await this.handleVoiceLeave(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling voice:leave:', error);
|
||||
socket.emit('error', { event: 'voice:leave', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('voice:signal', async (data) => {
|
||||
try {
|
||||
await this.handleVoiceSignal(socket, data);
|
||||
} catch (error) {
|
||||
console.error('Error handling voice:signal:', error);
|
||||
socket.emit('error', { event: 'voice:signal', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Disconnect handler
|
||||
socket.on('disconnect', async () => {
|
||||
try {
|
||||
|
|
@ -247,12 +275,82 @@ class SocketService {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice channel join
|
||||
*/
|
||||
handleVoiceJoin(socket, data) {
|
||||
const { channelId } = data;
|
||||
const voiceRoom = `voice:${channelId}`;
|
||||
|
||||
// Join the voice room
|
||||
socket.join(voiceRoom);
|
||||
|
||||
// Store the voice channel on the socket
|
||||
socket.voiceChannel = channelId;
|
||||
|
||||
console.log(`User ${socket.user.username} joined voice channel ${channelId}`);
|
||||
|
||||
// Notify others in the channel
|
||||
socket.to(voiceRoom).emit('voice:user_joined', {
|
||||
userId: socket.user.id,
|
||||
username: socket.user.username,
|
||||
channelId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice channel leave
|
||||
*/
|
||||
handleVoiceLeave(socket, data) {
|
||||
const { channelId } = data;
|
||||
const voiceRoom = `voice:${channelId}`;
|
||||
|
||||
// Leave the voice room
|
||||
socket.leave(voiceRoom);
|
||||
|
||||
// Clear the voice channel from socket
|
||||
socket.voiceChannel = null;
|
||||
|
||||
console.log(`User ${socket.user.username} left voice channel ${channelId}`);
|
||||
|
||||
// Notify others in the channel
|
||||
socket.to(voiceRoom).emit('voice:user_left', {
|
||||
userId: socket.user.id,
|
||||
username: socket.user.username,
|
||||
channelId
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle voice signaling (WebRTC)
|
||||
*/
|
||||
handleVoiceSignal(socket, data) {
|
||||
const { to, channelId, data: signalData } = data;
|
||||
|
||||
// Send signal to specific user
|
||||
this.io.to(`user:${to}`).emit('voice:signal', {
|
||||
from: socket.user.id,
|
||||
channelId,
|
||||
data: signalData
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnect
|
||||
*/
|
||||
handleDisconnect(socket, userId) {
|
||||
console.log(`User disconnected: ${socket.user.username} (${userId})`);
|
||||
|
||||
// If user was in a voice channel, notify others
|
||||
if (socket.voiceChannel) {
|
||||
const voiceRoom = `voice:${socket.voiceChannel}`;
|
||||
socket.to(voiceRoom).emit('voice:user_left', {
|
||||
userId: socket.user.id,
|
||||
username: socket.user.username,
|
||||
channelId: socket.voiceChannel
|
||||
});
|
||||
}
|
||||
|
||||
// Remove socket from tracking
|
||||
if (this.userSockets.has(userId)) {
|
||||
this.userSockets.get(userId).delete(socket.id);
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { MessagingProvider } from './contexts/MessagingContext';
|
||||
import MainLayout from './mockup/MainLayout';
|
||||
import LandingPage from './mockup/pages/LandingPage';
|
||||
import LoginPage from './mockup/pages/LoginPage';
|
||||
import RegisterPage from './mockup/pages/RegisterPage';
|
||||
import AboutPage from './mockup/pages/AboutPage';
|
||||
import FeaturesPage from './mockup/pages/FeaturesPage';
|
||||
import './index.css';
|
||||
import './mockup/mockup.css';
|
||||
|
||||
/**
|
||||
* Protected route - requires authentication
|
||||
*/
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public route - redirects to app if logged in
|
||||
*/
|
||||
function PublicRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !isAuthenticated ? children : <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main app with routing
|
||||
*/
|
||||
function AppRoutes() {
|
||||
const { login, register, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Marketing pages - public */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
|
||||
{/* Auth pages */}
|
||||
<Route path="/login" element={
|
||||
<PublicRoute>
|
||||
<LoginPage onLogin={login} />
|
||||
</PublicRoute>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
<PublicRoute>
|
||||
<RegisterPage onRegister={register} />
|
||||
</PublicRoute>
|
||||
} />
|
||||
|
||||
{/* Connect App - protected */}
|
||||
<Route path="/app" element={
|
||||
<ProtectedRoute>
|
||||
<MessagingProvider>
|
||||
<MainLayout onLogout={logout} />
|
||||
</MessagingProvider>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/app/*" element={
|
||||
<ProtectedRoute>
|
||||
<MessagingProvider>
|
||||
<MainLayout onLogout={logout} />
|
||||
</MessagingProvider>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Catch all - redirect to home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<AppRoutes />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,580 +0,0 @@
|
|||
/* 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-content: center;
|
||||
background: #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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
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">🚀</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;
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,556 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,156 +0,0 @@
|
|||
/* 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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,436 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
const activeConversationRef = 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();
|
||||
|
||||
// Only update messages if this conversation is still active
|
||||
// This prevents race conditions when rapidly switching conversations
|
||||
if (data.success && activeConversationRef.current === conversationId) {
|
||||
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) => {
|
||||
const conversationId = conversation.id;
|
||||
|
||||
// Update state and ref immediately to prevent race conditions
|
||||
setActiveConversation(conversation);
|
||||
activeConversationRef.current = conversationId;
|
||||
|
||||
// Clear current messages while loading
|
||||
setMessages([]);
|
||||
|
||||
await loadMessages(conversationId);
|
||||
};
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
/* 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;
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,316 +0,0 @@
|
|||
.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%;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,313 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
.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 */
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabase = createClient(
|
||||
import.meta.env.VITE_SUPABASE_URL || 'http://127.0.0.1:3000',
|
||||
import.meta.env.VITE_SUPABASE_ANON_KEY || 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH'
|
||||
);
|
||||
|
||||
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 login = async (email, password) => {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const register = async (email, password, metadata = {}) => {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: metadata
|
||||
}
|
||||
});
|
||||
if (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const updateUser = (updates) => {
|
||||
setUser(prev => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser,
|
||||
isAuthenticated: !!user
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
|
|
@ -1,159 +0,0 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { messagingService, socket, connectSocket, disconnectSocket } from '../services/messaging';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const MessagingContext = createContext();
|
||||
|
||||
export function useMessaging() {
|
||||
const context = useContext(MessagingContext);
|
||||
if (!context) {
|
||||
throw new Error('useMessaging must be used within a MessagingProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function MessagingProvider({ children }) {
|
||||
const { user } = useAuth();
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [currentConversation, setCurrentConversation] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load conversations when user logs in
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
connectSocket(user.id);
|
||||
loadConversations();
|
||||
} else {
|
||||
disconnectSocket();
|
||||
setConversations([]);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnectSocket();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Listen for real-time messages
|
||||
useEffect(() => {
|
||||
const handleNewMessage = (message) => {
|
||||
if (message.conversation_id === currentConversation?.id) {
|
||||
setMessages(prev => [...prev, message]);
|
||||
}
|
||||
// Update conversation's last message
|
||||
setConversations(prev =>
|
||||
prev.map(conv =>
|
||||
conv.id === message.conversation_id
|
||||
? { ...conv, lastMessage: message, updated_at: message.created_at }
|
||||
: conv
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
socket.on('message:new', handleNewMessage);
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMessage);
|
||||
};
|
||||
}, [currentConversation?.id]);
|
||||
|
||||
// Subscribe to current conversation's messages
|
||||
useEffect(() => {
|
||||
if (!currentConversation?.id) return;
|
||||
|
||||
const unsubscribe = messagingService.subscribeToMessages(
|
||||
currentConversation.id,
|
||||
(newMessage) => {
|
||||
setMessages(prev => {
|
||||
// Avoid duplicates
|
||||
if (prev.find(m => m.id === newMessage.id)) return prev;
|
||||
return [...prev, newMessage];
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentConversation?.id]);
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await messagingService.getConversations(user.id);
|
||||
setConversations(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const selectConversation = useCallback(async (conversation) => {
|
||||
setCurrentConversation(conversation);
|
||||
setLoading(true);
|
||||
try {
|
||||
const msgs = await messagingService.getMessages(conversation.id);
|
||||
setMessages(msgs);
|
||||
} catch (err) {
|
||||
console.error('Failed to load messages:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async (content) => {
|
||||
if (!currentConversation?.id || !user?.id || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const message = await messagingService.sendMessage(
|
||||
currentConversation.id,
|
||||
user.id,
|
||||
content
|
||||
);
|
||||
// Optimistically add to local state
|
||||
setMessages(prev => [...prev, message]);
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
setError(err.message);
|
||||
}
|
||||
}, [currentConversation?.id, user?.id]);
|
||||
|
||||
const createConversation = useCallback(async (type, title, participantIds) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
const conversation = await messagingService.createConversation(
|
||||
type,
|
||||
title,
|
||||
[user.id, ...participantIds]
|
||||
);
|
||||
setConversations(prev => [conversation, ...prev]);
|
||||
return conversation;
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError(err.message);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const value = {
|
||||
conversations,
|
||||
currentConversation,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
selectConversation,
|
||||
sendMessage,
|
||||
createConversation,
|
||||
refreshConversations: loadConversations,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessagingContext.Provider value={value}>
|
||||
{children}
|
||||
</MessagingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e4e4e7;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #111;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>AeThex Connect - Secure, Open-Source Communication Platform</title>
|
||||
<meta name="title" content="AeThex Connect - Secure, Open-Source Communication Platform" />
|
||||
<meta name="description" content="Privacy-first communication platform with end-to-end encryption, crystal-clear voice & video calls, and seamless cross-platform sync. Open source and community-driven." />
|
||||
<meta name="keywords" content="AeThex, Connect, Discord alternative, secure messaging, encrypted chat, voice calls, video calls, open source, privacy, communication platform, gaming, community" />
|
||||
<meta name="author" content="AeThex Corporation" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="language" content="English" />
|
||||
<meta name="revisit-after" content="7 days" />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="https://aethex.online/" />
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="msapplication-TileColor" content="#0a0a0a" />
|
||||
<meta name="theme-color" content="#0a0a0a" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://aethex.online/" />
|
||||
<meta property="og:title" content="AeThex Connect - Secure, Open-Source Communication" />
|
||||
<meta property="og:description" content="Privacy-first communication platform with end-to-end encryption, crystal-clear voice & video calls, and seamless cross-platform sync." />
|
||||
<meta property="og:image" content="https://aethex.online/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="AeThex Connect - The Trinity of Communication" />
|
||||
<meta property="og:site_name" content="AeThex Connect" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://aethex.online/" />
|
||||
<meta name="twitter:title" content="AeThex Connect - Secure, Open-Source Communication" />
|
||||
<meta name="twitter:description" content="Privacy-first communication platform with end-to-end encryption, crystal-clear voice & video calls, and seamless cross-platform sync." />
|
||||
<meta name="twitter:image" content="https://aethex.online/og-image.png" />
|
||||
<meta name="twitter:image:alt" content="AeThex Connect - The Trinity of Communication" />
|
||||
<meta name="twitter:site" content="@AeThexCorp" />
|
||||
<meta name="twitter:creator" content="@AeThexCorp" />
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="application-name" content="AeThex Connect" />
|
||||
<meta name="apple-mobile-web-app-title" content="AeThex Connect" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
|
||||
<!-- DNS Prefetch for performance -->
|
||||
<link rel="dns-prefetch" href="//fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="//fonts.gstatic.com" />
|
||||
|
||||
<!-- Preconnect for critical resources -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
|
||||
<!-- JSON-LD Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "AeThex Connect",
|
||||
"applicationCategory": "CommunicationApplication",
|
||||
"operatingSystem": "Web, Windows, macOS, Linux, iOS, Android",
|
||||
"description": "Privacy-first communication platform with end-to-end encryption, voice & video calls, and cross-platform sync.",
|
||||
"url": "https://aethex.online",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "AeThex Corporation",
|
||||
"url": "https://aethex.online"
|
||||
},
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.9",
|
||||
"ratingCount": "1250"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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>
|
||||
);
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ActivityStatus({ activity, size = 'normal' }) {
|
||||
if (!activity) return null;
|
||||
|
||||
const activityTypes = {
|
||||
playing: { icon: '🎮', label: 'Playing' },
|
||||
streaming: { icon: '📺', label: 'Streaming' },
|
||||
listening: { icon: '🎧', label: 'Listening to' },
|
||||
watching: { icon: '📺', label: 'Watching' },
|
||||
competing: { icon: '🏆', label: 'Competing in' },
|
||||
custom: { icon: null, label: null },
|
||||
developing: { icon: '💻', label: 'Developing' },
|
||||
building: { icon: '🔨', label: 'Building' },
|
||||
};
|
||||
|
||||
const typeInfo = activityTypes[activity.type] || activityTypes.custom;
|
||||
|
||||
if (size === 'small') {
|
||||
return (
|
||||
<div className="activity-status-small">
|
||||
{typeInfo.icon && <span className="activity-icon-small">{typeInfo.icon}</span>}
|
||||
<span className="activity-text-small">
|
||||
{typeInfo.label ? `${typeInfo.label} ${activity.name}` : activity.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (size === 'mini') {
|
||||
return (
|
||||
<span className="activity-status-mini">
|
||||
{activity.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="activity-status">
|
||||
<div className="activity-header">
|
||||
{typeInfo.icon && <span className="activity-type-icon">{typeInfo.icon}</span>}
|
||||
<span className="activity-type-label">{typeInfo.label || 'Activity'}</span>
|
||||
</div>
|
||||
|
||||
<div className="activity-content">
|
||||
{activity.largeImage && (
|
||||
<div className="activity-image">
|
||||
<img src={activity.largeImage} alt="" />
|
||||
{activity.smallImage && (
|
||||
<img src={activity.smallImage} alt="" className="activity-image-small" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="activity-details">
|
||||
<div className="activity-name">{activity.name}</div>
|
||||
{activity.details && <div className="activity-detail">{activity.details}</div>}
|
||||
{activity.state && <div className="activity-state">{activity.state}</div>}
|
||||
{activity.startTime && (
|
||||
<div className="activity-time">
|
||||
<ActivityTimer startTime={activity.startTime} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activity.buttons && activity.buttons.length > 0 && (
|
||||
<div className="activity-buttons">
|
||||
{activity.buttons.map((btn, idx) => (
|
||||
<button key={idx} className="activity-btn">
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTimer({ startTime }) {
|
||||
const [elapsed, setElapsed] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateElapsed = () => {
|
||||
const now = Date.now();
|
||||
const diff = now - startTime;
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const minutes = Math.floor((diff % 3600000) / 60000);
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
|
||||
if (hours > 0) {
|
||||
setElapsed(`${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')} elapsed`);
|
||||
} else {
|
||||
setElapsed(`${minutes}:${seconds.toString().padStart(2, '0')} elapsed`);
|
||||
}
|
||||
};
|
||||
|
||||
updateElapsed();
|
||||
const interval = setInterval(updateElapsed, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime]);
|
||||
|
||||
return <span>{elapsed}</span>;
|
||||
}
|
||||
|
||||
// Rich Presence card for games
|
||||
export function RichPresence({ game }) {
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<div className="rich-presence">
|
||||
<div className="rp-header">
|
||||
<span className="rp-icon">🎮</span>
|
||||
<span className="rp-label">Playing a game</span>
|
||||
</div>
|
||||
|
||||
<div className="rp-content">
|
||||
<div className="rp-image">
|
||||
{game.icon ? (
|
||||
<img src={game.icon} alt={game.name} />
|
||||
) : (
|
||||
<span className="rp-placeholder">🎮</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rp-info">
|
||||
<div className="rp-name">{game.name}</div>
|
||||
{game.details && <div className="rp-details">{game.details}</div>}
|
||||
{game.state && <div className="rp-state">{game.state}</div>}
|
||||
<div className="rp-elapsed">
|
||||
<ActivityTimer startTime={game.startTime || Date.now() - 3600000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Spotify-style listening activity
|
||||
export function SpotifyActivity({ track }) {
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<div className="spotify-activity">
|
||||
<div className="spotify-header">
|
||||
<span className="spotify-icon">🎧</span>
|
||||
<span className="spotify-label">Listening to Spotify</span>
|
||||
</div>
|
||||
|
||||
<div className="spotify-content">
|
||||
<div className="spotify-album">
|
||||
{track.albumArt ? (
|
||||
<img src={track.albumArt} alt={track.album} />
|
||||
) : (
|
||||
<span className="spotify-placeholder">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="spotify-info">
|
||||
<div className="spotify-song">{track.name}</div>
|
||||
<div className="spotify-artist">by {track.artist}</div>
|
||||
<div className="spotify-album-name">on {track.album}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spotify-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${(track.elapsed / track.duration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-times">
|
||||
<span>{formatTime(track.elapsed)}</span>
|
||||
<span>{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All Apps', icon: '🔷' },
|
||||
{ id: 'moderation', label: 'Moderation', icon: '🛡️' },
|
||||
{ id: 'entertainment', label: 'Entertainment', icon: '🎮' },
|
||||
{ id: 'social', label: 'Social', icon: '💬' },
|
||||
{ id: 'utility', label: 'Utility', icon: '🔧' },
|
||||
{ id: 'music', label: 'Music', icon: '🎵' },
|
||||
];
|
||||
|
||||
const apps = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'MEE6',
|
||||
icon: '🤖',
|
||||
color: '#5865f2',
|
||||
description: 'The most popular bot for moderation, leveling, and social commands.',
|
||||
category: 'moderation',
|
||||
verified: true,
|
||||
servers: '19M+',
|
||||
rating: 4.5,
|
||||
features: ['Moderation', 'Leveling', 'Custom Commands', 'Music'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Dyno',
|
||||
icon: '⚡',
|
||||
color: '#ffa500',
|
||||
description: 'Fully customizable server moderation bot with a web dashboard.',
|
||||
category: 'moderation',
|
||||
verified: true,
|
||||
servers: '4.7M+',
|
||||
rating: 4.3,
|
||||
features: ['Auto-mod', 'Dashboard', 'Logs', 'Announcements'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Rythm',
|
||||
icon: '🎵',
|
||||
color: '#e91e63',
|
||||
description: 'High quality music bot with support for multiple platforms.',
|
||||
category: 'music',
|
||||
verified: true,
|
||||
servers: '6M+',
|
||||
rating: 4.7,
|
||||
features: ['Music', 'Playlists', 'Lyrics', 'Volume Control'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Dank Memer',
|
||||
icon: '🐸',
|
||||
color: '#3ba55d',
|
||||
description: 'Meme posting, currency system, and fun commands.',
|
||||
category: 'entertainment',
|
||||
verified: true,
|
||||
servers: '8M+',
|
||||
rating: 4.6,
|
||||
features: ['Economy', 'Memes', 'Games', 'Gambling'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Carl-bot',
|
||||
icon: '🎭',
|
||||
color: '#9b59b6',
|
||||
description: 'Reaction roles, logging, automod, and utility commands.',
|
||||
category: 'utility',
|
||||
verified: true,
|
||||
servers: '3.5M+',
|
||||
rating: 4.4,
|
||||
features: ['Reaction Roles', 'Logging', 'Tags', 'Embeds'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Pokétwo',
|
||||
icon: '🔴',
|
||||
color: '#ff0000',
|
||||
description: 'Catch, trade, and battle Pokémon in Discord.',
|
||||
category: 'entertainment',
|
||||
verified: true,
|
||||
servers: '1.2M+',
|
||||
rating: 4.5,
|
||||
features: ['Pokémon', 'Trading', 'Battles', 'Events'],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Tickets',
|
||||
icon: '🎫',
|
||||
color: '#5865f2',
|
||||
description: 'Simple and powerful ticket system for support.',
|
||||
category: 'utility',
|
||||
verified: false,
|
||||
servers: '500K+',
|
||||
rating: 4.2,
|
||||
features: ['Tickets', 'Transcripts', 'Categories', 'Staff Roles'],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Arcane',
|
||||
icon: '🔮',
|
||||
color: '#eb459e',
|
||||
description: 'XP leveling, role rewards, and leaderboards.',
|
||||
category: 'social',
|
||||
verified: false,
|
||||
servers: '800K+',
|
||||
rating: 4.1,
|
||||
features: ['Leveling', 'Role Rewards', 'Leaderboard', 'Card Design'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AppDirectory({ onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
const [selectedApp, setSelectedApp] = useState(null);
|
||||
|
||||
const filteredApps = apps.filter(app => {
|
||||
const matchesSearch = !search ||
|
||||
app.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
app.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = activeCategory === 'all' || app.category === activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="app-directory">
|
||||
<div className="app-dir-header">
|
||||
<h2>🔷 App Directory</h2>
|
||||
<button className="app-dir-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="app-dir-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="app-dir-categories">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`app-cat-btn ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
<span>{cat.icon}</span> {cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="app-dir-content">
|
||||
{selectedApp ? (
|
||||
<div className="app-detail">
|
||||
<button className="back-btn" onClick={() => setSelectedApp(null)}>
|
||||
← Back
|
||||
</button>
|
||||
<div className="app-detail-header">
|
||||
<div
|
||||
className="app-icon-large"
|
||||
style={{ background: selectedApp.color }}
|
||||
>
|
||||
{selectedApp.icon}
|
||||
</div>
|
||||
<div className="app-detail-info">
|
||||
<div className="app-name-row">
|
||||
<h2>{selectedApp.name}</h2>
|
||||
{selectedApp.verified && <span className="verified-badge">✓ Verified</span>}
|
||||
</div>
|
||||
<p className="app-description">{selectedApp.description}</p>
|
||||
<div className="app-stats">
|
||||
<span>📊 {selectedApp.servers} servers</span>
|
||||
<span>⭐ {selectedApp.rating}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="add-to-server-btn">Add to Server</button>
|
||||
</div>
|
||||
<div className="app-features">
|
||||
<h3>Features</h3>
|
||||
<div className="feature-tags">
|
||||
{selectedApp.features.map((feature, idx) => (
|
||||
<span key={idx} className="feature-tag">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-grid">
|
||||
{filteredApps.map(app => (
|
||||
<div
|
||||
key={app.id}
|
||||
className="app-card"
|
||||
onClick={() => setSelectedApp(app)}
|
||||
>
|
||||
<div
|
||||
className="app-card-icon"
|
||||
style={{ background: app.color }}
|
||||
>
|
||||
{app.icon}
|
||||
</div>
|
||||
<div className="app-card-info">
|
||||
<div className="app-card-name">
|
||||
{app.name}
|
||||
{app.verified && <span className="verified-small">✓</span>}
|
||||
</div>
|
||||
<p className="app-card-desc">{app.description}</p>
|
||||
<div className="app-card-stats">
|
||||
<span>{app.servers} servers</span>
|
||||
<span>⭐ {app.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockLogs = [
|
||||
{ id: 1, action: 'member_kick', user: { name: 'ModUser', avatar: 'M', color: '#5865f2' }, target: 'SpamBot#1234', time: '2 minutes ago', reason: 'Spam' },
|
||||
{ id: 2, action: 'channel_create', user: { name: 'Admin', avatar: 'A', color: '#ff0000' }, target: '#announcements', time: '15 minutes ago' },
|
||||
{ id: 3, action: 'role_update', user: { name: 'Admin', avatar: 'A', color: '#ff0000' }, target: 'Moderator', time: '1 hour ago', changes: 'Added kick permission' },
|
||||
{ id: 4, action: 'member_ban', user: { name: 'ModUser', avatar: 'M', color: '#5865f2' }, target: 'ToxicUser#9999', time: '2 hours ago', reason: 'Harassment' },
|
||||
{ id: 5, action: 'message_delete', user: { name: 'AutoMod', avatar: '🤖', color: '#3ba55d' }, target: '5 messages', time: '3 hours ago', reason: 'Spam filter' },
|
||||
{ id: 6, action: 'invite_create', user: { name: 'Member', avatar: 'M', color: '#ffa500' }, target: 'discord.gg/abc123', time: '4 hours ago' },
|
||||
{ id: 7, action: 'member_timeout', user: { name: 'ModUser', avatar: 'M', color: '#5865f2' }, target: 'RuleBreaker#0001', time: '5 hours ago', reason: 'Breaking rules', duration: '10 minutes' },
|
||||
{ id: 8, action: 'server_update', user: { name: 'Owner', avatar: 'O', color: '#e91e63' }, target: 'Server name', time: '1 day ago', changes: 'Changed to AeThex Foundation' },
|
||||
{ id: 9, action: 'emoji_create', user: { name: 'Admin', avatar: 'A', color: '#ff0000' }, target: ':custom_emoji:', time: '2 days ago' },
|
||||
{ id: 10, action: 'webhook_create', user: { name: 'Developer', avatar: 'D', color: '#0066ff' }, target: 'GitHub Webhook', time: '3 days ago' },
|
||||
];
|
||||
|
||||
const actionTypes = [
|
||||
{ id: 'all', label: 'All Actions' },
|
||||
{ id: 'member', label: 'Member Actions' },
|
||||
{ id: 'channel', label: 'Channel Actions' },
|
||||
{ id: 'role', label: 'Role Actions' },
|
||||
{ id: 'message', label: 'Message Actions' },
|
||||
{ id: 'server', label: 'Server Actions' },
|
||||
{ id: 'invite', label: 'Invite Actions' },
|
||||
];
|
||||
|
||||
const actionIcons = {
|
||||
member_kick: '👢',
|
||||
member_ban: '🔨',
|
||||
member_timeout: '⏰',
|
||||
member_join: '📥',
|
||||
member_leave: '📤',
|
||||
channel_create: '➕',
|
||||
channel_delete: '🗑️',
|
||||
channel_update: '📝',
|
||||
role_create: '🎭',
|
||||
role_update: '🎭',
|
||||
role_delete: '🎭',
|
||||
message_delete: '💬',
|
||||
message_pin: '📌',
|
||||
server_update: '⚙️',
|
||||
invite_create: '🔗',
|
||||
invite_delete: '🔗',
|
||||
emoji_create: '😀',
|
||||
emoji_delete: '😀',
|
||||
webhook_create: '🔌',
|
||||
webhook_delete: '🔌',
|
||||
};
|
||||
|
||||
const actionLabels = {
|
||||
member_kick: 'kicked',
|
||||
member_ban: 'banned',
|
||||
member_timeout: 'timed out',
|
||||
member_join: 'joined',
|
||||
member_leave: 'left',
|
||||
channel_create: 'created channel',
|
||||
channel_delete: 'deleted channel',
|
||||
channel_update: 'updated channel',
|
||||
role_create: 'created role',
|
||||
role_update: 'updated role',
|
||||
role_delete: 'deleted role',
|
||||
message_delete: 'deleted',
|
||||
message_pin: 'pinned',
|
||||
server_update: 'updated',
|
||||
invite_create: 'created invite',
|
||||
invite_delete: 'deleted invite',
|
||||
emoji_create: 'added emoji',
|
||||
emoji_delete: 'removed emoji',
|
||||
webhook_create: 'created webhook',
|
||||
webhook_delete: 'deleted webhook',
|
||||
};
|
||||
|
||||
export default function AuditLog({ onClose }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [searchUser, setSearchUser] = useState('');
|
||||
const [expandedLog, setExpandedLog] = useState(null);
|
||||
|
||||
const filteredLogs = mockLogs.filter(log => {
|
||||
if (filter !== 'all' && !log.action.startsWith(filter)) return false;
|
||||
if (searchUser && !log.user.name.toLowerCase().includes(searchUser.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="audit-log">
|
||||
<div className="audit-header">
|
||||
<h2>📋 Audit Log</h2>
|
||||
<button className="audit-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="audit-filters">
|
||||
<div className="audit-search">
|
||||
<span>🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by user..."
|
||||
value={searchUser}
|
||||
onChange={(e) => setSearchUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="audit-type-filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
{actionTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="audit-list">
|
||||
{filteredLogs.map(log => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`audit-entry ${expandedLog === log.id ? 'expanded' : ''}`}
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
>
|
||||
<div className="audit-entry-main">
|
||||
<span className="audit-icon">{actionIcons[log.action] || '📝'}</span>
|
||||
<div
|
||||
className="audit-user-avatar"
|
||||
style={{ background: log.user.color }}
|
||||
>
|
||||
{log.user.avatar}
|
||||
</div>
|
||||
<div className="audit-info">
|
||||
<span className="audit-user-name">{log.user.name}</span>
|
||||
<span className="audit-action">{actionLabels[log.action] || log.action}</span>
|
||||
<span className="audit-target">{log.target}</span>
|
||||
</div>
|
||||
<span className="audit-time">{log.time}</span>
|
||||
</div>
|
||||
|
||||
{expandedLog === log.id && (
|
||||
<div className="audit-details">
|
||||
{log.reason && (
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">Reason:</span>
|
||||
<span className="detail-value">{log.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
{log.duration && (
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">Duration:</span>
|
||||
<span className="detail-value">{log.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{log.changes && (
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">Changes:</span>
|
||||
<span className="detail-value">{log.changes}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">ID:</span>
|
||||
<span className="detail-value audit-id">{log.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="audit-footer">
|
||||
<span className="audit-count">{filteredLogs.length} entries</span>
|
||||
<button className="audit-load-more">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const autoModRules = [
|
||||
{
|
||||
id: 'profanity',
|
||||
name: 'Block Profanity',
|
||||
description: 'Automatically block common profane words',
|
||||
icon: '🤬',
|
||||
severity: 'high'
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
name: 'Block Spam',
|
||||
description: 'Prevent repeated messages, excessive mentions, and invite links',
|
||||
icon: '📧',
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
id: 'links',
|
||||
name: 'Block Links',
|
||||
description: 'Block messages containing links (with allowlist)',
|
||||
icon: '🔗',
|
||||
severity: 'low'
|
||||
},
|
||||
{
|
||||
id: 'mentions',
|
||||
name: 'Limit Mentions',
|
||||
description: 'Block messages with excessive @mentions',
|
||||
icon: '@',
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
id: 'caps',
|
||||
name: 'Block Excessive Caps',
|
||||
description: 'Block messages that are mostly uppercase',
|
||||
icon: '🔠',
|
||||
severity: 'low'
|
||||
},
|
||||
{
|
||||
id: 'attachments',
|
||||
name: 'Block Attachments',
|
||||
description: 'Restrict file attachments in messages',
|
||||
icon: '📎',
|
||||
severity: 'low'
|
||||
},
|
||||
];
|
||||
|
||||
const defaultActions = [
|
||||
{ id: 'delete', label: 'Delete Message', icon: '🗑️' },
|
||||
{ id: 'alert', label: 'Send Alert to Channel', icon: '⚠️' },
|
||||
{ id: 'timeout', label: 'Timeout Member', icon: '⏰' },
|
||||
{ id: 'log', label: 'Log to Mod Channel', icon: '📝' },
|
||||
];
|
||||
|
||||
export default function AutoModSettings({ onSave, onClose }) {
|
||||
const [rules, setRules] = useState({
|
||||
profanity: { enabled: true, action: 'delete' },
|
||||
spam: { enabled: true, action: 'delete' },
|
||||
links: { enabled: false, action: 'delete', allowlist: [] },
|
||||
mentions: { enabled: true, action: 'alert', limit: 5 },
|
||||
caps: { enabled: false, action: 'delete', threshold: 70 },
|
||||
attachments: { enabled: false, action: 'delete' },
|
||||
});
|
||||
|
||||
const [customWords, setCustomWords] = useState(['badword1', 'badword2']);
|
||||
const [allowedLinks, setAllowedLinks] = useState(['youtube.com', 'twitter.com']);
|
||||
const [logChannel, setLogChannel] = useState('mod-logs');
|
||||
const [activeRule, setActiveRule] = useState(null);
|
||||
|
||||
const toggleRule = (ruleId) => {
|
||||
setRules(prev => ({
|
||||
...prev,
|
||||
[ruleId]: { ...prev[ruleId], enabled: !prev[ruleId]?.enabled }
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRuleAction = (ruleId, action) => {
|
||||
setRules(prev => ({
|
||||
...prev,
|
||||
[ruleId]: { ...prev[ruleId], action }
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="automod-settings">
|
||||
<div className="automod-header">
|
||||
<h2>🤖 AutoMod</h2>
|
||||
<p className="automod-subtitle">Automatically moderate your server with configurable rules</p>
|
||||
<button className="automod-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="automod-content">
|
||||
<section className="automod-section">
|
||||
<h3>Moderation Rules</h3>
|
||||
<div className="rules-list">
|
||||
{autoModRules.map(rule => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`rule-card ${rules[rule.id]?.enabled ? 'enabled' : ''}`}
|
||||
>
|
||||
<div className="rule-main">
|
||||
<div className="rule-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rules[rule.id]?.enabled || false}
|
||||
onChange={() => toggleRule(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rule-icon">{rule.icon}</div>
|
||||
<div className="rule-info">
|
||||
<h4>{rule.name}</h4>
|
||||
<p>{rule.description}</p>
|
||||
</div>
|
||||
<div className={`severity-badge ${rule.severity}`}>
|
||||
{rule.severity}
|
||||
</div>
|
||||
<button
|
||||
className="configure-btn"
|
||||
onClick={() => setActiveRule(activeRule === rule.id ? null : rule.id)}
|
||||
>
|
||||
{activeRule === rule.id ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeRule === rule.id && (
|
||||
<div className="rule-config">
|
||||
<div className="config-field">
|
||||
<label>Action when triggered</label>
|
||||
<div className="action-options">
|
||||
{defaultActions.map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
className={`action-btn ${rules[rule.id]?.action === action.id ? 'active' : ''}`}
|
||||
onClick={() => updateRuleAction(rule.id, action.id)}
|
||||
>
|
||||
<span className="action-icon">{action.icon}</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule.id === 'mentions' && (
|
||||
<div className="config-field">
|
||||
<label>Maximum mentions per message</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={rules.mentions?.limit || 5}
|
||||
onChange={(e) => setRules(prev => ({
|
||||
...prev,
|
||||
mentions: { ...prev.mentions, limit: Number(e.target.value) }
|
||||
}))}
|
||||
className="number-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.id === 'caps' && (
|
||||
<div className="config-field">
|
||||
<label>Caps threshold (%)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={rules.caps?.threshold || 70}
|
||||
onChange={(e) => setRules(prev => ({
|
||||
...prev,
|
||||
caps: { ...prev.caps, threshold: Number(e.target.value) }
|
||||
}))}
|
||||
/>
|
||||
<span>{rules.caps?.threshold || 70}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.id === 'links' && (
|
||||
<div className="config-field">
|
||||
<label>Allowed Domains</label>
|
||||
<div className="tag-list">
|
||||
{allowedLinks.map((link, idx) => (
|
||||
<span key={idx} className="tag">
|
||||
{link}
|
||||
<button onClick={() => setAllowedLinks(prev => prev.filter((_, i) => i !== idx))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add domain..."
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && e.target.value) {
|
||||
setAllowedLinks(prev => [...prev, e.target.value]);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="automod-section">
|
||||
<h3>Custom Blocked Words</h3>
|
||||
<p className="section-description">Add your own words to block in messages</p>
|
||||
<div className="custom-words">
|
||||
<div className="tag-list">
|
||||
{customWords.map((word, idx) => (
|
||||
<span key={idx} className="tag blocked">
|
||||
{word}
|
||||
<button onClick={() => setCustomWords(prev => prev.filter((_, i) => i !== idx))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add word to block..."
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && e.target.value) {
|
||||
setCustomWords(prev => [...prev, e.target.value.toLowerCase()]);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="automod-section">
|
||||
<h3>Log Channel</h3>
|
||||
<p className="section-description">Where to send AutoMod logs</p>
|
||||
<select
|
||||
value={logChannel}
|
||||
onChange={(e) => setLogChannel(e.target.value)}
|
||||
className="channel-select"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="mod-logs"># mod-logs</option>
|
||||
<option value="admin"># admin</option>
|
||||
<option value="bot-logs"># bot-logs</option>
|
||||
</select>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="automod-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="save-btn"
|
||||
onClick={() => onSave?.({ rules, customWords, allowedLinks, logChannel })}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockBans = [
|
||||
{ id: 1, user: { name: 'SpamBot_123', tag: '#0000', avatar: 'S' }, reason: 'Spamming invite links', bannedBy: 'Trevor', bannedAt: '2025-12-15T14:30:00' },
|
||||
{ id: 2, user: { name: 'ToxicUser', tag: '#6666', avatar: 'T' }, reason: 'Harassment and hate speech', bannedBy: 'Sarah', bannedAt: '2025-11-20T09:15:00' },
|
||||
{ id: 3, user: { name: 'RaidLeader', tag: '#9999', avatar: 'R' }, reason: 'Organizing raid against server', bannedBy: 'Trevor', bannedAt: '2025-10-05T18:45:00' },
|
||||
{ id: 4, user: { name: 'ScammerX', tag: '#1111', avatar: 'X' }, reason: 'Attempting to scam members', bannedBy: 'Sarah', bannedAt: '2025-09-12T11:00:00' },
|
||||
{ id: 5, user: { name: 'BadActor', tag: '#4321', avatar: 'B' }, reason: 'No reason given', bannedBy: 'Trevor', bannedAt: '2025-08-01T16:20:00' },
|
||||
];
|
||||
|
||||
export default function BanList({ onClose }) {
|
||||
const [bans, setBans] = useState(mockBans);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedBan, setSelectedBan] = useState(null);
|
||||
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
|
||||
|
||||
const filteredBans = bans.filter(ban =>
|
||||
ban.user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ban.reason.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevoke = (banId) => {
|
||||
setBans(prev => prev.filter(b => b.id !== banId));
|
||||
setShowRevokeConfirm(false);
|
||||
setSelectedBan(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="ban-list-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ban-list-header">
|
||||
<h2>🚫 Server Bans</h2>
|
||||
<button className="ban-list-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="ban-list-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search bans..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ban-list-content">
|
||||
{filteredBans.length === 0 ? (
|
||||
<div className="no-bans">
|
||||
<span className="no-bans-icon">✨</span>
|
||||
<p>No bans found</p>
|
||||
<span className="no-bans-hint">This server has no banned users</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ban-count">{filteredBans.length} banned user{filteredBans.length !== 1 ? 's' : ''}</div>
|
||||
{filteredBans.map(ban => (
|
||||
<div
|
||||
key={ban.id}
|
||||
className={`ban-item ${selectedBan === ban.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedBan(selectedBan === ban.id ? null : ban.id)}
|
||||
>
|
||||
<div className="ban-user-info">
|
||||
<div className="ban-avatar">{ban.user.avatar}</div>
|
||||
<div className="ban-user-details">
|
||||
<div className="ban-username">
|
||||
{ban.user.name}<span className="ban-tag">{ban.user.tag}</span>
|
||||
</div>
|
||||
<div className="ban-reason">{ban.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ban-meta">
|
||||
<div className="ban-date">{formatDate(ban.bannedAt)}</div>
|
||||
<div className="ban-by">by {ban.bannedBy}</div>
|
||||
</div>
|
||||
|
||||
{selectedBan === ban.id && (
|
||||
<div className="ban-actions">
|
||||
<button
|
||||
className="revoke-ban-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRevokeConfirm(true);
|
||||
}}
|
||||
>
|
||||
Revoke Ban
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRevokeConfirm && selectedBan && (
|
||||
<div className="revoke-confirm-overlay" onClick={() => setShowRevokeConfirm(false)}>
|
||||
<div className="revoke-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Revoke Ban</h3>
|
||||
<p>
|
||||
Are you sure you want to unban{' '}
|
||||
<strong>{bans.find(b => b.id === selectedBan)?.user.name}</strong>?
|
||||
</p>
|
||||
<p className="revoke-warning">They will be able to rejoin the server with an invite.</p>
|
||||
<div className="revoke-actions">
|
||||
<button className="cancel-btn" onClick={() => setShowRevokeConfirm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="confirm-revoke-btn" onClick={() => handleRevoke(selectedBan)}>
|
||||
Revoke Ban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function CategoryEditor({ category, onClose, onSave, onDelete }) {
|
||||
const categoryData = category || {
|
||||
id: 'new',
|
||||
name: '',
|
||||
channels: [],
|
||||
};
|
||||
|
||||
const [name, setName] = useState(categoryData.name);
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [syncPermissions, setSyncPermissions] = useState(true);
|
||||
|
||||
const availableRoles = [
|
||||
{ id: 'founder', name: 'Founder', color: '#ff0000' },
|
||||
{ id: 'foundation', name: 'Foundation', color: '#ff0000' },
|
||||
{ id: 'corporation', name: 'Corporation', color: '#0066ff' },
|
||||
{ id: 'labs', name: 'Labs', color: '#ffa500' },
|
||||
{ id: 'everyone', name: '@everyone', color: '#99aab5' },
|
||||
];
|
||||
|
||||
const [allowedRoles, setAllowedRoles] = useState(['everyone']);
|
||||
|
||||
const toggleRole = (roleId) => {
|
||||
setAllowedRoles(prev =>
|
||||
prev.includes(roleId)
|
||||
? prev.filter(id => id !== roleId)
|
||||
: [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({
|
||||
id: categoryData.id,
|
||||
name: name.toUpperCase(),
|
||||
isPrivate,
|
||||
allowedRoles,
|
||||
syncPermissions,
|
||||
});
|
||||
};
|
||||
|
||||
const isNew = !category?.name;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="category-editor" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="category-editor-header">
|
||||
<h2>{isNew ? 'Create Category' : 'Edit Category'}</h2>
|
||||
<button className="category-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="category-editor-content">
|
||||
<div className="setting-group">
|
||||
<label>Category Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="category-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New Category"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group toggle-group">
|
||||
<div className="toggle-info">
|
||||
<label>Private Category</label>
|
||||
<p className="setting-hint">Only selected roles can view this category</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isPrivate && (
|
||||
<div className="setting-group">
|
||||
<label>Who can view this category?</label>
|
||||
<div className="role-selector">
|
||||
{availableRoles.map(role => (
|
||||
<label
|
||||
key={role.id}
|
||||
className={`role-checkbox ${allowedRoles.includes(role.id) ? 'checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowedRoles.includes(role.id)}
|
||||
onChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
<span className="role-color" style={{ background: role.color }}></span>
|
||||
<span className="role-name">{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && categoryData.channels?.length > 0 && (
|
||||
<div className="setting-group toggle-group">
|
||||
<div className="toggle-info">
|
||||
<label>Sync Permissions</label>
|
||||
<p className="setting-hint">
|
||||
Sync permissions with {categoryData.channels.length} channels in this category
|
||||
</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncPermissions}
|
||||
onChange={(e) => setSyncPermissions(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<div className="category-channels">
|
||||
<label>Channels in this Category</label>
|
||||
<div className="channel-list-preview">
|
||||
{(categoryData.channels?.length > 0 ? categoryData.channels : [
|
||||
{ id: 'general', name: 'general', type: 'text' },
|
||||
{ id: 'api-discussion', name: 'api-discussion', type: 'text' },
|
||||
]).map(ch => (
|
||||
<div key={ch.id} className="channel-preview-item">
|
||||
<span className="channel-icon">{ch.type === 'voice' ? '🔊' : '#'}</span>
|
||||
<span>{ch.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="category-editor-footer">
|
||||
{!isNew && (
|
||||
<button className="delete-category-btn" onClick={onDelete}>
|
||||
Delete Category
|
||||
</button>
|
||||
)}
|
||||
<div className="footer-actions">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button className="save-btn" onClick={handleSave} disabled={!name.trim()}>
|
||||
{isNew ? 'Create Category' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const permissionCategories = [
|
||||
{
|
||||
name: 'General Channel Permissions',
|
||||
permissions: [
|
||||
{ id: 'view_channel', label: 'View Channel', description: 'Allows members to view this channel' },
|
||||
{ id: 'manage_channel', label: 'Manage Channel', description: 'Allows members to change name, description, and settings' },
|
||||
{ id: 'manage_permissions', label: 'Manage Permissions', description: 'Allows members to change permission overrides' },
|
||||
{ id: 'manage_webhooks', label: 'Manage Webhooks', description: 'Allows members to create, edit and delete webhooks' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Text Channel Permissions',
|
||||
permissions: [
|
||||
{ id: 'send_messages', label: 'Send Messages', description: 'Allows members to send messages in this channel' },
|
||||
{ id: 'send_messages_threads', label: 'Send Messages in Threads', description: 'Allows members to send messages in threads' },
|
||||
{ id: 'create_public_threads', label: 'Create Public Threads', description: 'Allows members to create public threads' },
|
||||
{ id: 'create_private_threads', label: 'Create Private Threads', description: 'Allows members to create private threads' },
|
||||
{ id: 'embed_links', label: 'Embed Links', description: 'Links will show preview embeds' },
|
||||
{ id: 'attach_files', label: 'Attach Files', description: 'Allows members to upload files and images' },
|
||||
{ id: 'add_reactions', label: 'Add Reactions', description: 'Allows members to add reactions to messages' },
|
||||
{ id: 'use_external_emoji', label: 'Use External Emoji', description: 'Allows members to use emoji from other servers' },
|
||||
{ id: 'use_external_stickers', label: 'Use External Stickers', description: 'Allows members to use stickers from other servers' },
|
||||
{ id: 'mention_everyone', label: 'Mention @everyone', description: 'Allows members to mention @everyone and @here' },
|
||||
{ id: 'manage_messages', label: 'Manage Messages', description: 'Allows members to delete and pin messages' },
|
||||
{ id: 'manage_threads', label: 'Manage Threads', description: 'Allows members to rename, delete, and archive threads' },
|
||||
{ id: 'read_message_history', label: 'Read Message History', description: 'Allows reading of message history' },
|
||||
{ id: 'send_tts', label: 'Send TTS Messages', description: 'Allows sending text-to-speech messages' },
|
||||
{ id: 'use_application_commands', label: 'Use Application Commands', description: 'Allows using slash commands' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Voice Channel Permissions',
|
||||
permissions: [
|
||||
{ id: 'connect', label: 'Connect', description: 'Allows members to join this voice channel' },
|
||||
{ id: 'speak', label: 'Speak', description: 'Allows members to speak in this voice channel' },
|
||||
{ id: 'video', label: 'Video', description: 'Allows members to share video or stream' },
|
||||
{ id: 'mute_members', label: 'Mute Members', description: 'Allows muting other members' },
|
||||
{ id: 'deafen_members', label: 'Deafen Members', description: 'Allows deafening other members' },
|
||||
{ id: 'move_members', label: 'Move Members', description: 'Allows moving members between voice channels' },
|
||||
{ id: 'use_vad', label: 'Use Voice Activity', description: 'Allows using voice activity detection instead of push-to-talk' },
|
||||
{ id: 'priority_speaker', label: 'Priority Speaker', description: 'Allows using priority speaker mode' },
|
||||
{ id: 'request_to_speak', label: 'Request to Speak', description: 'Allows requesting to speak in stage channels' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function ChannelPermissions({ target, channelName, onClose, onSave }) {
|
||||
const targetData = target || {
|
||||
id: 'mod',
|
||||
name: 'Moderator',
|
||||
type: 'role',
|
||||
color: '#5865f2',
|
||||
};
|
||||
|
||||
const [permissions, setPermissions] = useState({});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const getPermState = (permId) => permissions[permId] || 'neutral';
|
||||
|
||||
const cyclePermission = (permId) => {
|
||||
setPermissions(prev => {
|
||||
const current = prev[permId] || 'neutral';
|
||||
const next = current === 'neutral' ? 'allow' : current === 'allow' ? 'deny' : 'neutral';
|
||||
return { ...prev, [permId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const setAllPermissions = (state) => {
|
||||
const newPerms = {};
|
||||
permissionCategories.forEach(cat => {
|
||||
cat.permissions.forEach(perm => {
|
||||
newPerms[perm.id] = state;
|
||||
});
|
||||
});
|
||||
setPermissions(newPerms);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(permissions);
|
||||
};
|
||||
|
||||
const filteredCategories = permissionCategories.map(cat => ({
|
||||
...cat,
|
||||
permissions: cat.permissions.filter(p =>
|
||||
p.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
})).filter(cat => cat.permissions.length > 0);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="channel-permissions-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="permissions-header">
|
||||
<div className="permissions-title">
|
||||
<h2>Edit Permissions</h2>
|
||||
<span className="permissions-context">
|
||||
{targetData.type === 'role' ? '@' : ''}
|
||||
<span style={targetData.color ? { color: targetData.color } : {}}>
|
||||
{targetData.name}
|
||||
</span>
|
||||
<span className="channel-ref">in #{channelName || 'general'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button className="permissions-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="permissions-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="permissions-search"
|
||||
placeholder="Search permissions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="bulk-actions">
|
||||
<button onClick={() => setAllPermissions('allow')}>Allow All</button>
|
||||
<button onClick={() => setAllPermissions('neutral')}>Clear All</button>
|
||||
<button onClick={() => setAllPermissions('deny')}>Deny All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="permissions-legend">
|
||||
<div className="legend-item">
|
||||
<span className="legend-icon allow">✓</span>
|
||||
<span>Allow</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<span className="legend-icon neutral">/</span>
|
||||
<span>Inherit</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<span className="legend-icon deny">✕</span>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="permissions-list">
|
||||
{filteredCategories.map(category => (
|
||||
<div key={category.name} className="permission-category">
|
||||
<h3>{category.name}</h3>
|
||||
{category.permissions.map(perm => (
|
||||
<div key={perm.id} className="permission-row">
|
||||
<div className="permission-info">
|
||||
<span className="permission-label">{perm.label}</span>
|
||||
<span className="permission-desc">{perm.description}</span>
|
||||
</div>
|
||||
<div className="permission-toggles">
|
||||
<button
|
||||
className={`perm-btn allow ${getPermState(perm.id) === 'allow' ? 'active' : ''}`}
|
||||
onClick={() => setPermissions(p => ({ ...p, [perm.id]: 'allow' }))}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
className={`perm-btn neutral ${getPermState(perm.id) === 'neutral' ? 'active' : ''}`}
|
||||
onClick={() => setPermissions(p => ({ ...p, [perm.id]: 'neutral' }))}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
className={`perm-btn deny ${getPermState(perm.id) === 'deny' ? 'active' : ''}`}
|
||||
onClick={() => setPermissions(p => ({ ...p, [perm.id]: 'deny' }))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="permissions-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button className="save-btn" onClick={handleSave}>Save Permissions</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function ChannelSettings({ channel, onClose, onSave }) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const channelData = channel || {
|
||||
id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
topic: 'Welcome to the general chat!',
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
category: 'Development',
|
||||
};
|
||||
|
||||
const [name, setName] = useState(channelData.name);
|
||||
const [topic, setTopic] = useState(channelData.topic);
|
||||
const [slowMode, setSlowMode] = useState(channelData.slowMode);
|
||||
const [nsfw, setNsfw] = useState(channelData.nsfw);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: '📋' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: '🔒' },
|
||||
{ id: 'invites', label: 'Invites', icon: '✉️' },
|
||||
{ id: 'integrations', label: 'Integrations', icon: '🔗' },
|
||||
];
|
||||
|
||||
const slowModeOptions = [
|
||||
{ value: 0, label: 'Off' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
{ value: 15, label: '15s' },
|
||||
{ value: 30, label: '30s' },
|
||||
{ value: 60, label: '1m' },
|
||||
{ value: 300, label: '5m' },
|
||||
{ value: 600, label: '10m' },
|
||||
{ value: 900, label: '15m' },
|
||||
{ value: 1800, label: '30m' },
|
||||
{ value: 3600, label: '1h' },
|
||||
{ value: 7200, label: '2h' },
|
||||
{ value: 21600, label: '6h' },
|
||||
];
|
||||
|
||||
const permissionOverrides = [
|
||||
{ id: 'founder', name: 'Founder', color: '#ff0000', type: 'role', allow: ['all'], deny: [] },
|
||||
{ id: 'mod', name: 'Moderator', color: '#5865f2', type: 'role', allow: ['manage_messages'], deny: [] },
|
||||
{ id: 'user123', name: 'SpecificUser', color: null, type: 'member', allow: [], deny: ['send_messages'] },
|
||||
];
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({ name, topic, slowMode, nsfw });
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="channel-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="settings-sidebar">
|
||||
<div className="settings-channel-info">
|
||||
<span className="channel-hash">#</span>
|
||||
<span className="settings-channel-name">{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-nav">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`settings-nav-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="nav-icon">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
<div className="settings-nav-item danger">
|
||||
<span className="nav-icon">🗑️</span>
|
||||
<span>Delete Channel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="settings-header">
|
||||
<h2>{tabs.find(t => t.id === activeTab)?.label}</h2>
|
||||
<button className="settings-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="channel-overview">
|
||||
<div className="setting-group">
|
||||
<label>Channel Name</label>
|
||||
<div className="channel-name-input">
|
||||
<span className="input-prefix">#</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Channel Topic</label>
|
||||
<textarea
|
||||
className="setting-textarea"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="Let everyone know what this channel is about"
|
||||
maxLength={1024}
|
||||
/>
|
||||
<span className="char-count">{topic.length}/1024</span>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Slowmode</label>
|
||||
<div className="slowmode-select">
|
||||
<select value={slowMode} onChange={(e) => setSlowMode(parseInt(e.target.value))}>
|
||||
{slowModeOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="setting-hint">Members can only send one message per this interval</p>
|
||||
</div>
|
||||
|
||||
<div className="setting-group toggle-group">
|
||||
<div className="toggle-info">
|
||||
<label>NSFW Channel</label>
|
||||
<p className="setting-hint">Users must verify their age to view content</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={nsfw} onChange={(e) => setNsfw(e.target.checked)} />
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Category</label>
|
||||
<select className="setting-select" defaultValue={channelData.category}>
|
||||
<option>Announcements</option>
|
||||
<option>Development</option>
|
||||
<option>Support</option>
|
||||
<option>Voice Channels</option>
|
||||
<option>No Category</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="channel-permissions">
|
||||
<div className="permissions-info">
|
||||
<p>Channel permissions override server-level permissions for specific roles or members.</p>
|
||||
</div>
|
||||
|
||||
<div className="permissions-section">
|
||||
<div className="section-header">
|
||||
<h3>Roles/Members</h3>
|
||||
<button className="add-override-btn">+ Add Role or Member</button>
|
||||
</div>
|
||||
|
||||
<div className="overrides-list">
|
||||
{permissionOverrides.map(override => (
|
||||
<div key={override.id} className="override-item">
|
||||
<div className="override-info">
|
||||
{override.type === 'role' ? (
|
||||
<span className="role-indicator" style={{ background: override.color }}>@</span>
|
||||
) : (
|
||||
<span className="member-indicator">👤</span>
|
||||
)}
|
||||
<span className="override-name" style={override.color ? { color: override.color } : {}}>
|
||||
{override.name}
|
||||
</span>
|
||||
<span className="override-type">{override.type}</span>
|
||||
</div>
|
||||
<div className="override-actions">
|
||||
<button className="edit-override">Edit</button>
|
||||
<button className="remove-override">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="permissions-section">
|
||||
<h3>Advanced Permissions</h3>
|
||||
<div className="advanced-perms-grid">
|
||||
<div className="perm-category">
|
||||
<h4>General Channel Permissions</h4>
|
||||
<div className="perm-item">
|
||||
<span>View Channel</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow">✓</button>
|
||||
<button className="perm-btn neutral active">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-item">
|
||||
<span>Manage Channel</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow">✓</button>
|
||||
<button className="perm-btn neutral active">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="perm-category">
|
||||
<h4>Text Channel Permissions</h4>
|
||||
<div className="perm-item">
|
||||
<span>Send Messages</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow active">✓</button>
|
||||
<button className="perm-btn neutral">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-item">
|
||||
<span>Embed Links</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow active">✓</button>
|
||||
<button className="perm-btn neutral">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-item">
|
||||
<span>Attach Files</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow">✓</button>
|
||||
<button className="perm-btn neutral active">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invites' && (
|
||||
<div className="channel-invites">
|
||||
<p className="no-invites">No invites have been created for this channel yet.</p>
|
||||
<button className="create-invite-btn">Create Invite</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="channel-integrations">
|
||||
<div className="integration-section">
|
||||
<h3>Webhooks</h3>
|
||||
<p className="integration-desc">Webhooks allow external services to send messages to this channel.</p>
|
||||
<button className="create-webhook-btn">Create Webhook</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-save-bar">
|
||||
<span>Careful — you have unsaved changes!</span>
|
||||
<div className="save-actions">
|
||||
<button className="reset-btn" onClick={onClose}>Reset</button>
|
||||
<button className="save-btn" onClick={handleSave}>Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import UserSettingsBar from './UserSettingsBar';
|
||||
import VoiceChannel from './VoiceChannel';
|
||||
|
||||
const channels = [
|
||||
{ category: 'Announcements', items: [
|
||||
{ id: 'updates', icon: '📢', name: 'updates', badge: 3 },
|
||||
{ id: 'changelog', icon: '📜', name: 'changelog' },
|
||||
]},
|
||||
{ category: 'Development', items: [
|
||||
{ id: 'general', icon: '#', name: 'general' },
|
||||
{ id: 'api-discussion', icon: '#', name: 'api-discussion' },
|
||||
{ id: 'passport-development', icon: '#', name: 'passport-development' },
|
||||
]},
|
||||
{ category: 'Support', items: [
|
||||
{ id: 'help', icon: '❓', name: 'help' },
|
||||
{ id: 'bug-reports', icon: '🐛', name: 'bug-reports' },
|
||||
]},
|
||||
];
|
||||
|
||||
const voiceChannels = [
|
||||
{ id: 'nexus-lounge', name: 'Nexus Lounge', users: [
|
||||
{ id: 1, name: 'Trevor', avatar: 'T', color: '#ff0000', speaking: true, muted: false },
|
||||
{ id: 2, name: 'Sarah', avatar: 'S', color: '#ffa500', speaking: false, muted: true },
|
||||
{ id: 3, name: 'DevUser_2847', avatar: 'D', color: '#666', speaking: false, muted: false, streaming: true },
|
||||
]},
|
||||
{ id: 'collab-space', name: 'Collab Space', users: [] },
|
||||
];
|
||||
|
||||
export default function ChannelSidebar({ onCreateChannel, onSettingsClick, onJoinVoice }) {
|
||||
const [activeChannel, setActiveChannel] = useState('general');
|
||||
const [expandedVoice, setExpandedVoice] = useState(['nexus-lounge']);
|
||||
const [hoveredCategory, setHoveredCategory] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="channel-sidebar">
|
||||
<div className="server-header">
|
||||
<span>AeThex Foundation</span>
|
||||
<span className="server-badge foundation">Official</span>
|
||||
</div>
|
||||
|
||||
<div className="channel-list">
|
||||
{channels.map((category) => (
|
||||
<div key={category.category}>
|
||||
<div
|
||||
className="channel-category"
|
||||
onMouseEnter={() => setHoveredCategory(category.category)}
|
||||
onMouseLeave={() => setHoveredCategory(null)}
|
||||
>
|
||||
{category.category}
|
||||
{hoveredCategory === category.category && onCreateChannel && (
|
||||
<button
|
||||
className="category-add-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onCreateChannel(); }}
|
||||
title="Create Channel"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{category.items.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`channel-item${activeChannel === channel.id ? ' active' : ''}`}
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
>
|
||||
<span className="channel-icon">{channel.icon}</span>
|
||||
<span className="channel-name">{channel.name}</span>
|
||||
{channel.badge && <span className="channel-badge">{channel.badge}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Voice Channels */}
|
||||
<div
|
||||
className="channel-category"
|
||||
onMouseEnter={() => setHoveredCategory('voice')}
|
||||
onMouseLeave={() => setHoveredCategory(null)}
|
||||
>
|
||||
Voice Channels
|
||||
{hoveredCategory === 'voice' && onCreateChannel && (
|
||||
<button
|
||||
className="category-add-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onCreateChannel(); }}
|
||||
title="Create Channel"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceChannels.map((vc) => (
|
||||
<VoiceChannel
|
||||
key={vc.id}
|
||||
channel={vc}
|
||||
expanded={expandedVoice.includes(vc.id)}
|
||||
onToggle={() => {
|
||||
setExpandedVoice((prev) =>
|
||||
prev.includes(vc.id)
|
||||
? prev.filter((id) => id !== vc.id)
|
||||
: [...prev, vc.id]
|
||||
);
|
||||
}}
|
||||
onJoin={onJoinVoice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<UserSettingsBar onSettingsClick={onSettingsClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,283 +0,0 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import MessageActions from './MessageActions';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
|
||||
const initialMessages = [
|
||||
{
|
||||
type: 'system',
|
||||
division: 'foundation',
|
||||
label: '[FOUNDATION] System Announcement',
|
||||
text: 'Foundation authentication services upgraded to v2.1.0. Enhanced security protocols now active across all AeThex infrastructure.',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 1,
|
||||
author: 'Trevor',
|
||||
badge: 'foundation',
|
||||
badgeLabel: 'Foundation',
|
||||
time: '10:34 AM',
|
||||
avatar: { initial: 'T', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)' },
|
||||
text: 'Just pushed the authentication updates. All services should automatically migrate to the new protocols within 24 hours.',
|
||||
reactions: [{ emoji: '🔥', count: 3, reacted: true }, { emoji: '👍', count: 2, reacted: false }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 2,
|
||||
author: 'Marcus',
|
||||
time: '10:41 AM',
|
||||
avatar: { initial: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)' },
|
||||
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.",
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
division: 'labs',
|
||||
label: '[LABS] Experimental Feature Alert',
|
||||
text: 'Nexus Engine v2.0-beta now available for testing. New cross-platform sync reduces latency by 40%. Join #labs-testing to participate.',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 3,
|
||||
author: 'Sarah',
|
||||
badge: 'labs',
|
||||
badgeLabel: 'Labs',
|
||||
time: '11:15 AM',
|
||||
avatar: { initial: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)' },
|
||||
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...',
|
||||
reactions: [{ emoji: '🚀', count: 5, reacted: true }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 4,
|
||||
author: 'Anderson',
|
||||
badge: 'foundation',
|
||||
badgeLabel: 'Founder',
|
||||
time: '11:47 AM',
|
||||
avatar: { initial: 'A', gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)' },
|
||||
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.',
|
||||
reactions: [{ emoji: '❤️', count: 8, reacted: false }, { emoji: '🔥', count: 4, reacted: true }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 5,
|
||||
author: 'DevUser_2847',
|
||||
time: '12:03 PM',
|
||||
avatar: { initial: 'D' },
|
||||
text: 'Quick question - when using AeThex Studio, does the Terminal automatically connect to all three Trinity divisions, or do I need to configure that?',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
division: 'corporation',
|
||||
label: '[CORPORATION] Service Update',
|
||||
text: 'AeThex Studio Pro users: New Railway deployment templates available. Optimized configurations for Foundation APIs, Corporation services, and Labs experiments.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatArea({ onOpenSearch, onOpenThread, onPinnedClick, onNotificationsClick, onContextMenu }) {
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [hoveredMessage, setHoveredMessage] = useState(null);
|
||||
const [typingUsers] = useState(['Sarah', 'Marcus']); // Simulated typing
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const newMessage = {
|
||||
type: 'message',
|
||||
id: Date.now(),
|
||||
author: 'You',
|
||||
time: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
|
||||
avatar: { initial: 'Y', gradient: 'linear-gradient(135deg, #0066ff, #003380)' },
|
||||
text: inputValue,
|
||||
};
|
||||
|
||||
setMessages([...messages, newMessage]);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
setInputValue((prev) => prev + emoji);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleReaction = (messageId, emoji) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== messageId) return msg;
|
||||
const reactions = msg.reactions || [];
|
||||
const existing = reactions.find((r) => r.emoji === emoji);
|
||||
if (existing) {
|
||||
return {
|
||||
...msg,
|
||||
reactions: reactions.map((r) =>
|
||||
r.emoji === emoji
|
||||
? { ...r, count: r.reacted ? r.count - 1 : r.count + 1, reacted: !r.reacted }
|
||||
: r
|
||||
).filter((r) => r.count > 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...msg,
|
||||
reactions: [...reactions, { emoji, count: 1, reacted: true }],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-area">
|
||||
<div className="chat-header">
|
||||
<span className="channel-name-header"># general</span>
|
||||
<div className="chat-tools">
|
||||
<span
|
||||
className="chat-tool"
|
||||
onClick={onNotificationsClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Notifications"
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
<span
|
||||
className="chat-tool"
|
||||
onClick={onPinnedClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Pinned Messages"
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
<span className="chat-tool">👥 128</span>
|
||||
<span className="chat-tool" onClick={onOpenSearch} style={{ cursor: 'pointer' }} title="Search">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, idx) => {
|
||||
if (msg.type === 'system') {
|
||||
return (
|
||||
<div key={`system-${idx}`} className={`message-system ${msg.division}`}>
|
||||
<div className={`system-label ${msg.division}`}>{msg.label}</div>
|
||||
<div>{msg.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="message"
|
||||
onMouseEnter={() => setHoveredMessage(msg.id)}
|
||||
onMouseLeave={() => setHoveredMessage(null)}
|
||||
>
|
||||
<div
|
||||
className="message-avatar"
|
||||
style={msg.avatar.gradient ? { background: msg.avatar.gradient } : undefined}
|
||||
>
|
||||
{msg.avatar.initial}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<span className="message-author">{msg.author}</span>
|
||||
{msg.badge && (
|
||||
<span className={`message-badge ${msg.badge}`}>{msg.badgeLabel}</span>
|
||||
)}
|
||||
<span className="message-time">{msg.time}</span>
|
||||
</div>
|
||||
<div className="message-text">{msg.text}</div>
|
||||
|
||||
{/* Reactions */}
|
||||
{msg.reactions && msg.reactions.length > 0 && (
|
||||
<div className="message-reactions" style={{ display: 'flex', gap: '6px', marginTop: '6px' }}>
|
||||
{msg.reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
onClick={() => handleReaction(msg.id, r.emoji)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
background: r.reacted ? 'rgba(88, 101, 242, 0.3)' : '#2f3136',
|
||||
border: r.reacted ? '1px solid #5865f2' : '1px solid transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85em',
|
||||
}}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span style={{ color: r.reacted ? '#5865f2' : '#b9bbbe' }}>{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Actions on Hover */}
|
||||
{hoveredMessage === msg.id && (
|
||||
<MessageActions
|
||||
onReact={(emoji) => handleReaction(msg.id, emoji)}
|
||||
onReply={() => onOpenThread && onOpenThread(msg)}
|
||||
onThread={() => onOpenThread && onOpenThread(msg)}
|
||||
isOwn={msg.author === 'You'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{typingUsers.length > 0 && <TypingIndicator users={typingUsers} />}
|
||||
|
||||
<div className="message-input-container" style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.2em',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="message-input"
|
||||
placeholder="Message #general (Foundation infrastructure channel)"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
style={{ paddingRight: '48px' }}
|
||||
/>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
onSelect={handleEmojiSelect}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default function ContextMenu({ x, y, type, data, onClose, onAction }) {
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const menuItems = {
|
||||
message: [
|
||||
{ id: 'add-reaction', label: 'Add Reaction', icon: '😀' },
|
||||
{ divider: true },
|
||||
{ id: 'reply', label: 'Reply', icon: '↩️' },
|
||||
{ id: 'create-thread', label: 'Create Thread', icon: '🧵' },
|
||||
{ divider: true },
|
||||
{ id: 'edit', label: 'Edit Message', icon: '✏️', ownerOnly: true },
|
||||
{ id: 'pin', label: 'Pin Message', icon: '📌' },
|
||||
{ id: 'mark-unread', label: 'Mark Unread', icon: '📩' },
|
||||
{ id: 'copy-text', label: 'Copy Text', icon: '📋' },
|
||||
{ id: 'copy-link', label: 'Copy Message Link', icon: '🔗' },
|
||||
{ divider: true },
|
||||
{ id: 'speak', label: 'Speak Message', icon: '🔊' },
|
||||
{ divider: true },
|
||||
{ id: 'delete', label: 'Delete Message', icon: '🗑️', danger: true, ownerOnly: true },
|
||||
{ id: 'report', label: 'Report Message', icon: '🚩', danger: true },
|
||||
],
|
||||
user: [
|
||||
{ id: 'profile', label: 'Profile', icon: '👤' },
|
||||
{ id: 'mention', label: 'Mention', icon: '@' },
|
||||
{ id: 'message', label: 'Message', icon: '💬' },
|
||||
{ divider: true },
|
||||
{ id: 'call', label: 'Call', icon: '📞' },
|
||||
{ id: 'video-call', label: 'Video Call', icon: '📹' },
|
||||
{ divider: true },
|
||||
{ id: 'add-note', label: 'Add Note', icon: '📝' },
|
||||
{ id: 'add-friend', label: 'Add Friend', icon: '👋' },
|
||||
{ divider: true },
|
||||
{ id: 'invite', label: 'Invite to Server', icon: '➕', submenu: true },
|
||||
{ divider: true },
|
||||
{ id: 'mute', label: 'Mute', icon: '🔇' },
|
||||
{ id: 'block', label: 'Block', icon: '🚫', danger: true },
|
||||
{ id: 'report', label: 'Report', icon: '🚩', danger: true },
|
||||
],
|
||||
channel: [
|
||||
{ id: 'mark-read', label: 'Mark As Read', icon: '✓' },
|
||||
{ divider: true },
|
||||
{ id: 'mute', label: 'Mute Channel', icon: '🔇', submenu: true },
|
||||
{ id: 'notification-settings', label: 'Notification Settings', icon: '🔔' },
|
||||
{ divider: true },
|
||||
{ id: 'edit', label: 'Edit Channel', icon: '✏️' },
|
||||
{ id: 'duplicate', label: 'Duplicate Channel', icon: '📋' },
|
||||
{ id: 'create-invite', label: 'Create Invite', icon: '✉️' },
|
||||
{ divider: true },
|
||||
{ id: 'copy-link', label: 'Copy Channel Link', icon: '🔗' },
|
||||
{ id: 'copy-id', label: 'Copy Channel ID', icon: '#️⃣' },
|
||||
{ divider: true },
|
||||
{ id: 'delete', label: 'Delete Channel', icon: '🗑️', danger: true },
|
||||
],
|
||||
server: [
|
||||
{ id: 'mark-read', label: 'Mark As Read', icon: '✓' },
|
||||
{ divider: true },
|
||||
{ id: 'invite-people', label: 'Invite People', icon: '👋' },
|
||||
{ divider: true },
|
||||
{ id: 'mute', label: 'Mute Server', icon: '🔇', submenu: true },
|
||||
{ id: 'notification-settings', label: 'Notification Settings', icon: '🔔' },
|
||||
{ id: 'hide-muted', label: 'Hide Muted Channels', icon: '👁️' },
|
||||
{ divider: true },
|
||||
{ id: 'privacy-settings', label: 'Privacy Settings', icon: '🔒' },
|
||||
{ id: 'edit-nickname', label: 'Edit Server Nickname', icon: '✏️' },
|
||||
{ divider: true },
|
||||
{ id: 'server-settings', label: 'Server Settings', icon: '⚙️' },
|
||||
{ id: 'create-channel', label: 'Create Channel', icon: '➕' },
|
||||
{ id: 'create-category', label: 'Create Category', icon: '📁' },
|
||||
{ divider: true },
|
||||
{ id: 'copy-id', label: 'Copy Server ID', icon: '#️⃣' },
|
||||
{ divider: true },
|
||||
{ id: 'leave', label: 'Leave Server', icon: '🚪', danger: true },
|
||||
],
|
||||
};
|
||||
|
||||
const items = menuItems[type] || [];
|
||||
const isOwn = data?.isOwn;
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
const adjustedStyle = {
|
||||
position: 'fixed',
|
||||
top: y,
|
||||
left: x,
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-menu" ref={menuRef} style={adjustedStyle}>
|
||||
{items.map((item, idx) => {
|
||||
if (item.divider) {
|
||||
return <div key={`divider-${idx}`} className="context-menu-divider" />;
|
||||
}
|
||||
|
||||
if (item.ownerOnly && !isOwn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`context-menu-item ${item.danger ? 'danger' : ''}`}
|
||||
onClick={() => {
|
||||
onAction?.(item.id, data);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<span className="context-menu-icon">{item.icon}</span>
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.submenu && <span className="context-menu-arrow">▶</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function CreateChannelModal({ category, onClose, onCreate }) {
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
|
||||
const channelTypes = [
|
||||
{ id: 'text', icon: '#', label: 'Text', description: 'Send messages, images, GIFs, emoji, opinions, and puns' },
|
||||
{ id: 'voice', icon: '🔊', label: 'Voice', description: 'Hang out together with voice, video, and screen share' },
|
||||
{ id: 'forum', icon: '💬', label: 'Forum', description: 'Create a space for organized discussions' },
|
||||
{ id: 'announcement', icon: '📢', label: 'Announcement', description: 'Important updates followers can subscribe to' },
|
||||
{ id: 'stage', icon: '🎭', label: 'Stage', description: 'Host events with moderated discussions' },
|
||||
];
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!channelName.trim()) return;
|
||||
|
||||
onCreate?.({
|
||||
type: channelType,
|
||||
name: channelName.toLowerCase().replace(/\s+/g, '-'),
|
||||
isPrivate,
|
||||
category,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create Channel</h2>
|
||||
{category && <span className="category-name">in {category}</span>}
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="channel-type-section">
|
||||
<label className="section-label">CHANNEL TYPE</label>
|
||||
<div className="channel-types">
|
||||
{channelTypes.map((type) => (
|
||||
<label
|
||||
key={type.id}
|
||||
className={`channel-type-option ${channelType === type.id ? 'selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="channelType"
|
||||
value={type.id}
|
||||
checked={channelType === type.id}
|
||||
onChange={() => setChannelType(type.id)}
|
||||
/>
|
||||
<span className="type-icon">{type.icon}</span>
|
||||
<div className="type-info">
|
||||
<span className="type-label">{type.label}</span>
|
||||
<span className="type-description">{type.description}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-name-section">
|
||||
<label className="section-label">CHANNEL NAME</label>
|
||||
<div className="channel-name-input">
|
||||
<span className="channel-prefix">
|
||||
{channelType === 'text' ? '#' :
|
||||
channelType === 'voice' ? '🔊' :
|
||||
channelType === 'forum' ? '💬' :
|
||||
channelType === 'announcement' ? '📢' : '🎭'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="new-channel"
|
||||
value={channelName}
|
||||
onChange={(e) => setChannelName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-privacy-section">
|
||||
<label className="privacy-toggle">
|
||||
<div className="privacy-info">
|
||||
<span className="privacy-icon">🔒</span>
|
||||
<div>
|
||||
<span className="privacy-label">Private Channel</span>
|
||||
<span className="privacy-description">
|
||||
Only selected members and roles will be able to view this channel.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="modal-cancel" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="modal-submit"
|
||||
onClick={handleCreate}
|
||||
disabled={!channelName.trim()}
|
||||
>
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,242 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const friends = [
|
||||
{ id: 1, name: 'Trevor', tag: 'Trevor#0042', avatar: 'T', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)', status: 'online', activity: 'Working on AeThex' },
|
||||
{ id: 2, name: 'Sarah', tag: 'Sarah#1337', avatar: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)', status: 'online', activity: 'Playing Valorant' },
|
||||
{ id: 3, name: 'Marcus', tag: 'Marcus#2048', avatar: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)', status: 'idle' },
|
||||
{ id: 4, name: 'DevUser_2847', tag: 'DevUser_2847#2847', avatar: 'D', status: 'dnd', activity: 'Do Not Disturb' },
|
||||
{ id: 5, name: 'JohnDev', tag: 'JohnDev#9999', avatar: 'J', status: 'offline' },
|
||||
];
|
||||
|
||||
const dmConversations = [
|
||||
{ id: 1, user: friends[0], lastMessage: 'The auth system is ready!', time: '2m', unread: 2 },
|
||||
{ id: 2, user: friends[1], lastMessage: 'Check out the new Nexus build', time: '15m', unread: 0 },
|
||||
{ id: 3, user: friends[2], lastMessage: 'Thanks for the help!', time: '1h', unread: 0 },
|
||||
{ id: 4, user: { name: 'Group Chat', avatar: '👥', isGroup: true, members: ['Trevor', 'Sarah', 'Marcus'] }, lastMessage: 'Sarah: Let\'s sync up tomorrow', time: '3h', unread: 5 },
|
||||
];
|
||||
|
||||
const pendingRequests = [
|
||||
{ id: 1, name: 'NewDev123', tag: 'NewDev123#4567', avatar: 'N', type: 'incoming', mutualServers: 2 },
|
||||
{ id: 2, name: 'CoolCoder', tag: 'CoolCoder#8899', avatar: 'C', type: 'outgoing' },
|
||||
];
|
||||
|
||||
export default function DMsView({ onSelectDM, onOpenProfile }) {
|
||||
const [activeTab, setActiveTab] = useState('online');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'pending', label: 'Pending', count: pendingRequests.length },
|
||||
{ id: 'blocked', label: 'Blocked' },
|
||||
{ id: 'add', label: 'Add Friend', isAction: true },
|
||||
];
|
||||
|
||||
const statusColors = {
|
||||
online: '#3ba55d',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const filteredFriends = friends.filter((f) => {
|
||||
if (activeTab === 'online') return f.status === 'online';
|
||||
if (activeTab === 'all') return true;
|
||||
return true;
|
||||
}).filter((f) =>
|
||||
f.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dms-view">
|
||||
{/* DMs Sidebar */}
|
||||
<div className="dms-sidebar">
|
||||
<div className="dms-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find or start a conversation"
|
||||
className="dms-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dms-section">
|
||||
<div className="dms-section-header">
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
<div
|
||||
className="dms-nav-item"
|
||||
onClick={() => setActiveTab('online')}
|
||||
>
|
||||
<span className="nav-icon">👥</span>
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
<div className="dms-nav-item">
|
||||
<span className="nav-icon">🚀</span>
|
||||
<span>Nitro</span>
|
||||
</div>
|
||||
<div className="dms-nav-item">
|
||||
<span className="nav-icon">🛒</span>
|
||||
<span>Shop</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dms-section">
|
||||
<div className="dms-section-header">
|
||||
<span>Direct Messages</span>
|
||||
<button className="add-dm-btn">+</button>
|
||||
</div>
|
||||
|
||||
{dmConversations.map((dm) => (
|
||||
<div
|
||||
key={dm.id}
|
||||
className="dm-item"
|
||||
onClick={() => onSelectDM?.(dm)}
|
||||
>
|
||||
<div className="dm-avatar" style={{ background: dm.user.gradient || '#36393f' }}>
|
||||
{dm.user.avatar}
|
||||
{!dm.user.isGroup && (
|
||||
<div
|
||||
className="dm-status"
|
||||
style={{ background: statusColors[dm.user.status] || statusColors.offline }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dm-info">
|
||||
<div className="dm-name">
|
||||
{dm.user.name}
|
||||
{dm.user.isGroup && <span className="group-count">{dm.user.members?.length}</span>}
|
||||
</div>
|
||||
<div className="dm-preview">{dm.lastMessage}</div>
|
||||
</div>
|
||||
{dm.unread > 0 && (
|
||||
<div className="dm-unread">{dm.unread}</div>
|
||||
)}
|
||||
<button className="dm-close" onClick={(e) => { e.stopPropagation(); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Friends List Panel */}
|
||||
<div className="friends-panel">
|
||||
<div className="friends-header">
|
||||
<div className="friends-title">
|
||||
<span className="friends-icon">👥</span>
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
<div className="friends-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`friends-tab ${activeTab === tab.id ? 'active' : ''} ${tab.isAction ? 'action' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 && <span className="tab-count">{tab.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'add' ? (
|
||||
<div className="add-friend-panel">
|
||||
<h2>Add Friend</h2>
|
||||
<p>You can add friends with their AeThex username.</p>
|
||||
<div className="add-friend-input">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a Username#0000"
|
||||
className="friend-input"
|
||||
/>
|
||||
<button className="send-request-btn">Send Friend Request</button>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'pending' ? (
|
||||
<div className="pending-requests">
|
||||
<div className="friends-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="friends-count">PENDING — {pendingRequests.length}</div>
|
||||
{pendingRequests.map((req) => (
|
||||
<div key={req.id} className="friend-item">
|
||||
<div className="friend-avatar">{req.avatar}</div>
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{req.name}</div>
|
||||
<div className="friend-tag">
|
||||
{req.type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="friend-actions">
|
||||
{req.type === 'incoming' && (
|
||||
<>
|
||||
<button className="friend-action accept">✓</button>
|
||||
<button className="friend-action decline">✕</button>
|
||||
</>
|
||||
)}
|
||||
{req.type === 'outgoing' && (
|
||||
<button className="friend-action decline">✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="friends-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="friends-count">
|
||||
{activeTab === 'online' ? 'ONLINE' : 'ALL FRIENDS'} — {filteredFriends.length}
|
||||
</div>
|
||||
<div className="friends-list">
|
||||
{filteredFriends.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="friend-item"
|
||||
onClick={() => onOpenProfile?.(friend)}
|
||||
>
|
||||
<div className="friend-avatar" style={{ background: friend.gradient || '#36393f' }}>
|
||||
{friend.avatar}
|
||||
<div
|
||||
className="friend-status"
|
||||
style={{ background: statusColors[friend.status] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{friend.name}</div>
|
||||
<div className="friend-activity">{friend.activity || friend.status}</div>
|
||||
</div>
|
||||
<div className="friend-actions">
|
||||
<button className="friend-action" title="Message">💬</button>
|
||||
<button className="friend-action" title="Voice Call">📞</button>
|
||||
<button className="friend-action" title="Video Call">📹</button>
|
||||
<button className="friend-action" title="More">⋯</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity/Now Playing Panel */}
|
||||
<div className="activity-panel">
|
||||
<h3>Active Now</h3>
|
||||
<div className="activity-placeholder">
|
||||
<p>It's quiet for now...</p>
|
||||
<p className="activity-hint">When a friend starts an activity—like playing a game or hanging out on voice—we'll show it here!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const emojiCategories = [
|
||||
{ id: 'recent', icon: '🕐', name: 'Recent', emojis: ['👍', '❤️', '😂', '🔥', '✅', '👀', '🎉', '💯'] },
|
||||
{ id: 'smileys', icon: '😀', name: 'Smileys', emojis: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘', '😗', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '😮', '🤤', '😪', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🥵', '🥶', '😵', '🤯', '😎', '🥳', '😱', '😨', '😰', '😥', '😢', '😭', '😤', '😡', '🤬'] },
|
||||
{ id: 'people', icon: '👋', name: 'People', emojis: ['👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️', '👅', '👄'] },
|
||||
{ id: 'nature', icon: '🌿', name: 'Nature', emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🌸', '🌺', '🌻', '🌹', '🌷', '🌲', '🌳', '🌴', '🌵', '🌾', '☀️', '🌙', '⭐', '🌈', '☁️', '⛈️', '❄️', '🔥', '💧'] },
|
||||
{ id: 'food', icon: '🍔', name: 'Food', emojis: ['🍎', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🌽', '🌶️', '🫑', '🥒', '🥬', '🧄', '🧅', '🥔', '🍠', '🥐', '🍞', '🥖', '🥨', '🧀', '🍖', '🍗', '🥩', '🥓', '🍔', '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🫔', '🥙', '🧆', '🥚', '🍳', '🥘', '🍲', '🫕', '🥣', '🥗', '🍿', '🧈', '🧂', '🥫'] },
|
||||
{ id: 'activities', icon: '⚽', name: 'Activities', emojis: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🎮', '🕹️', '🎲', '🧩', '♟️', '🎯', '🎳', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻'] },
|
||||
{ id: 'objects', icon: '💡', name: 'Objects', emojis: ['💻', '🖥️', '🖨️', '⌨️', '🖱️', '💽', '💾', '💿', '📀', '📱', '📲', '☎️', '📞', '📟', '📠', '🔋', '🔌', '💡', '🔦', '🕯️', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🪛', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺'] },
|
||||
{ id: 'symbols', icon: '❤️', name: 'Symbols', emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️'] },
|
||||
{ id: 'custom', icon: '⭐', name: 'AeThex', emojis: ['🔴', '🔵', '🟠', '⚡', '🛡️', '🧪', '🏢', '🚀', '💻', '🔐', '🌐', '⚙️'] },
|
||||
];
|
||||
|
||||
export default function EmojiPicker({ onSelect, onClose }) {
|
||||
const [activeCategory, setActiveCategory] = useState('smileys');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const currentCategory = emojiCategories.find(c => c.id === activeCategory);
|
||||
const filteredEmojis = searchQuery
|
||||
? emojiCategories.flatMap(c => c.emojis)
|
||||
: currentCategory?.emojis || [];
|
||||
|
||||
return (
|
||||
<div className="emoji-picker">
|
||||
<div className="emoji-picker-header">
|
||||
<input
|
||||
type="text"
|
||||
className="emoji-search"
|
||||
placeholder="Search emoji..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="emoji-categories">
|
||||
{emojiCategories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`emoji-category-btn ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => { setActiveCategory(cat.id); setSearchQuery(''); }}
|
||||
title={cat.name}
|
||||
>
|
||||
{cat.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="emoji-grid">
|
||||
{filteredEmojis.slice(0, 64).map((emoji, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="emoji-btn"
|
||||
onClick={() => onSelect?.(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="emoji-picker-footer">
|
||||
<span className="emoji-preview">
|
||||
{currentCategory?.name || 'Search Results'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
|
||||
const existingEmojis = [
|
||||
{ id: 1, name: 'pepehype', image: '🐸', animated: false },
|
||||
{ id: 2, name: 'monkaS', image: '😰', animated: false },
|
||||
{ id: 3, name: 'KEKW', image: '🤣', animated: false },
|
||||
{ id: 4, name: 'Pog', image: '😮', animated: true },
|
||||
{ id: 5, name: 'catJAM', image: '🐱', animated: true },
|
||||
{ id: 6, name: 'aethex_logo', image: '🔺', animated: false },
|
||||
];
|
||||
|
||||
const existingStickers = [
|
||||
{ id: 1, name: 'Wave', emoji: '👋', description: 'A friendly wave' },
|
||||
{ id: 2, name: 'Love', emoji: '❤️', description: 'Show some love' },
|
||||
{ id: 3, name: 'Party', emoji: '🎉', description: 'Party time!' },
|
||||
];
|
||||
|
||||
export default function EmojiUpload({ type = 'emoji', onUpload, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState(type);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
const [emojiName, setEmojiName] = useState('');
|
||||
const [stickerDescription, setStickerDescription] = useState('');
|
||||
const [relatedEmoji, setRelatedEmoji] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
const newFiles = files.map(file => ({
|
||||
id: Date.now() + Math.random(),
|
||||
file,
|
||||
name: file.name.split('.')[0],
|
||||
preview: URL.createObjectURL(file),
|
||||
size: (file.size / 1024).toFixed(1) + ' KB',
|
||||
animated: file.type === 'image/gif',
|
||||
}));
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const newFiles = files.map(file => ({
|
||||
id: Date.now() + Math.random(),
|
||||
file,
|
||||
name: file.name.split('.')[0],
|
||||
preview: URL.createObjectURL(file),
|
||||
size: (file.size / 1024).toFixed(1) + ' KB',
|
||||
animated: file.type === 'image/gif',
|
||||
}));
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (fileId) => {
|
||||
setUploadedFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
};
|
||||
|
||||
const updateFileName = (fileId, name) => {
|
||||
setUploadedFiles(prev => prev.map(f =>
|
||||
f.id === fileId ? { ...f, name } : f
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="emoji-upload-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="emoji-upload-header">
|
||||
<h2>Upload {activeTab === 'emoji' ? 'Emoji' : 'Sticker'}</h2>
|
||||
<button className="close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="emoji-upload-tabs">
|
||||
<button
|
||||
className={`upload-tab ${activeTab === 'emoji' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('emoji')}
|
||||
>
|
||||
😀 Emoji
|
||||
</button>
|
||||
<button
|
||||
className={`upload-tab ${activeTab === 'sticker' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('sticker')}
|
||||
>
|
||||
🎨 Sticker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="emoji-upload-content">
|
||||
{/* Existing items */}
|
||||
<div className="existing-items">
|
||||
<h3>{activeTab === 'emoji' ? 'Server Emoji' : 'Server Stickers'}</h3>
|
||||
<div className="items-grid">
|
||||
{activeTab === 'emoji' ? (
|
||||
existingEmojis.map(emoji => (
|
||||
<div key={emoji.id} className="item-card">
|
||||
<div className="item-preview">{emoji.image}</div>
|
||||
<span className="item-name">:{emoji.name}:</span>
|
||||
{emoji.animated && <span className="animated-badge">GIF</span>}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
existingStickers.map(sticker => (
|
||||
<div key={sticker.id} className="item-card sticker">
|
||||
<div className="item-preview">{sticker.emoji}</div>
|
||||
<span className="item-name">{sticker.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="slots-info">
|
||||
{activeTab === 'emoji' ? (
|
||||
<span>6/50 slots used ({existingEmojis.filter(e => e.animated).length}/50 animated)</span>
|
||||
) : (
|
||||
<span>3/15 sticker slots used</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
className="upload-zone"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={activeTab === 'emoji' ? 'image/png,image/gif' : 'image/png,image/apng'}
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
hidden
|
||||
/>
|
||||
<div className="upload-icon">📤</div>
|
||||
<p>Drag and drop or click to upload</p>
|
||||
<span className="upload-hint">
|
||||
{activeTab === 'emoji'
|
||||
? 'PNG or GIF, 256KB max, 128x128 recommended'
|
||||
: 'PNG or APNG, 512KB max, 320x320 size'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pending uploads */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="pending-uploads">
|
||||
<h3>Ready to Upload</h3>
|
||||
{uploadedFiles.map(file => (
|
||||
<div key={file.id} className="pending-file">
|
||||
<img src={file.preview} alt="" className="pending-preview" />
|
||||
<div className="pending-info">
|
||||
<input
|
||||
type="text"
|
||||
value={file.name}
|
||||
onChange={(e) => updateFileName(file.id, e.target.value)}
|
||||
placeholder="Name"
|
||||
className="pending-name-input"
|
||||
/>
|
||||
<span className="pending-size">{file.size}</span>
|
||||
{file.animated && <span className="animated-badge">GIF</span>}
|
||||
</div>
|
||||
<button
|
||||
className="pending-remove"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="emoji-upload-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="upload-btn"
|
||||
disabled={uploadedFiles.length === 0}
|
||||
onClick={() => onUpload?.(uploadedFiles)}
|
||||
>
|
||||
Upload {uploadedFiles.length > 0 ? `(${uploadedFiles.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Monthly Community Townhall',
|
||||
description: 'Join us for our monthly Q&A session with the AeThex team. Ask questions, get updates, and connect with the community.',
|
||||
type: 'stage',
|
||||
channel: 'community-stage',
|
||||
startTime: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours from now
|
||||
endTime: new Date(Date.now() + 4 * 60 * 60 * 1000),
|
||||
host: { name: 'Anderson', avatar: 'A', color: '#ff0000' },
|
||||
interested: 234,
|
||||
image: null,
|
||||
recurring: 'monthly',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Game Night: Among Us',
|
||||
description: 'Weekly game night! This week we\'re playing Among Us. Join the voice channel 10 minutes early.',
|
||||
type: 'voice',
|
||||
channel: 'gaming-voice',
|
||||
startTime: new Date(Date.now() + 26 * 60 * 60 * 1000), // Tomorrow
|
||||
endTime: new Date(Date.now() + 29 * 60 * 60 * 1000),
|
||||
host: { name: 'Sarah', avatar: 'S', color: '#ffa500' },
|
||||
interested: 89,
|
||||
image: null,
|
||||
recurring: 'weekly',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'AeThex SDK Workshop',
|
||||
description: 'Learn how to integrate the AeThex SDK into your projects. Hands-on coding session with live Q&A.',
|
||||
type: 'external',
|
||||
location: 'https://workshop.aethex.com',
|
||||
startTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3 days
|
||||
endTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000 + 2 * 60 * 60 * 1000),
|
||||
host: { name: 'Trevor', avatar: 'T', color: '#0066ff' },
|
||||
interested: 456,
|
||||
image: null,
|
||||
recurring: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EventsPanel({ onClose, onCreateEvent }) {
|
||||
const [activeTab, setActiveTab] = useState('upcoming');
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
const formatDate = (date) => {
|
||||
const now = new Date();
|
||||
const diff = date - now;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return `In ${minutes} minutes`;
|
||||
}
|
||||
return `In ${hours} hours`;
|
||||
} else if (days === 1) {
|
||||
return 'Tomorrow';
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'stage': return '📢';
|
||||
case 'voice': return '🔊';
|
||||
case 'external': return '🔗';
|
||||
default: return '📅';
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedEvent) {
|
||||
return (
|
||||
<EventDetail
|
||||
event={selectedEvent}
|
||||
onBack={() => setSelectedEvent(null)}
|
||||
formatDate={formatDate}
|
||||
formatTime={formatTime}
|
||||
getTypeIcon={getTypeIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="events-panel">
|
||||
<div className="events-header">
|
||||
<h2>Events</h2>
|
||||
<div className="events-actions">
|
||||
<button className="create-event-btn" onClick={onCreateEvent}>
|
||||
+ Create Event
|
||||
</button>
|
||||
<button className="events-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="events-tabs">
|
||||
<button
|
||||
className={`events-tab ${activeTab === 'upcoming' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('upcoming')}
|
||||
>
|
||||
Upcoming
|
||||
</button>
|
||||
<button
|
||||
className={`events-tab ${activeTab === 'recurring' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('recurring')}
|
||||
>
|
||||
Recurring
|
||||
</button>
|
||||
<button
|
||||
className={`events-tab ${activeTab === 'past' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('past')}
|
||||
>
|
||||
Past
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="events-list">
|
||||
{mockEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="event-card"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
<div className="event-date-badge">
|
||||
<span className="event-month">
|
||||
{event.startTime.toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
<span className="event-day">
|
||||
{event.startTime.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="event-info">
|
||||
<div className="event-timing">
|
||||
<span className="event-time-label">{formatDate(event.startTime)}</span>
|
||||
<span className="event-time">
|
||||
{formatTime(event.startTime)} - {formatTime(event.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="event-title">
|
||||
<span className="event-type-icon">{getTypeIcon(event.type)}</span>
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
<p className="event-description">{event.description}</p>
|
||||
|
||||
<div className="event-meta">
|
||||
<div className="event-host">
|
||||
<div
|
||||
className="host-avatar"
|
||||
style={event.host.color ? { background: event.host.color } : undefined}
|
||||
>
|
||||
{event.host.avatar}
|
||||
</div>
|
||||
<span>Hosted by {event.host.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="event-interested">
|
||||
<span className="interested-count">⭐ {event.interested} interested</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.recurring && (
|
||||
<div className="recurring-badge">
|
||||
🔄 Repeats {event.recurring}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mockEvents.length === 0 && (
|
||||
<div className="events-empty">
|
||||
<span className="empty-icon">📅</span>
|
||||
<h3>No upcoming events</h3>
|
||||
<p>Create an event to bring your community together!</p>
|
||||
<button className="create-event-btn" onClick={onCreateEvent}>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetail({ event, onBack, formatDate, formatTime, getTypeIcon }) {
|
||||
const [isInterested, setIsInterested] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="event-detail">
|
||||
<button className="back-btn" onClick={onBack}>
|
||||
← Back to Events
|
||||
</button>
|
||||
|
||||
<div className="event-detail-header">
|
||||
<div className="event-type-badge">
|
||||
{getTypeIcon(event.type)} {event.type.charAt(0).toUpperCase() + event.type.slice(1)}
|
||||
</div>
|
||||
<h1>{event.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="event-detail-content">
|
||||
<div className="detail-section">
|
||||
<h4>📅 Date & Time</h4>
|
||||
<p>{formatDate(event.startTime)}</p>
|
||||
<p className="time-range">
|
||||
{formatTime(event.startTime)} - {formatTime(event.endTime)}
|
||||
</p>
|
||||
{event.recurring && (
|
||||
<p className="recurring-info">🔄 Repeats {event.recurring}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>📍 Location</h4>
|
||||
{event.type === 'external' ? (
|
||||
<a href={event.location} className="external-link">
|
||||
{event.location}
|
||||
</a>
|
||||
) : (
|
||||
<p>#{event.channel}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>📝 Description</h4>
|
||||
<p>{event.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>👤 Host</h4>
|
||||
<div className="host-info">
|
||||
<div
|
||||
className="host-avatar large"
|
||||
style={event.host.color ? { background: event.host.color } : undefined}
|
||||
>
|
||||
{event.host.avatar}
|
||||
</div>
|
||||
<span>{event.host.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="interested-section">
|
||||
<span>⭐ {event.interested + (isInterested ? 1 : 0)} interested</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="event-detail-actions">
|
||||
<button
|
||||
className={`interested-btn ${isInterested ? 'active' : ''}`}
|
||||
onClick={() => setIsInterested(!isInterested)}
|
||||
>
|
||||
{isInterested ? '⭐ Interested' : '☆ Mark as Interested'}
|
||||
</button>
|
||||
<button className="share-event-btn">
|
||||
📤 Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Event creation modal
|
||||
export function CreateEventModal({ onSubmit, onClose }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState('voice');
|
||||
const [date, setDate] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title.trim() || !date || !startTime) return;
|
||||
|
||||
onSubmit?.({
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-event-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create Event</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="form-group">
|
||||
<label>Event Type</label>
|
||||
<div className="event-type-options">
|
||||
<button
|
||||
className={`type-option ${type === 'stage' ? 'active' : ''}`}
|
||||
onClick={() => setType('stage')}
|
||||
>
|
||||
📢 Stage
|
||||
</button>
|
||||
<button
|
||||
className={`type-option ${type === 'voice' ? 'active' : ''}`}
|
||||
onClick={() => setType('voice')}
|
||||
>
|
||||
🔊 Voice
|
||||
</button>
|
||||
<button
|
||||
className={`type-option ${type === 'external' ? 'active' : ''}`}
|
||||
onClick={() => setType('external')}
|
||||
>
|
||||
🔗 External
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Event Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What's your event called?"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
placeholder="Tell people what this event is about..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={!title.trim() || !date || !startTime}
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
import React, { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export default function FileUpload({ onUpload, onClose, maxSize = 25 }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [isSpoiler, setIsSpoiler] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
addFiles(droppedFiles);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
addFiles(selectedFiles);
|
||||
};
|
||||
|
||||
const addFiles = (newFiles) => {
|
||||
const processedFiles = newFiles.map((file) => ({
|
||||
file,
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
||||
progress: 100, // Simulated upload progress
|
||||
}));
|
||||
|
||||
setFiles((prev) => [...prev, ...processedFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (id) => {
|
||||
setFiles((prev) => {
|
||||
const removed = prev.find((f) => f.id === id);
|
||||
if (removed?.preview) {
|
||||
URL.revokeObjectURL(removed.preview);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
if (type.startsWith('image/')) return '🖼️';
|
||||
if (type.startsWith('video/')) return '🎬';
|
||||
if (type.startsWith('audio/')) return '🎵';
|
||||
if (type.includes('pdf')) return '📄';
|
||||
if (type.includes('zip') || type.includes('rar')) return '📦';
|
||||
if (type.includes('text') || type.includes('document')) return '📝';
|
||||
return '📎';
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
onUpload?.({
|
||||
files,
|
||||
comment,
|
||||
isSpoiler,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||
const isOverLimit = totalSize > maxSize * 1024 * 1024;
|
||||
|
||||
return (
|
||||
<div className="file-upload-modal">
|
||||
<div className="file-upload-header">
|
||||
<h3>Upload to #general</h3>
|
||||
<button className="upload-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`file-drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="drop-zone-content">
|
||||
<span className="drop-icon">📁</span>
|
||||
<p>Drag & drop files here or click to browse</p>
|
||||
<span className="drop-hint">Max file size: {maxSize} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="file-list">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="file-item">
|
||||
{file.preview ? (
|
||||
<img src={file.preview} alt={file.name} className="file-preview" />
|
||||
) : (
|
||||
<div className="file-icon">{getFileIcon(file.type)}</div>
|
||||
)}
|
||||
<div className="file-info">
|
||||
<div className="file-name">{file.name}</div>
|
||||
<div className="file-size">{formatSize(file.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="file-remove"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="upload-options">
|
||||
<label className="spoiler-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSpoiler}
|
||||
onChange={(e) => setIsSpoiler(e.target.checked)}
|
||||
/>
|
||||
<span>Mark as spoiler</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="upload-comment">
|
||||
<textarea
|
||||
placeholder="Add a comment (optional)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="upload-warning">
|
||||
Total size exceeds {maxSize} MB limit. Some files may not upload.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="upload-footer">
|
||||
<div className="upload-summary">
|
||||
{files.length} file{files.length !== 1 ? 's' : ''} • {formatSize(totalSize)}
|
||||
</div>
|
||||
<div className="upload-actions">
|
||||
<button className="upload-cancel" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="upload-submit"
|
||||
disabled={files.length === 0}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockPosts = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'How to set up AeThex Passport for your game?',
|
||||
author: { name: 'Trevor', avatar: 'T', color: '#ff0000' },
|
||||
content: 'I want to integrate Passport authentication into my Unity game. What are the steps?',
|
||||
tags: ['question', 'passport', 'unity'],
|
||||
replies: 12,
|
||||
reactions: { '👍': 24, '❤️': 5 },
|
||||
pinned: true,
|
||||
createdAt: '2 hours ago',
|
||||
lastActivity: '15 minutes ago',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '[Tutorial] Complete Nexus SDK Integration Guide',
|
||||
author: { name: 'Sarah', avatar: 'S', color: '#ffa500' },
|
||||
content: 'Step-by-step guide for integrating the Nexus SDK into your application...',
|
||||
tags: ['tutorial', 'nexus', 'sdk'],
|
||||
replies: 45,
|
||||
reactions: { '🔥': 89, '👍': 67, '❤️': 23 },
|
||||
pinned: true,
|
||||
createdAt: '1 day ago',
|
||||
lastActivity: '30 minutes ago',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Bug: Voice chat not working on Linux',
|
||||
author: { name: 'DevUser_456', avatar: 'D' },
|
||||
content: 'Getting an error when trying to join voice channels on Ubuntu 22.04...',
|
||||
tags: ['bug', 'linux', 'voice'],
|
||||
replies: 8,
|
||||
reactions: { '👍': 12 },
|
||||
pinned: false,
|
||||
createdAt: '5 hours ago',
|
||||
lastActivity: '1 hour ago',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Show off your AeThex-powered projects!',
|
||||
author: { name: 'Anderson', avatar: 'A', color: '#5865f2' },
|
||||
content: 'Share what you\'ve built with AeThex tools and get feedback from the community.',
|
||||
tags: ['showcase', 'community'],
|
||||
replies: 156,
|
||||
reactions: { '🚀': 234, '❤️': 145, '👏': 89 },
|
||||
pinned: false,
|
||||
createdAt: '3 days ago',
|
||||
lastActivity: '10 minutes ago',
|
||||
},
|
||||
];
|
||||
|
||||
const tags = [
|
||||
{ id: 'all', label: 'All Posts', color: '#5865f2' },
|
||||
{ id: 'question', label: 'Question', color: '#3ba55d' },
|
||||
{ id: 'tutorial', label: 'Tutorial', color: '#faa61a' },
|
||||
{ id: 'bug', label: 'Bug Report', color: '#ed4245' },
|
||||
{ id: 'showcase', label: 'Showcase', color: '#9b59b6' },
|
||||
{ id: 'discussion', label: 'Discussion', color: '#747f8d' },
|
||||
];
|
||||
|
||||
export default function ForumChannel({ channel, onClose }) {
|
||||
const [activeTag, setActiveTag] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('activity');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedPost, setSelectedPost] = useState(null);
|
||||
|
||||
const channelData = channel || {
|
||||
name: 'help-forum',
|
||||
description: 'Get help with AeThex products and services',
|
||||
guidelines: 'Be respectful, search before posting, use appropriate tags.',
|
||||
};
|
||||
|
||||
const filteredPosts = mockPosts.filter(post => {
|
||||
const matchesTag = activeTag === 'all' || post.tags.includes(activeTag);
|
||||
const matchesSearch = !search ||
|
||||
post.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
post.content.toLowerCase().includes(search.toLowerCase());
|
||||
return matchesTag && matchesSearch;
|
||||
});
|
||||
|
||||
const sortedPosts = [...filteredPosts].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (selectedPost) {
|
||||
return (
|
||||
<ForumPost
|
||||
post={selectedPost}
|
||||
onBack={() => setSelectedPost(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-channel">
|
||||
<div className="forum-header">
|
||||
<div className="forum-title">
|
||||
<span className="forum-icon">💬</span>
|
||||
<h2>{channelData.name}</h2>
|
||||
</div>
|
||||
<p className="forum-description">{channelData.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="forum-toolbar">
|
||||
<div className="forum-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search posts..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="forum-filters">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="sort-select"
|
||||
>
|
||||
<option value="activity">Recent Activity</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="new-post-btn">
|
||||
+ New Post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="forum-tags">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
className={`tag-btn ${activeTag === tag.id ? 'active' : ''}`}
|
||||
style={activeTag === tag.id ? { background: tag.color } : undefined}
|
||||
onClick={() => setActiveTag(tag.id)}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="forum-posts">
|
||||
{sortedPosts.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`forum-post-card ${post.pinned ? 'pinned' : ''}`}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
>
|
||||
{post.pinned && <span className="pinned-badge">📌 Pinned</span>}
|
||||
<div className="post-main">
|
||||
<div
|
||||
className="post-avatar"
|
||||
style={post.author.color ? { background: post.author.color } : undefined}
|
||||
>
|
||||
{post.author.avatar}
|
||||
</div>
|
||||
<div className="post-content">
|
||||
<h3 className="post-title">{post.title}</h3>
|
||||
<p className="post-preview">{post.content}</p>
|
||||
<div className="post-tags">
|
||||
{post.tags.map(tagId => {
|
||||
const tag = tags.find(t => t.id === tagId);
|
||||
return tag ? (
|
||||
<span
|
||||
key={tagId}
|
||||
className="post-tag"
|
||||
style={{ background: tag.color }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
<div className="post-stats">
|
||||
<span className="reply-count">💬 {post.replies}</span>
|
||||
{Object.entries(post.reactions).map(([emoji, count]) => (
|
||||
<span key={emoji} className="reaction-count">{emoji} {count}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="post-time">
|
||||
<span className="author-name">{post.author.name}</span>
|
||||
<span>•</span>
|
||||
<span>Last activity {post.lastActivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPosts.length === 0 && (
|
||||
<div className="forum-empty">
|
||||
<span className="empty-icon">📭</span>
|
||||
<p>No posts found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForumPost({ post, onBack }) {
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
|
||||
const replies = [
|
||||
{ id: 1, author: { name: 'Marcus', avatar: 'M' }, content: 'Have you checked the documentation? There\'s a great guide at docs.aethex.com/passport', time: '1 hour ago', reactions: { '👍': 5 } },
|
||||
{ id: 2, author: { name: 'Sarah', avatar: 'S', color: '#ffa500' }, content: 'I wrote a tutorial for this! Check out post #2 in this forum.', time: '45 minutes ago', reactions: { '❤️': 3 } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="forum-post-detail">
|
||||
<button className="back-btn" onClick={onBack}>
|
||||
← Back to posts
|
||||
</button>
|
||||
|
||||
<div className="post-header">
|
||||
<h1>{post.title}</h1>
|
||||
<div className="post-author-info">
|
||||
<div
|
||||
className="author-avatar"
|
||||
style={post.author.color ? { background: post.author.color } : undefined}
|
||||
>
|
||||
{post.author.avatar}
|
||||
</div>
|
||||
<span className="author-name">{post.author.name}</span>
|
||||
<span className="post-date">Posted {post.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
<p>{post.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="post-reactions">
|
||||
{Object.entries(post.reactions).map(([emoji, count]) => (
|
||||
<button key={emoji} className="reaction-btn">
|
||||
{emoji} {count}
|
||||
</button>
|
||||
))}
|
||||
<button className="reaction-btn add">+</button>
|
||||
</div>
|
||||
|
||||
<div className="post-replies">
|
||||
<h3>Replies ({replies.length})</h3>
|
||||
{replies.map(reply => (
|
||||
<div key={reply.id} className="reply-card">
|
||||
<div
|
||||
className="reply-avatar"
|
||||
style={reply.author.color ? { background: reply.author.color } : undefined}
|
||||
>
|
||||
{reply.author.avatar}
|
||||
</div>
|
||||
<div className="reply-content">
|
||||
<div className="reply-header">
|
||||
<span className="reply-author">{reply.author.name}</span>
|
||||
<span className="reply-time">{reply.time}</span>
|
||||
</div>
|
||||
<p>{reply.content}</p>
|
||||
<div className="reply-actions">
|
||||
{Object.entries(reply.reactions).map(([emoji, count]) => (
|
||||
<button key={emoji} className="reaction-btn small">
|
||||
{emoji} {count}
|
||||
</button>
|
||||
))}
|
||||
<button className="reply-btn">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="reply-input">
|
||||
<textarea
|
||||
placeholder="Write a reply..."
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
/>
|
||||
<button className="send-reply-btn" disabled={!replyContent.trim()}>
|
||||
Send Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue