modified: astro-site/package.json
This commit is contained in:
parent
f14765f47c
commit
df3146abdf
43 changed files with 12244 additions and 3678 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev --port 3000",
|
"dev": "astro dev --port 4321",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview"
|
"preview": "astro preview"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import AppWrapper from './App';
|
import MainLayout from './mockup/MainLayout';
|
||||||
import './index.css';
|
import './mockup/global.css';
|
||||||
import './Demo.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AppWrapper />
|
<MainLayout />
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
23
astro-site/src/react-app/tsconfig.json
Normal file
23
astro-site/src/react-app/tsconfig.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import react from '@vitejs/plugin-react';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: 'http://localhost:3000',
|
||||||
|
|
|
||||||
3353
package-lock.json
generated
3353
package-lock.json
generated
File diff suppressed because it is too large
Load diff
6993
packages/desktop/package-lock.json
generated
Normal file
6993
packages/desktop/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,8 @@
|
||||||
"description": "AeThex Connect Desktop App",
|
"description": "AeThex Connect Desktop App",
|
||||||
"main": "dist/main/index.js",
|
"main": "dist/main/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
|
"dev": "concurrently \"npm run dev:renderer\" \"npm run dev:main\"",
|
||||||
"dev:main": "tsc -p tsconfig.main.json && electron dist/main/index.js",
|
"dev:main": "tsc -p tsconfig.main.json && cross-env NODE_ENV=development electron dist/main/index.js",
|
||||||
"dev:renderer": "vite",
|
"dev:renderer": "vite",
|
||||||
"build": "npm run build:main && npm run build:renderer",
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
"build:main": "tsc -p tsconfig.main.json",
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
|
|
@ -28,7 +28,10 @@
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
"target": ["dmg", "zip"],
|
"target": [
|
||||||
|
"dmg",
|
||||||
|
"zip"
|
||||||
|
],
|
||||||
"category": "public.app-category.social-networking",
|
"category": "public.app-category.social-networking",
|
||||||
"icon": "assets/icon.icns",
|
"icon": "assets/icon.icns",
|
||||||
"hardenedRuntime": true,
|
"hardenedRuntime": true,
|
||||||
|
|
@ -37,11 +40,18 @@
|
||||||
"entitlementsInherit": "build/entitlements.mac.plist"
|
"entitlementsInherit": "build/entitlements.mac.plist"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": ["nsis", "portable"],
|
"target": [
|
||||||
|
"nsis",
|
||||||
|
"portable"
|
||||||
|
],
|
||||||
"icon": "assets/icon.ico"
|
"icon": "assets/icon.ico"
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"target": ["AppImage", "deb", "rpm"],
|
"target": [
|
||||||
|
"AppImage",
|
||||||
|
"deb",
|
||||||
|
"rpm"
|
||||||
|
],
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
"category": "Network"
|
"category": "Network"
|
||||||
},
|
},
|
||||||
|
|
@ -57,10 +67,20 @@
|
||||||
"electron-updater": "^6.1.7"
|
"electron-updater": "^6.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.2.0",
|
||||||
"@types/node": "^20.10.0",
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"electron": "^28.1.0",
|
"electron": "^28.1.0",
|
||||||
"electron-builder": "^24.9.1",
|
"electron-builder": "^24.9.1",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"tailwindcss": "^4.2.0",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.8"
|
"vite": "^5.0.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
6
packages/desktop/postcss.config.js
Normal file
6
packages/desktop/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { autoUpdater } from 'electron-updater';
|
||||||
const store = new Store();
|
const store = new Store();
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
|
let isQuitting = false;
|
||||||
|
|
||||||
// Single instance lock
|
// Single instance lock
|
||||||
const gotTheLock = app.requestSingleInstanceLock();
|
const gotTheLock = app.requestSingleInstanceLock();
|
||||||
|
|
@ -28,15 +29,14 @@ function createWindow() {
|
||||||
minWidth: 800,
|
minWidth: 800,
|
||||||
minHeight: 600,
|
minHeight: 600,
|
||||||
backgroundColor: '#1a1a1a',
|
backgroundColor: '#1a1a1a',
|
||||||
show: false,
|
show: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
},
|
},
|
||||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||||
frame: process.platform !== 'win32',
|
frame: true,
|
||||||
icon: path.join(__dirname, '../../assets/icon.png'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load app
|
// Load app
|
||||||
|
|
@ -55,7 +55,7 @@ function createWindow() {
|
||||||
|
|
||||||
// Minimize to tray instead of closing
|
// Minimize to tray instead of closing
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (!app.isQuitting && store.get('minimizeToTray', true)) {
|
if (!isQuitting && store.get('minimizeToTray', true)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
mainWindow?.hide();
|
mainWindow?.hide();
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ function updateTrayMenu() {
|
||||||
{
|
{
|
||||||
label: 'Quit',
|
label: 'Quit',
|
||||||
click: () => {
|
click: () => {
|
||||||
app.isQuitting = true;
|
isQuitting = true;
|
||||||
app.quit();
|
app.quit();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -231,7 +231,7 @@ ipcMain.handle('get-sources', async () => {
|
||||||
thumbnailSize: { width: 150, height: 150 },
|
thumbnailSize: { width: 150, height: 150 },
|
||||||
});
|
});
|
||||||
|
|
||||||
return sources.map((source) => ({
|
return sources.map((source: any) => ({
|
||||||
id: source.id,
|
id: source.id,
|
||||||
name: source.name,
|
name: source.name,
|
||||||
thumbnail: source.thumbnail.toDataURL(),
|
thumbnail: source.thumbnail.toDataURL(),
|
||||||
|
|
@ -317,7 +317,7 @@ app.on('window-all-closed', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
app.isQuitting = true;
|
isQuitting = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,43 @@
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { AuthProvider, useAuth } from "./context/AuthContext";
|
||||||
|
import { AppProvider } from "./context/AppContext";
|
||||||
|
import { ToastProvider } from "./components/ToastProvider";
|
||||||
import MainLayout from "./components/MainLayout";
|
import MainLayout from "./components/MainLayout";
|
||||||
|
import LoginScreen from "./components/LoginScreen";
|
||||||
|
import { PageLoadingSkeleton } from "./components/Skeletons";
|
||||||
import "./global.css";
|
import "./global.css";
|
||||||
|
|
||||||
const App: React.FC = () => <MainLayout />;
|
const AppContent: React.FC = () => {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [showSplash, setShowSplash] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Show splash for at least 1.5s for branding
|
||||||
|
const timer = setTimeout(() => setShowSplash(false), 1500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading || showSplash) {
|
||||||
|
return <PageLoadingSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <LoginScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppProvider>
|
||||||
|
<MainLayout />
|
||||||
|
</AppProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App: React.FC = () => (
|
||||||
|
<AuthProvider>
|
||||||
|
<ToastProvider>
|
||||||
|
<AppContent />
|
||||||
|
</ToastProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
||||||
158
packages/desktop/src/renderer/components/ActivityPanel.tsx
Normal file
158
packages/desktop/src/renderer/components/ActivityPanel.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
const ActivityPanel: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Mock activity data - would come from real-time API
|
||||||
|
const activities = [
|
||||||
|
{ type: 'gaming', user: 'xKryptic', game: 'Valorant', time: '2h 34m', avatar: 'K' },
|
||||||
|
{ type: 'streaming', user: 'AeThexDev', platform: 'Twitch', viewers: 142, avatar: 'A' },
|
||||||
|
{ type: 'listening', user: 'NeonByte', track: 'Blinding Lights', artist: 'The Weeknd', avatar: 'N' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const friends = [
|
||||||
|
{ name: 'CyberWolf', status: 'online', activity: 'Playing Apex Legends', avatar: 'C' },
|
||||||
|
{ name: 'PixelArt', status: 'idle', activity: 'Away', avatar: 'P' },
|
||||||
|
{ name: 'NightOwl', status: 'dnd', activity: 'Do Not Disturb', avatar: 'N' },
|
||||||
|
{ name: 'TechGuru', status: 'offline', activity: 'Last seen 2h ago', avatar: 'T' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online': return 'bg-emerald-500';
|
||||||
|
case 'idle': return 'bg-amber-500';
|
||||||
|
case 'dnd': return 'bg-rose-500';
|
||||||
|
default: return 'bg-gray-500';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="activity-panel w-72 bg-[#08080a]/90 backdrop-blur-xl border-l border-white/5 flex flex-col">
|
||||||
|
{/* Panel Header */}
|
||||||
|
<div className="p-4 border-b border-white/5">
|
||||||
|
<h3 className="text-sm font-bold text-white/90 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-gradient-to-r from-cyan-500 to-blue-500 animate-pulse" />
|
||||||
|
Active Now
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Live Activities */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-3">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-white/40 font-semibold mb-3 px-1">
|
||||||
|
Live Activity
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activities.map((activity, i) => (
|
||||||
|
<div key={i} className="activity-card mb-2 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.06] border border-white/5 transition-all cursor-pointer group">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-600 to-indigo-600 flex items-center justify-center font-bold text-sm">
|
||||||
|
{activity.avatar}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-[#08080a] flex items-center justify-center">
|
||||||
|
{activity.type === 'gaming' && <span className="text-[10px]">🎮</span>}
|
||||||
|
{activity.type === 'streaming' && <span className="text-[10px]">📺</span>}
|
||||||
|
{activity.type === 'listening' && <span className="text-[10px]">🎵</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-white/90 truncate">{activity.user}</div>
|
||||||
|
<div className="text-xs text-white/50 truncate">
|
||||||
|
{activity.type === 'gaming' && `Playing ${activity.game}`}
|
||||||
|
{activity.type === 'streaming' && `Live on ${activity.platform} • ${activity.viewers} viewers`}
|
||||||
|
{activity.type === 'listening' && `${activity.track} • ${activity.artist}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{activity.type === 'streaming' && (
|
||||||
|
<div className="px-2 py-1 rounded-md bg-rose-500/20 text-rose-400 text-[10px] font-bold uppercase">
|
||||||
|
Live
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Friends List */}
|
||||||
|
<div className="p-3 border-t border-white/5">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-white/40 font-semibold mb-3 px-1">
|
||||||
|
Friends — {friends.filter(f => f.status !== 'offline').length} Online
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{friends.map((friend, i) => (
|
||||||
|
<div key={i} className="friend-item flex items-center gap-3 p-2 rounded-lg hover:bg-white/[0.05] cursor-pointer transition-all">
|
||||||
|
<div className="relative">
|
||||||
|
<div className={`w-9 h-9 rounded-full flex items-center justify-center font-semibold text-sm ${
|
||||||
|
friend.status === 'offline' ? 'bg-white/10 text-white/40' : 'bg-gradient-to-br from-cyan-500 to-blue-600'
|
||||||
|
}`}>
|
||||||
|
{friend.avatar}
|
||||||
|
</div>
|
||||||
|
<div className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-[#08080a] ${getStatusColor(friend.status)}`} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-sm font-medium truncate ${friend.status === 'offline' ? 'text-white/40' : 'text-white/90'}`}>
|
||||||
|
{friend.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/40 truncate">{friend.activity}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="p-3 border-t border-white/5">
|
||||||
|
<div className="text-xs uppercase tracking-wider text-white/40 font-semibold mb-3 px-1">
|
||||||
|
Quick Actions
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button className="flex flex-col items-center justify-center p-3 rounded-xl bg-gradient-to-br from-violet-600/20 to-indigo-600/20 border border-violet-500/20 hover:border-violet-500/40 transition-all group">
|
||||||
|
<span className="text-lg mb-1 group-hover:scale-110 transition-transform">🎮</span>
|
||||||
|
<span className="text-xs text-white/70">GameForge</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center justify-center p-3 rounded-xl bg-gradient-to-br from-emerald-600/20 to-cyan-600/20 border border-emerald-500/20 hover:border-emerald-500/40 transition-all group">
|
||||||
|
<span className="text-lg mb-1 group-hover:scale-110 transition-transform">📞</span>
|
||||||
|
<span className="text-xs text-white/70">Voice Call</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center justify-center p-3 rounded-xl bg-gradient-to-br from-rose-600/20 to-pink-600/20 border border-rose-500/20 hover:border-rose-500/40 transition-all group">
|
||||||
|
<span className="text-lg mb-1 group-hover:scale-110 transition-transform">🎬</span>
|
||||||
|
<span className="text-xs text-white/70">Go Live</span>
|
||||||
|
</button>
|
||||||
|
<button className="flex flex-col items-center justify-center p-3 rounded-xl bg-gradient-to-br from-amber-600/20 to-orange-600/20 border border-amber-500/20 hover:border-amber-500/40 transition-all group">
|
||||||
|
<span className="text-lg mb-1 group-hover:scale-110 transition-transform">⚡</span>
|
||||||
|
<span className="text-xs text-white/70">Nitro</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Now Playing */}
|
||||||
|
<div className="p-3 border-t border-white/5 bg-gradient-to-r from-violet-600/10 to-transparent">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center shadow-lg shadow-violet-500/20">
|
||||||
|
🎵
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-white/50 uppercase tracking-wider">Now Playing</div>
|
||||||
|
<div className="text-sm font-medium text-white/90 truncate">Midnight City</div>
|
||||||
|
<div className="text-xs text-white/50 truncate">M83</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors">
|
||||||
|
⏸
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1 rounded-full bg-white/10 overflow-hidden">
|
||||||
|
<div className="h-full w-1/3 rounded-full bg-gradient-to-r from-violet-500 to-purple-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityPanel;
|
||||||
|
|
@ -1,65 +1,281 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
const ChannelSidebar: React.FC = () => (
|
interface ChannelSidebarProps {
|
||||||
<div className="channel-sidebar w-72 bg-[#0f0f0f] border-r border-[#1a1a1a] flex flex-col">
|
onOpenSettings?: () => void;
|
||||||
{/* Server Header */}
|
}
|
||||||
<div className="server-header p-4 border-b border-[#1a1a1a] font-bold text-base flex items-center justify-between">
|
|
||||||
<span>AeThex Foundation</span>
|
const ChannelSidebar: React.FC<ChannelSidebarProps> = ({ onOpenSettings }) => {
|
||||||
<span className="server-badge foundation text-xs px-2 py-1 rounded bg-red-900/20 text-red-500 border border-red-500 uppercase tracking-wider">Official</span>
|
const { currentServer, channels, currentChannel, selectChannel, loadingChannels } = useApp();
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [statusMenu, setStatusMenu] = useState(false);
|
||||||
|
|
||||||
|
const textChannels = channels.filter(c => c.channel_type === 'text' || c.channel_type === 'announcement');
|
||||||
|
const voiceChannels = channels.filter(c => c.channel_type === 'voice' || c.channel_type === 'stage');
|
||||||
|
|
||||||
|
const getChannelIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'voice': return '🔊';
|
||||||
|
case 'announcement': return '📢';
|
||||||
|
case 'stage': return '🎭';
|
||||||
|
default: return '#';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentServer) {
|
||||||
|
return (
|
||||||
|
<div className="channel-sidebar w-60 bg-[#0c0c0e]/80 backdrop-blur-xl border-r border-white/5 flex flex-col">
|
||||||
|
{/* Home Header */}
|
||||||
|
<div className="p-4 border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-red-600 via-blue-600 to-orange-500 flex items-center justify-center font-black text-lg shadow-lg">
|
||||||
|
A
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold text-white">AeThex Connect</div>
|
||||||
|
<div className="text-xs text-white/40">Home</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Access */}
|
||||||
|
<div className="flex-1 p-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-white/30 font-semibold px-2 mb-2">Quick Access</div>
|
||||||
|
|
||||||
|
<button className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all text-left group mb-1">
|
||||||
|
<span className="text-lg">👋</span>
|
||||||
|
<span className="text-sm text-white/70 group-hover:text-white/90">Friends</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all text-left group mb-1">
|
||||||
|
<span className="text-lg">🎮</span>
|
||||||
|
<span className="text-sm text-white/70 group-hover:text-white/90">GameForge Hub</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all text-left group mb-1">
|
||||||
|
<span className="text-lg">🧭</span>
|
||||||
|
<span className="text-sm text-white/70 group-hover:text-white/90">Discover Servers</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-white/5 transition-all text-left group">
|
||||||
|
<span className="text-lg">⚡</span>
|
||||||
|
<span className="text-sm text-white/70 group-hover:text-white/90">Nitro</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Panel */}
|
||||||
|
<UserPanel user={user} logout={logout} statusMenu={statusMenu} setStatusMenu={setStatusMenu} onOpenSettings={onOpenSettings} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="channel-sidebar w-60 bg-[#0c0c0e]/80 backdrop-blur-xl border-r border-white/5 flex flex-col">
|
||||||
|
{/* Server Header with Banner */}
|
||||||
|
<div className="relative">
|
||||||
|
{currentServer.banner_url ? (
|
||||||
|
<div className="h-24 bg-cover bg-center" style={{ backgroundImage: `url(${currentServer.banner_url})` }}>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-[#0c0c0e] to-transparent" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-12 bg-gradient-to-r from-indigo-600/30 via-violet-600/30 to-purple-600/30" />
|
||||||
|
)}
|
||||||
|
<div className="px-4 py-3 flex items-center justify-between relative">
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<span className="font-bold text-white truncate">{currentServer.name}</span>
|
||||||
|
{currentServer.is_public && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded text-[10px] bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 font-medium">
|
||||||
|
PUBLIC
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button className="w-7 h-7 rounded flex items-center justify-center text-white/50 hover:text-white/80 hover:bg-white/10 transition-all">
|
||||||
|
⌄
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Stats Bar */}
|
||||||
|
<div className="px-4 pb-3 flex items-center gap-3 text-sm border-b border-white/5">
|
||||||
|
<div className="flex items-center gap-1.5 text-white/50">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||||
|
<span>{currentServer.member_count || 1}</span>
|
||||||
|
</div>
|
||||||
|
{currentServer.invite_code && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigator.clipboard.writeText(currentServer.invite_code)}
|
||||||
|
className="ml-auto text-xs px-2 py-1 rounded bg-white/5 text-white/50 hover:text-white/80 hover:bg-white/10 transition-all"
|
||||||
|
title="Click to copy"
|
||||||
|
>
|
||||||
|
Invite: {currentServer.invite_code}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Channel List */}
|
{/* Channel List */}
|
||||||
<div className="channel-list flex-1 overflow-y-auto py-2">
|
<div className="channel-list flex-1 overflow-y-auto py-3 px-2">
|
||||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Announcements</div>
|
{loadingChannels ? (
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
<span className="channel-icon">📢</span>
|
<div className="w-6 h-6 border-2 border-white/20 border-t-white/60 rounded-full animate-spin mb-2" />
|
||||||
<span className="channel-name flex-1">updates</span>
|
<span className="text-xs text-white/40">Loading channels...</span>
|
||||||
<span className="channel-badge text-xs bg-red-600 text-white px-2 rounded-full">3</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
) : (
|
||||||
<span className="channel-icon">📜</span>
|
<>
|
||||||
<span className="channel-name flex-1">changelog</span>
|
{textChannels.length > 0 && (
|
||||||
|
<ChannelGroup
|
||||||
|
title="Text Channels"
|
||||||
|
channels={textChannels}
|
||||||
|
currentChannel={currentChannel}
|
||||||
|
selectChannel={selectChannel}
|
||||||
|
getChannelIcon={getChannelIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{voiceChannels.length > 0 && (
|
||||||
|
<ChannelGroup
|
||||||
|
title="Voice Channels"
|
||||||
|
channels={voiceChannels}
|
||||||
|
currentChannel={currentChannel}
|
||||||
|
selectChannel={selectChannel}
|
||||||
|
getChannelIcon={getChannelIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{channels.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-3xl mb-2">📭</div>
|
||||||
|
<p className="text-sm text-white/40">No channels yet</p>
|
||||||
|
<p className="text-xs text-white/30 mt-1">Create one to get started!</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Development</div>
|
)}
|
||||||
<div className="channel-item active flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm bg-[#1a1a1a]">
|
</>
|
||||||
<span className="channel-icon">#</span>
|
)}
|
||||||
<span className="channel-name flex-1">general</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
|
||||||
<span className="channel-icon">#</span>
|
{/* User Panel */}
|
||||||
<span className="channel-name flex-1">api-discussion</span>
|
<UserPanel user={user} logout={logout} statusMenu={statusMenu} setStatusMenu={setStatusMenu} onOpenSettings={onOpenSettings} />
|
||||||
</div>
|
</div>
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
);
|
||||||
<span className="channel-icon">#</span>
|
};
|
||||||
<span className="channel-name flex-1">passport-development</span>
|
|
||||||
</div>
|
// Channel Group Component
|
||||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Support</div>
|
const ChannelGroup: React.FC<{
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
title: string;
|
||||||
<span className="channel-icon">❓</span>
|
channels: any[];
|
||||||
<span className="channel-name flex-1">help</span>
|
currentChannel: any;
|
||||||
</div>
|
selectChannel: (channel: any) => void;
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
getChannelIcon: (type: string) => string;
|
||||||
<span className="channel-icon">🐛</span>
|
}> = ({ title, channels, currentChannel, selectChannel, getChannelIcon }) => {
|
||||||
<span className="channel-name flex-1">bug-reports</span>
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
</div>
|
|
||||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Voice Channels</div>
|
return (
|
||||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
<div className="mb-4">
|
||||||
<span className="channel-icon">🔊</span>
|
<button
|
||||||
<span className="channel-name flex-1">Nexus Lounge</span>
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
<span className="text-gray-500 text-xs">3</span>
|
className="w-full flex items-center gap-1 px-2 py-1 text-[11px] uppercase tracking-wider text-white/40 font-semibold hover:text-white/60 transition-colors"
|
||||||
|
>
|
||||||
|
<span className={`transition-transform ${collapsed ? '-rotate-90' : ''}`}>▾</span>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!collapsed && channels.map(channel => (
|
||||||
|
<div
|
||||||
|
key={channel.id}
|
||||||
|
onClick={() => selectChannel(channel)}
|
||||||
|
className={`channel-item flex items-center gap-2 px-2 py-1.5 mx-1 rounded-md cursor-pointer text-sm transition-all group ${
|
||||||
|
currentChannel?.id === channel.id
|
||||||
|
? 'bg-white/10 text-white'
|
||||||
|
: 'text-white/50 hover:bg-white/5 hover:text-white/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`channel-icon text-base ${currentChannel?.id === channel.id ? 'text-white/90' : 'text-white/40'}`}>
|
||||||
|
{getChannelIcon(channel.channel_type)}
|
||||||
|
</span>
|
||||||
|
<span className="channel-name flex-1 truncate">{channel.name}</span>
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-0.5 transition-opacity">
|
||||||
|
<button className="w-6 h-6 rounded flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/10 text-xs">
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* User Presence */}
|
))}
|
||||||
<div className="user-presence p-3 border-t border-[#1a1a1a] flex items-center gap-3 text-sm">
|
</div>
|
||||||
<div className="user-avatar w-10 h-10 rounded-full flex items-center justify-center font-bold bg-gradient-to-tr from-red-600 via-blue-600 to-orange-400">A</div>
|
);
|
||||||
<div className="user-info flex-1">
|
};
|
||||||
<div className="user-name font-bold mb-0.5">Anderson</div>
|
|
||||||
<div className="user-status flex items-center gap-1 text-xs text-gray-500">
|
// User Panel Component
|
||||||
<span className="status-dot w-2 h-2 rounded-full bg-green-400 shadow-green-400/50 shadow" />
|
const UserPanel: React.FC<{
|
||||||
<span>Building AeThex</span>
|
user: any;
|
||||||
|
logout: () => void;
|
||||||
|
statusMenu: boolean;
|
||||||
|
setStatusMenu: (v: boolean) => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}> = ({ user, logout, statusMenu, setStatusMenu, onOpenSettings }) => {
|
||||||
|
const statuses = [
|
||||||
|
{ id: 'online', label: 'Online', color: 'bg-emerald-500' },
|
||||||
|
{ id: 'idle', label: 'Idle', color: 'bg-amber-500' },
|
||||||
|
{ id: 'dnd', label: 'Do Not Disturb', color: 'bg-rose-500' },
|
||||||
|
{ id: 'offline', label: 'Invisible', color: 'bg-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="user-panel p-2 border-t border-white/5 bg-[#08080a]/50">
|
||||||
|
<div className="flex items-center gap-2 p-2 rounded-lg hover:bg-white/5 transition-all cursor-pointer">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-9 h-9 rounded-full flex items-center justify-center font-bold text-sm bg-gradient-to-br from-red-600 via-blue-600 to-orange-400">
|
||||||
|
{user?.avatarUrl ? (
|
||||||
|
<img src={user.avatarUrl} alt={user.username} className="w-full h-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-white">{user?.username?.charAt(0).toUpperCase() || 'U'}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusMenu(!statusMenu)}
|
||||||
|
className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-emerald-500 border-2 border-[#0c0c0e] cursor-pointer hover:scale-110 transition-transform"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-semibold text-sm text-white truncate">{user?.displayName || user?.username}</div>
|
||||||
|
<div className="text-xs text-white/40 truncate">@{user?.username}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/10 transition-all">
|
||||||
|
🎤
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/10 transition-all">
|
||||||
|
🎧
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/10 transition-all"
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
⚙️
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Menu */}
|
||||||
|
{statusMenu && (
|
||||||
|
<div className="absolute bottom-16 left-2 right-2 bg-[#111113] rounded-xl border border-white/10 shadow-xl p-2 z-50">
|
||||||
|
{statuses.map(status => (
|
||||||
|
<button
|
||||||
|
key={status.id}
|
||||||
|
onClick={() => setStatusMenu(false)}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 transition-all text-left"
|
||||||
|
>
|
||||||
|
<span className={`w-3 h-3 rounded-full ${status.color}`} />
|
||||||
|
<span className="text-sm text-white/80">{status.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="my-2 h-px bg-white/10" />
|
||||||
|
<button className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 transition-all text-left">
|
||||||
|
<span className="text-lg">✏️</span>
|
||||||
|
<span className="text-sm text-white/80">Set Custom Status</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ChannelSidebar;
|
export default ChannelSidebar;
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,182 @@
|
||||||
import React from "react";
|
import React, { useRef, useEffect, useState } from "react";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
import Message from "./Message";
|
import Message from "./Message";
|
||||||
import MessageInput from "./MessageInput";
|
import MessageInput from "./MessageInput";
|
||||||
|
|
||||||
const messages = [
|
interface ChatAreaProps {
|
||||||
{ type: "system", label: "FOUNDATION", text: "Foundation authentication services upgraded to v2.1.0. Enhanced security protocols now active across all AeThex infrastructure.", className: "foundation" },
|
onToggleMembers?: () => void;
|
||||||
{ type: "user", author: "Trevor", badge: "Foundation", time: "10:34 AM", text: "Just pushed the authentication updates. All services should automatically migrate to the new protocols within 24 hours.", avatar: "T", avatarBg: "from-red-600 to-red-800" },
|
onToggleActivity?: () => void;
|
||||||
{ type: "user", author: "Marcus", time: "10:41 AM", text: "Excellent work! I've been testing the new Passport integration and it's incredibly smooth. The Trinity color-coding in the UI makes it really clear which division is handling what.", avatar: "M", avatarBg: "from-blue-600 to-blue-900" },
|
showMembers?: boolean;
|
||||||
{ type: "system", label: "LABS", text: "Nexus Engine v2.0-beta now available for testing. New cross-platform sync reduces latency by 40%. Join #labs-testing to participate.", className: "labs" },
|
showActivity?: boolean;
|
||||||
{ type: "user", author: "Sarah", badge: "Labs", time: "11:15 AM", text: "The Nexus v2 parallel compilation is insane. Cut my build time from 3 minutes to under 2. Still some edge cases with complex state synchronization but wow... ⚠️", avatar: "S", avatarBg: "from-orange-400 to-orange-700" },
|
onOpenQuickSwitcher?: () => void;
|
||||||
{ type: "user", author: "Anderson", badge: "Founder", time: "11:47 AM", text: "Love seeing the Trinity infrastructure working in harmony. Foundation keeping everything secure, Labs pushing the boundaries, Corporation delivering production-ready tools. This is exactly the vision.", avatar: "A", avatarBg: "from-red-600 via-blue-600 to-orange-400" },
|
}
|
||||||
{ type: "user", author: "DevUser_2847", time: "12:03 PM", text: "Quick question - when using AeThex Studio, does the Terminal automatically connect to all three Trinity divisions, or do I need to configure that?", avatar: "D", avatarBg: "bg-[#1a1a1a]" },
|
|
||||||
{ type: "system", label: "CORPORATION", text: "AeThex Studio Pro users: New Railway deployment templates available. Optimized configurations for Foundation APIs, Corporation services, and Labs experiments.", className: "corporation" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ChatArea: React.FC = () => (
|
const ChatArea: React.FC<ChatAreaProps> = ({
|
||||||
<div className="chat-area flex flex-col flex-1 bg-[#0a0a0a]">
|
onToggleMembers,
|
||||||
{/* Chat Header */}
|
onToggleActivity,
|
||||||
<div className="chat-header px-5 py-4 border-b border-[#1a1a1a] flex items-center gap-3">
|
showMembers = true,
|
||||||
<span className="channel-name-header flex-1 font-bold text-base"># general</span>
|
showActivity = true,
|
||||||
<div className="chat-tools flex gap-4 text-sm text-gray-500">
|
onOpenQuickSwitcher
|
||||||
<span className="chat-tool cursor-pointer hover:text-blue-500">🔔</span>
|
}) => {
|
||||||
<span className="chat-tool cursor-pointer hover:text-blue-500">📌</span>
|
const { currentChannel, messages, loadingMessages } = useApp();
|
||||||
<span className="chat-tool cursor-pointer hover:text-blue-500">👥 128</span>
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
<span className="chat-tool cursor-pointer hover:text-blue-500">🔍</span>
|
const [typingUsers] = useState<string[]>(['PixelArt']); // Mock typing indicator
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
if (!currentChannel) {
|
||||||
|
return (
|
||||||
|
<div className="chat-area flex flex-col flex-1 bg-[#0a0a0c] items-center justify-center">
|
||||||
|
<div className="text-center max-w-md">
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="w-24 h-24 mx-auto rounded-3xl bg-gradient-to-br from-indigo-600/20 to-violet-600/20 flex items-center justify-center border border-white/10">
|
||||||
|
<span className="text-5xl">💬</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500 to-violet-500 rounded-3xl blur-2xl opacity-20 -z-10" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-3">No Channel Selected</h2>
|
||||||
|
<p className="text-white/50 leading-relaxed">
|
||||||
|
Select a text or voice channel from the sidebar to start chatting with your community.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Messages */}
|
);
|
||||||
<div className="chat-messages flex-1 overflow-y-auto px-5 py-5">
|
}
|
||||||
{messages.map((msg, i) => (
|
|
||||||
<Message key={i} {...msg} />
|
return (
|
||||||
))}
|
<div className="chat-area flex flex-col flex-1 bg-[#0a0a0c]">
|
||||||
|
{/* Enhanced Chat Header */}
|
||||||
|
<div className="chat-header px-5 py-3 border-b border-white/5 flex items-center gap-3 bg-[#0a0a0c]/80 backdrop-blur-xl">
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center text-white/60">
|
||||||
|
#
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-white/90">{currentChannel.name}</div>
|
||||||
|
{currentChannel.description && (
|
||||||
|
<div className="text-xs text-white/40 truncate max-w-md">{currentChannel.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-tools flex items-center gap-1">
|
||||||
|
<button className="w-9 h-9 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/5 hover:text-white/80 transition-all" title="Threads">
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
|
<button className="w-9 h-9 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/5 hover:text-white/80 transition-all" title="Notifications">
|
||||||
|
🔔
|
||||||
|
</button>
|
||||||
|
<button className="w-9 h-9 rounded-lg flex items-center justify-center text-white/50 hover:bg-white/5 hover:text-white/80 transition-all" title="Pinned Messages">
|
||||||
|
📌
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggleMembers}
|
||||||
|
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${
|
||||||
|
showMembers ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80'
|
||||||
|
}`}
|
||||||
|
title="Toggle Member List"
|
||||||
|
>
|
||||||
|
👥
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggleActivity}
|
||||||
|
className={`w-9 h-9 rounded-lg flex items-center justify-center transition-all ${
|
||||||
|
showActivity ? 'bg-white/10 text-white' : 'text-white/50 hover:bg-white/5 hover:text-white/80'
|
||||||
|
}`}
|
||||||
|
title="Toggle Activity"
|
||||||
|
>
|
||||||
|
⚡
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||||
|
<button
|
||||||
|
onClick={onOpenQuickSwitcher}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-lg bg-black/30 border border-white/10 text-white/30 hover:text-white/50 hover:border-white/20 transition-all"
|
||||||
|
>
|
||||||
|
<span>Search</span>
|
||||||
|
<kbd className="text-[10px] px-1 py-0.5 bg-white/10 rounded border border-white/10">Ctrl+K</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div className="chat-messages flex-1 overflow-y-auto">
|
||||||
|
{loadingMessages ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||||
|
<p className="text-white/50 text-sm">Loading messages...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full">
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-600/20 to-cyan-600/20 flex items-center justify-center border border-white/10">
|
||||||
|
<span className="text-4xl">🎉</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white mb-2">Welcome to #{currentChannel.name}!</h3>
|
||||||
|
<p className="text-white/50 text-sm text-center max-w-sm">
|
||||||
|
This is the start of the #{currentChannel.name} channel. Send your first message to get the conversation going!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-5 py-4">
|
||||||
|
{/* Channel Start Header */}
|
||||||
|
<div className="mb-6 pb-6 border-b border-white/5">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-indigo-600/30 to-violet-600/30 flex items-center justify-center mb-4 border border-white/10">
|
||||||
|
<span className="text-3xl font-bold text-white">#</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-bold text-white mb-1">Welcome to #{currentChannel.name}!</h3>
|
||||||
|
<p className="text-white/50 text-sm">This is the beginning of the #{currentChannel.name} channel.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{messages.map((msg, index) => {
|
||||||
|
const prevMsg = messages[index - 1];
|
||||||
|
const showAvatar = !prevMsg ||
|
||||||
|
prevMsg.user_id !== msg.user_id ||
|
||||||
|
new Date(msg.created_at).getTime() - new Date(prevMsg.created_at).getTime() > 300000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
key={msg.id}
|
||||||
|
type="user"
|
||||||
|
author={msg.display_name || msg.username}
|
||||||
|
time={new Date(msg.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
text={msg.content}
|
||||||
|
avatar={msg.avatar_url ? undefined : (msg.username?.charAt(0).toUpperCase() || 'U')}
|
||||||
|
avatarUrl={msg.avatar_url}
|
||||||
|
avatarBg="from-indigo-600 to-violet-600"
|
||||||
|
showAvatar={showAvatar}
|
||||||
|
compact={!showAvatar}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Typing Indicator */}
|
||||||
|
{typingUsers.length > 0 && (
|
||||||
|
<div className="px-5 py-2 text-sm text-white/50 flex items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-white/50 animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<span className="w-2 h-2 rounded-full bg-white/50 animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<span className="w-2 h-2 rounded-full bg-white/50 animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
<span><strong className="text-white/70">{typingUsers.join(', ')}</strong> is typing...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Message Input */}
|
{/* Message Input */}
|
||||||
<div className="message-input-container px-5 py-5 border-t border-[#1a1a1a]">
|
<div className="message-input-container px-5 pb-6 pt-2">
|
||||||
<MessageInput />
|
<MessageInput />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ChatArea;
|
export default ChatArea;
|
||||||
|
|
|
||||||
319
packages/desktop/src/renderer/components/ContextMenu.tsx
Normal file
319
packages/desktop/src/renderer/components/ContextMenu.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export interface ContextMenuItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
shortcut?: string;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
divider?: boolean;
|
||||||
|
submenu?: ContextMenuItem[];
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
position: { x: number; y: number } | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ContextMenu: React.FC<ContextMenuProps> = ({ items, position, onClose }) => {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [adjustedPosition, setAdjustedPosition] = useState(position);
|
||||||
|
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!position || !menuRef.current) return;
|
||||||
|
|
||||||
|
const rect = menuRef.current.getBoundingClientRect();
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
let x = position.x;
|
||||||
|
let y = position.y;
|
||||||
|
|
||||||
|
// Adjust horizontal position
|
||||||
|
if (x + rect.width > viewportWidth) {
|
||||||
|
x = viewportWidth - rect.width - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust vertical position
|
||||||
|
if (y + rect.height > viewportHeight) {
|
||||||
|
y = viewportHeight - rect.height - 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAdjustedPosition({ x, y });
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
if (!position) return null;
|
||||||
|
|
||||||
|
const handleItemClick = (item: ContextMenuItem) => {
|
||||||
|
if (item.disabled || item.submenu) return;
|
||||||
|
item.onClick?.();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="fixed z-[100] min-w-[200px] bg-[#0c0c0e]/95 backdrop-blur-xl rounded-xl border border-white/10 shadow-2xl py-2 animate-fadeIn"
|
||||||
|
style={{
|
||||||
|
left: adjustedPosition?.x ?? position.x,
|
||||||
|
top: adjustedPosition?.y ?? position.y,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
if (item.divider) {
|
||||||
|
return <div key={index} className="h-px bg-white/10 my-2 mx-2" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="relative"
|
||||||
|
onMouseEnter={() => item.submenu && setActiveSubmenu(item.id)}
|
||||||
|
onMouseLeave={() => setActiveSubmenu(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors
|
||||||
|
${item.disabled
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: item.danger
|
||||||
|
? 'text-red-400 hover:bg-red-500/10'
|
||||||
|
: 'text-gray-200 hover:bg-white/10'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{item.icon && (
|
||||||
|
<span className={`w-5 h-5 flex items-center justify-center ${item.disabled ? 'opacity-50' : ''}`}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-left">{item.label}</span>
|
||||||
|
{item.shortcut && (
|
||||||
|
<span className="text-gray-500 text-xs">{item.shortcut}</span>
|
||||||
|
)}
|
||||||
|
{item.submenu && (
|
||||||
|
<svg className="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Submenu */}
|
||||||
|
{item.submenu && activeSubmenu === item.id && (
|
||||||
|
<div className="absolute left-full top-0 ml-1 min-w-[180px] bg-[#0c0c0e]/95 backdrop-blur-xl rounded-xl border border-white/10 shadow-2xl py-2">
|
||||||
|
{item.submenu.map((subitem) => (
|
||||||
|
<button
|
||||||
|
key={subitem.id}
|
||||||
|
onClick={() => {
|
||||||
|
subitem.onClick?.();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
disabled={subitem.disabled}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors
|
||||||
|
${subitem.disabled
|
||||||
|
? 'text-gray-600 cursor-not-allowed'
|
||||||
|
: subitem.danger
|
||||||
|
? 'text-red-400 hover:bg-red-500/10'
|
||||||
|
: 'text-gray-200 hover:bg-white/10'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{subitem.icon && (
|
||||||
|
<span className="w-5 h-5 flex items-center justify-center">
|
||||||
|
{subitem.icon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex-1 text-left">{subitem.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Context Menu Hook
|
||||||
|
interface ContextMenuState {
|
||||||
|
position: { x: number; y: number } | null;
|
||||||
|
items: ContextMenuItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useContextMenu = () => {
|
||||||
|
const [state, setState] = useState<ContextMenuState>({ position: null, items: [] });
|
||||||
|
|
||||||
|
const showContextMenu = (e: React.MouseEvent, items: ContextMenuItem[]) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setState({
|
||||||
|
position: { x: e.clientX, y: e.clientY },
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideContextMenu = () => {
|
||||||
|
setState({ position: null, items: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
contextMenu: state,
|
||||||
|
showContextMenu,
|
||||||
|
hideContextMenu,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-defined menu item sets
|
||||||
|
export const messageContextMenuItems = (params: {
|
||||||
|
onReply?: () => void;
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
onCopy?: () => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
isOwner?: boolean;
|
||||||
|
}): ContextMenuItem[] => [
|
||||||
|
{
|
||||||
|
id: 'reply',
|
||||||
|
label: 'Reply',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" /></svg>,
|
||||||
|
onClick: params.onReply,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy',
|
||||||
|
label: 'Copy Text',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>,
|
||||||
|
shortcut: 'Ctrl+C',
|
||||||
|
onClick: params.onCopy,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pin',
|
||||||
|
label: 'Pin Message',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /></svg>,
|
||||||
|
onClick: params.onPin,
|
||||||
|
},
|
||||||
|
{ id: 'divider1', label: '', divider: true },
|
||||||
|
...(params.isOwner ? [
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
label: 'Edit Message',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>,
|
||||||
|
onClick: params.onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
label: 'Delete Message',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>,
|
||||||
|
danger: true,
|
||||||
|
onClick: params.onDelete,
|
||||||
|
},
|
||||||
|
] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
export const userContextMenuItems = (params: {
|
||||||
|
onProfile?: () => void;
|
||||||
|
onMessage?: () => void;
|
||||||
|
onCall?: () => void;
|
||||||
|
onMute?: () => void;
|
||||||
|
onBlock?: () => void;
|
||||||
|
}): ContextMenuItem[] => [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
label: 'View Profile',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>,
|
||||||
|
onClick: params.onProfile,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'message',
|
||||||
|
label: 'Send Message',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" /></svg>,
|
||||||
|
onClick: params.onMessage,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'call',
|
||||||
|
label: 'Start Voice Call',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>,
|
||||||
|
onClick: params.onCall,
|
||||||
|
},
|
||||||
|
{ id: 'divider1', label: '', divider: true },
|
||||||
|
{
|
||||||
|
id: 'mute',
|
||||||
|
label: 'Mute',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2" /></svg>,
|
||||||
|
onClick: params.onMute,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'block',
|
||||||
|
label: 'Block',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>,
|
||||||
|
danger: true,
|
||||||
|
onClick: params.onBlock,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const channelContextMenuItems = (params: {
|
||||||
|
onEdit?: () => void;
|
||||||
|
onMute?: () => void;
|
||||||
|
onInvite?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
canManage?: boolean;
|
||||||
|
}): ContextMenuItem[] => [
|
||||||
|
{
|
||||||
|
id: 'invite',
|
||||||
|
label: 'Invite People',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" /></svg>,
|
||||||
|
onClick: params.onInvite,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mute',
|
||||||
|
label: 'Mute Channel',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><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>,
|
||||||
|
onClick: params.onMute,
|
||||||
|
},
|
||||||
|
...(params.canManage ? [
|
||||||
|
{ id: 'divider1', label: '', divider: true },
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
label: 'Edit Channel',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
|
||||||
|
onClick: params.onEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
label: 'Delete Channel',
|
||||||
|
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>,
|
||||||
|
danger: true,
|
||||||
|
onClick: params.onDelete,
|
||||||
|
},
|
||||||
|
] as ContextMenuItem[] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
export default ContextMenu;
|
||||||
291
packages/desktop/src/renderer/components/CreateServerModal.tsx
Normal file
291
packages/desktop/src/renderer/components/CreateServerModal.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface CreateServerModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreateServer: (name: string, icon?: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverTemplates = [
|
||||||
|
{ id: 'gaming', name: 'Gaming', icon: '🎮', description: 'For gaming communities' },
|
||||||
|
{ id: 'friends', name: 'Friends', icon: '👥', description: 'A place to hang out' },
|
||||||
|
{ id: 'study', name: 'Study Group', icon: '📚', description: 'For learning together' },
|
||||||
|
{ id: 'creators', name: 'Creators', icon: '🎨', description: 'For content creators' },
|
||||||
|
{ id: 'local', name: 'Local Community', icon: '🏠', description: 'For local groups' },
|
||||||
|
{ id: 'custom', name: 'Custom', icon: '⚙️', description: 'Start from scratch' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CreateServerModal: React.FC<CreateServerModalProps> = ({ isOpen, onClose, onCreateServer }) => {
|
||||||
|
const [step, setStep] = useState<'select' | 'customize' | 'invite'>('select');
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState<string>('');
|
||||||
|
const [serverName, setServerName] = useState('');
|
||||||
|
const [serverIcon, setServerIcon] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleTemplateSelect = (templateId: string) => {
|
||||||
|
setSelectedTemplate(templateId);
|
||||||
|
const template = serverTemplates.find(t => t.id === templateId);
|
||||||
|
if (template) {
|
||||||
|
setServerName(`${template.name} Server`);
|
||||||
|
}
|
||||||
|
setStep('customize');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!serverName.trim()) {
|
||||||
|
setError('Server name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onCreateServer(serverName, serverIcon);
|
||||||
|
setStep('invite');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to create server');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setStep('select');
|
||||||
|
setSelectedTemplate('');
|
||||||
|
setServerName('');
|
||||||
|
setServerIcon('');
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (step) {
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Create Your Server</h2>
|
||||||
|
<p className="text-gray-400">Your server is where you and your friends hang out. Make yours and start talking.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Templates Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||||
|
{serverTemplates.map((template) => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => handleTemplateSelect(template.id)}
|
||||||
|
className="p-4 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 hover:border-indigo-500/50 transition-all text-left group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 bg-gradient-to-br from-indigo-600/20 to-purple-600/20 rounded-xl flex items-center justify-center text-2xl group-hover:scale-110 transition-transform">
|
||||||
|
{template.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-white">{template.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{template.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Join Server Option */}
|
||||||
|
<div className="pt-6 border-t border-white/10">
|
||||||
|
<p className="text-center text-gray-400 mb-4">Have an invite already?</p>
|
||||||
|
<button className="w-full py-3 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 text-gray-300 font-medium transition-colors">
|
||||||
|
Join a Server
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'customize':
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Back Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('select')}
|
||||||
|
className="flex items-center gap-2 text-gray-400 hover:text-white mb-6 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Customize Your Server</h2>
|
||||||
|
<p className="text-gray-400">Give your new server a personality with a name and an icon. You can always change it later.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Icon Upload */}
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-indigo-600/30 to-purple-600/30 flex items-center justify-center border-2 border-dashed border-white/20 hover:border-indigo-500/50 cursor-pointer transition-colors overflow-hidden">
|
||||||
|
{serverIcon ? (
|
||||||
|
<img src={serverIcon} alt="Server icon" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="text-center">
|
||||||
|
<svg className="w-8 h-8 text-gray-500 mx-auto mb-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs text-gray-500">Upload</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center cursor-pointer hover:bg-indigo-500 transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server Name Input */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">SERVER NAME</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||||
|
placeholder="Enter server name"
|
||||||
|
/>
|
||||||
|
{error && <p className="text-red-400 text-sm mt-2">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Guidelines */}
|
||||||
|
<p className="text-gray-500 text-xs mb-6">
|
||||||
|
By creating a server, you agree to AeThex's <span className="text-indigo-400 hover:underline cursor-pointer">Community Guidelines</span>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Create Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={loading || !serverName.trim()}
|
||||||
|
className="w-full py-3 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white font-medium rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Creating...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
'Create Server'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'invite':
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Success Animation */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="w-20 h-20 mx-auto mb-4 bg-gradient-to-br from-green-500/20 to-emerald-500/20 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">Server Created!</h2>
|
||||||
|
<p className="text-gray-400">"{serverName}" is ready to go. Invite your friends to start chatting.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Link */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-2">INVITE LINK</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value="aethex.io/invite/AbC123"
|
||||||
|
readOnly
|
||||||
|
className="flex-1 px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-gray-300"
|
||||||
|
/>
|
||||||
|
<button className="px-4 py-3 bg-indigo-600 hover:bg-indigo-500 rounded-xl text-white font-medium transition-colors">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Options */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||||
|
{['Twitter', 'Discord', 'Reddit', 'More'].map((platform) => (
|
||||||
|
<button
|
||||||
|
key={platform}
|
||||||
|
className="p-3 bg-white/5 hover:bg-white/10 rounded-xl text-gray-400 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{platform}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Done Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-full py-3 bg-white/5 hover:bg-white/10 rounded-xl text-gray-300 font-medium border border-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-md bg-[#0a0a0c] rounded-2xl border border-white/10 shadow-2xl animate-fadeIn overflow-hidden">
|
||||||
|
{/* Background Glow */}
|
||||||
|
<div className="absolute -top-20 -right-20 w-40 h-40 bg-indigo-600/20 rounded-full blur-3xl" />
|
||||||
|
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-purple-600/20 rounded-full blur-3xl" />
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute top-4 right-4 w-8 h-8 bg-white/5 hover:bg-white/10 rounded-lg flex items-center justify-center transition-colors z-10"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="relative">
|
||||||
|
{renderStep()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Indicator */}
|
||||||
|
<div className="flex justify-center gap-2 pb-6">
|
||||||
|
{['select', 'customize', 'invite'].map((s, i) => (
|
||||||
|
<div
|
||||||
|
key={s}
|
||||||
|
className={`w-2 h-2 rounded-full transition-colors ${
|
||||||
|
step === s ? 'bg-indigo-500' :
|
||||||
|
['select', 'customize', 'invite'].indexOf(step) > i ? 'bg-indigo-500/50' :
|
||||||
|
'bg-white/10'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateServerModal;
|
||||||
356
packages/desktop/src/renderer/components/LoginScreen.tsx
Normal file
356
packages/desktop/src/renderer/components/LoginScreen.tsx
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
const LoginScreen: React.FC = () => {
|
||||||
|
const { login, register, demoLogin } = useAuth();
|
||||||
|
const [isRegister, setIsRegister] = useState(false);
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showQR, setShowQR] = useState(false);
|
||||||
|
const [passwordStrength, setPasswordStrength] = useState(0);
|
||||||
|
|
||||||
|
// Calculate password strength
|
||||||
|
useEffect(() => {
|
||||||
|
let strength = 0;
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (/[A-Z]/.test(password)) strength++;
|
||||||
|
if (/[0-9]/.test(password)) strength++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||||
|
setPasswordStrength(strength);
|
||||||
|
}, [password]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isRegister) {
|
||||||
|
await register(username, password, displayName || undefined);
|
||||||
|
} else {
|
||||||
|
await login(username, password);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Authentication failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoLogin = async () => {
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await demoLogin();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Demo login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-green-500'];
|
||||||
|
const strengthLabels = ['Weak', 'Fair', 'Good', 'Strong'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#050507] flex overflow-hidden">
|
||||||
|
{/* Animated Background */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
|
<div className="absolute -top-1/2 -left-1/2 w-full h-full bg-gradient-to-br from-indigo-600/20 via-transparent to-transparent rounded-full blur-3xl animate-pulse" />
|
||||||
|
<div className="absolute -bottom-1/2 -right-1/2 w-full h-full bg-gradient-to-tl from-purple-600/20 via-transparent to-transparent rounded-full blur-3xl animate-pulse" style={{ animationDelay: '1s' }} />
|
||||||
|
<div className="absolute top-1/4 right-1/4 w-64 h-64 bg-pink-500/10 rounded-full blur-3xl animate-pulse" style={{ animationDelay: '2s' }} />
|
||||||
|
|
||||||
|
{/* Grid pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-5" style={{
|
||||||
|
backgroundImage: 'linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)',
|
||||||
|
backgroundSize: '50px 50px'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Left Side - Branding */}
|
||||||
|
<div className="hidden lg:flex flex-1 items-center justify-center relative p-12">
|
||||||
|
<div className="max-w-lg text-center">
|
||||||
|
{/* Logo Animation */}
|
||||||
|
<div className="relative w-32 h-32 mx-auto mb-8">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-indigo-600 via-purple-600 to-pink-600 rounded-3xl rotate-6 animate-pulse" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-tr from-indigo-600 via-purple-600 to-pink-600 rounded-3xl -rotate-6 opacity-60" />
|
||||||
|
<div className="relative w-full h-full bg-[#0a0a0c] rounded-3xl flex items-center justify-center border border-white/10">
|
||||||
|
<span className="text-5xl font-black bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-4">
|
||||||
|
Welcome to <span className="bg-gradient-to-r from-indigo-400 via-purple-400 to-pink-400 bg-clip-text text-transparent">AeThex</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-400 text-lg mb-8">
|
||||||
|
The next generation communication platform for gamers, creators, and communities.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ icon: '🎮', label: 'GameForge' },
|
||||||
|
{ icon: '📞', label: 'HD Calls' },
|
||||||
|
{ icon: '🔒', label: 'E2E Encryption' },
|
||||||
|
].map((feature, i) => (
|
||||||
|
<div key={i} className="bg-white/5 backdrop-blur-sm rounded-xl p-4 border border-white/5">
|
||||||
|
<div className="text-2xl mb-2">{feature.icon}</div>
|
||||||
|
<div className="text-sm text-gray-300">{feature.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Form */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-6 relative z-10">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
{/* Mobile Logo */}
|
||||||
|
<div className="lg:hidden text-center mb-8">
|
||||||
|
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-tr from-indigo-600 via-purple-600 to-pink-600 flex items-center justify-center">
|
||||||
|
<span className="text-2xl font-bold text-white">A</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">AeThex Connect</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Card */}
|
||||||
|
<div className="bg-[#0a0a0c]/80 backdrop-blur-xl rounded-2xl p-8 border border-white/10 shadow-2xl">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-white">
|
||||||
|
{showQR ? 'Scan to Login' : isRegister ? 'Create Account' : 'Welcome Back'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-400 mt-1 text-sm">
|
||||||
|
{showQR ? 'Use the AeThex mobile app to scan' : isRegister ? 'Join millions of users worldwide' : 'Sign in to continue to AeThex'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl text-red-400 text-sm flex items-center gap-2">
|
||||||
|
<svg className="w-5 h-5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showQR ? (
|
||||||
|
/* QR Code View */
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-48 h-48 mx-auto bg-white rounded-2xl p-4 mb-4">
|
||||||
|
{/* Fake QR pattern */}
|
||||||
|
<div className="w-full h-full bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAiIGhlaWdodD0iMTAwIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0id2hpdGUiLz48cmVjdCB4PSIxMCIgeT0iMTAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSI3MCIgeT0iMTAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSIxMCIgeT0iNzAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iYmxhY2siLz48cmVjdCB4PSI0MCIgeT0iNDAiIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iYmxhY2siLz48L3N2Zz4=')] bg-cover" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 text-sm mb-4">
|
||||||
|
Open the AeThex app and scan this code
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowQR(false)}
|
||||||
|
className="text-indigo-400 hover:text-indigo-300 text-sm font-medium"
|
||||||
|
>
|
||||||
|
← Back to login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{isRegister && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1.5">Email</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 pl-11 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1.5">Username</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 pl-11 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isRegister && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1.5">Display Name</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 pl-11 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||||
|
placeholder="How should we call you?"
|
||||||
|
/>
|
||||||
|
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-300 mb-1.5">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 pl-11 bg-white/5 border border-white/10 rounded-xl text-white placeholder-gray-500 focus:outline-none focus:border-indigo-500/50 focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{isRegister && password && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex gap-1 mb-1">
|
||||||
|
{[0, 1, 2, 3].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||||
|
i < passwordStrength ? strengthColors[passwordStrength - 1] : 'bg-white/10'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs ${strengthColors[passwordStrength - 1]?.replace('bg-', 'text-') || 'text-gray-500'}`}>
|
||||||
|
{password.length < 8 ? 'Min 8 characters' : strengthLabels[passwordStrength - 1] || 'Weak'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isRegister && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<label className="flex items-center gap-2 text-gray-400 cursor-pointer">
|
||||||
|
<input type="checkbox" className="w-4 h-4 rounded border-white/20 bg-white/5 text-indigo-500 focus:ring-indigo-500/20" />
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
<button type="button" className="text-indigo-400 hover:text-indigo-300">
|
||||||
|
Forgot password?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-500 hover:to-purple-500 text-white font-medium rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/25 hover:shadow-indigo-500/40 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
{isRegister ? 'Creating Account...' : 'Signing In...'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
isRegister ? 'Create Account' : 'Sign In'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Social Login */}
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-white/10" />
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-4 bg-[#0a0a0c] text-gray-500">or continue with</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ icon: '🔵', name: 'Discord' },
|
||||||
|
{ icon: '⚫', name: 'GitHub' },
|
||||||
|
{ icon: '🔴', name: 'Google' },
|
||||||
|
].map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.name}
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center gap-2 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl transition-all text-sm text-gray-300"
|
||||||
|
>
|
||||||
|
<span>{provider.icon}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Login Option */}
|
||||||
|
{!isRegister && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowQR(true)}
|
||||||
|
className="w-full py-2.5 text-gray-400 hover:text-white text-sm flex items-center justify-center gap-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
|
||||||
|
</svg>
|
||||||
|
Log in with QR Code
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDemoLogin}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-3 bg-white/5 hover:bg-white/10 text-gray-300 font-medium rounded-xl transition-all border border-white/10 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🚀</span>
|
||||||
|
Try Demo Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsRegister(!isRegister)}
|
||||||
|
className="text-gray-400 hover:text-white text-sm transition-colors"
|
||||||
|
>
|
||||||
|
{isRegister ? (
|
||||||
|
<>Already have an account? <span className="text-indigo-400 font-medium">Sign in</span></>
|
||||||
|
) : (
|
||||||
|
<>Don't have an account? <span className="text-indigo-400 font-medium">Register</span></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-center text-gray-600 text-xs mt-6">
|
||||||
|
By signing in, you agree to AeThex's <span className="text-gray-500 hover:text-gray-400 cursor-pointer">Terms of Service</span> and <span className="text-gray-500 hover:text-gray-400 cursor-pointer">Privacy Policy</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginScreen;
|
||||||
|
|
@ -1,16 +1,156 @@
|
||||||
import React from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import ServerList from "./ServerList";
|
import ServerList from "./ServerList";
|
||||||
import ChannelSidebar from "./ChannelSidebar";
|
import ChannelSidebar from "./ChannelSidebar";
|
||||||
import ChatArea from "./ChatArea";
|
import ChatArea from "./ChatArea";
|
||||||
import MemberSidebar from "./MemberSidebar";
|
import MemberSidebar from "./MemberSidebar";
|
||||||
|
import ActivityPanel from "./ActivityPanel";
|
||||||
|
import SettingsPanel from "./SettingsPanel";
|
||||||
|
import QuickSwitcher from "./QuickSwitcher";
|
||||||
|
import CreateServerModal from "./CreateServerModal";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
import { useToast } from "./ToastProvider";
|
||||||
|
|
||||||
const MainLayout: React.FC = () => {
|
const MainLayout: React.FC = () => {
|
||||||
|
const [showActivity, setShowActivity] = useState(true);
|
||||||
|
const [showMembers, setShowMembers] = useState(true);
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [showQuickSwitcher, setShowQuickSwitcher] = useState(false);
|
||||||
|
const [showCreateServer, setShowCreateServer] = useState(false);
|
||||||
|
|
||||||
|
const { servers, channels, createServer, selectServer, selectChannel } = useApp();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
|
// Global keyboard shortcuts
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Ctrl+K or Cmd+K - Quick Switcher
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowQuickSwitcher(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+, or Cmd+, - Settings
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowSettings(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape - Close any modal
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showSettings) setShowSettings(false);
|
||||||
|
if (showQuickSwitcher) setShowQuickSwitcher(false);
|
||||||
|
if (showCreateServer) setShowCreateServer(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [showSettings, showQuickSwitcher, showCreateServer]);
|
||||||
|
|
||||||
|
const handleCreateServer = useCallback(async (name: string, icon?: string) => {
|
||||||
|
try {
|
||||||
|
await createServer(name, icon);
|
||||||
|
addToast({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Server Created',
|
||||||
|
message: `"${name}" is ready to go!`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
addToast({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Failed to Create Server',
|
||||||
|
message: error.message || 'Something went wrong',
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [createServer, addToast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="connect-container flex h-screen">
|
<div className="connect-container flex h-screen bg-[#050507] overflow-hidden">
|
||||||
<ServerList />
|
{/* Ambient glow effects */}
|
||||||
<ChannelSidebar />
|
<div className="fixed top-0 left-1/4 w-96 h-96 bg-indigo-600/10 rounded-full blur-[120px] pointer-events-none" />
|
||||||
<ChatArea />
|
<div className="fixed bottom-0 right-1/4 w-96 h-96 bg-purple-600/10 rounded-full blur-[120px] pointer-events-none" />
|
||||||
<MemberSidebar />
|
<div className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-600/5 rounded-full blur-[100px] pointer-events-none" />
|
||||||
|
|
||||||
|
<ServerList
|
||||||
|
onOpenCreateServer={() => setShowCreateServer(true)}
|
||||||
|
/>
|
||||||
|
<ChannelSidebar
|
||||||
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
|
/>
|
||||||
|
<ChatArea
|
||||||
|
onToggleMembers={() => setShowMembers(!showMembers)}
|
||||||
|
onToggleActivity={() => setShowActivity(!showActivity)}
|
||||||
|
showMembers={showMembers}
|
||||||
|
showActivity={showActivity}
|
||||||
|
onOpenQuickSwitcher={() => setShowQuickSwitcher(true)}
|
||||||
|
/>
|
||||||
|
{showMembers && <MemberSidebar />}
|
||||||
|
{showActivity && <ActivityPanel />}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<SettingsPanel
|
||||||
|
isOpen={showSettings}
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<QuickSwitcher
|
||||||
|
isOpen={showQuickSwitcher}
|
||||||
|
onClose={() => setShowQuickSwitcher(false)}
|
||||||
|
servers={servers}
|
||||||
|
channels={channels}
|
||||||
|
onSelectServer={selectServer}
|
||||||
|
onSelectChannel={selectChannel}
|
||||||
|
onOpenSettings={() => {
|
||||||
|
setShowQuickSwitcher(false);
|
||||||
|
setShowSettings(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CreateServerModal
|
||||||
|
isOpen={showCreateServer}
|
||||||
|
onClose={() => setShowCreateServer(false)}
|
||||||
|
onCreateServer={handleCreateServer}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Keyboard shortcut hints - show briefly on load */}
|
||||||
|
<KeyboardHints />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component to show keyboard hints briefly
|
||||||
|
const KeyboardHints: React.FC = () => {
|
||||||
|
const [show, setShow] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setShow(false), 5000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 px-4 py-2 bg-black/80 backdrop-blur-xl rounded-full border border-white/10 text-xs text-gray-400 animate-fadeIn">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/10 rounded text-gray-300">Ctrl</kbd>
|
||||||
|
<span>+</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/10 rounded text-gray-300">K</kbd>
|
||||||
|
<span className="ml-1">Quick Switcher</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-4 bg-white/10" />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/10 rounded text-gray-300">Ctrl</kbd>
|
||||||
|
<span>+</span>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/10 rounded text-gray-300">,</kbd>
|
||||||
|
<span className="ml-1">Settings</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShow(false)}
|
||||||
|
className="ml-2 text-gray-500 hover:text-white"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,114 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
const members = [
|
const MemberSidebar: React.FC = () => {
|
||||||
{ section: "Foundation Team — 8", users: [
|
const { members, currentServer } = useApp();
|
||||||
{ name: "Anderson", avatar: "A", status: "online", avatarBg: "from-red-600 to-red-800" },
|
|
||||||
{ name: "Trevor", avatar: "T", status: "online", avatarBg: "from-red-600 to-red-800" },
|
|
||||||
]},
|
|
||||||
{ section: "Labs Team — 12", users: [
|
|
||||||
{ name: "Sarah", avatar: "S", status: "labs", avatarBg: "from-orange-400 to-orange-700", activity: "Testing v2.0" },
|
|
||||||
]},
|
|
||||||
{ section: "Developers — 47", users: [
|
|
||||||
{ name: "Marcus", avatar: "M", status: "in-game", avatarBg: "bg-[#1a1a1a]", activity: "Building" },
|
|
||||||
{ name: "DevUser_2847", avatar: "D", status: "online", avatarBg: "bg-[#1a1a1a]" },
|
|
||||||
]},
|
|
||||||
{ section: "Community — 61", users: [
|
|
||||||
{ name: "JohnDev", avatar: "J", status: "offline", avatarBg: "bg-[#1a1a1a]" },
|
|
||||||
]},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MemberSidebar: React.FC = () => (
|
if (!currentServer) {
|
||||||
<div className="member-sidebar w-72 bg-[#0f0f0f] border-l border-[#1a1a1a] flex flex-col">
|
return null;
|
||||||
<div className="member-header p-4 border-b border-[#1a1a1a] text-xs uppercase tracking-widest text-gray-500">Members — 128</div>
|
}
|
||||||
<div className="member-list flex-1 overflow-y-auto py-3">
|
|
||||||
{members.map((section, i) => (
|
// Group members by role
|
||||||
<div key={i} className="member-section mb-4">
|
const grouped = members.reduce((acc, member) => {
|
||||||
<div className="member-section-title px-4 py-2 text-xs uppercase tracking-wider text-gray-500 font-bold">{section.section}</div>
|
const role = member.role || 'member';
|
||||||
{section.users.map((user, j) => (
|
if (!acc[role]) acc[role] = [];
|
||||||
<div key={j} className="member-item flex items-center gap-3 px-4 py-1.5 cursor-pointer hover:bg-[#1a1a1a]">
|
acc[role].push(member);
|
||||||
<div className={`member-avatar-small w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm relative bg-gradient-to-tr ${user.avatarBg}`}>
|
return acc;
|
||||||
{user.avatar}
|
}, {} as Record<string, typeof members>);
|
||||||
<div className={`online-indicator absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-[#0f0f0f] ${user.status === "online" ? "bg-green-400" : user.status === "in-game" ? "bg-blue-500" : user.status === "labs" ? "bg-orange-400" : "bg-gray-700"}`}></div>
|
|
||||||
|
const roleOrder = ['owner', 'admin', 'moderator', 'member'];
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
owner: '👑 Owner',
|
||||||
|
admin: '⚡ Admins',
|
||||||
|
moderator: '🛡️ Moderators',
|
||||||
|
member: 'Members'
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
owner: 'text-amber-400',
|
||||||
|
admin: 'text-rose-400',
|
||||||
|
moderator: 'text-emerald-400',
|
||||||
|
member: 'text-white/70'
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'online': return 'bg-emerald-500';
|
||||||
|
case 'away': return 'bg-amber-500';
|
||||||
|
case 'dnd': return 'bg-rose-500';
|
||||||
|
default: return 'bg-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGradient = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'owner': return 'from-amber-600 to-orange-600';
|
||||||
|
case 'admin': return 'from-rose-600 to-pink-600';
|
||||||
|
case 'moderator': return 'from-emerald-600 to-cyan-600';
|
||||||
|
default: return 'from-indigo-600 to-violet-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="member-sidebar w-60 bg-[#08080a]/80 backdrop-blur-xl border-l border-white/5 flex flex-col">
|
||||||
|
<div className="member-header px-4 py-3 border-b border-white/5">
|
||||||
|
<div className="text-[11px] uppercase tracking-wider text-white/40 font-semibold">
|
||||||
|
Members — {members.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="member-list flex-1 overflow-y-auto py-2 px-2">
|
||||||
|
{roleOrder.map(role => {
|
||||||
|
const roleMembers = grouped[role];
|
||||||
|
if (!roleMembers || roleMembers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={role} className="member-section mb-3">
|
||||||
|
<div className={`member-section-title px-2 py-1.5 text-[11px] uppercase tracking-wider font-semibold ${roleColors[role]}`}>
|
||||||
|
{roleLabels[role]} — {roleMembers.length}
|
||||||
|
</div>
|
||||||
|
{roleMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="member-item group flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
<div className={`member-avatar w-8 h-8 rounded-full flex items-center justify-center font-semibold text-xs relative bg-gradient-to-br ${getGradient(member.role)}`}>
|
||||||
|
{member.avatar_url ? (
|
||||||
|
<img src={member.avatar_url} alt={member.username} className="w-full h-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-white">{member.username?.charAt(0).toUpperCase() || 'U'}</span>
|
||||||
|
)}
|
||||||
|
<div className={`online-indicator absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-[#08080a] ${getStatusColor(member.status)}`} />
|
||||||
|
</div>
|
||||||
|
<div className="member-info flex-1 min-w-0">
|
||||||
|
<div className={`member-name text-sm truncate ${roleColors[member.role] || 'text-white/80'}`}>
|
||||||
|
{member.nickname || member.display_name || member.username}
|
||||||
|
</div>
|
||||||
|
{member.custom_status && (
|
||||||
|
<div className="text-[11px] text-white/40 truncate">{member.custom_status}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Hover Actions */}
|
||||||
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button className="w-6 h-6 rounded flex items-center justify-center text-white/40 hover:text-white/70 hover:bg-white/10">
|
||||||
|
💬
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="member-name flex-1 text-sm">{user.name}</div>
|
|
||||||
{user.activity && <div className="member-activity text-xs text-gray-500">{user.activity}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{members.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-3xl mb-2">👥</div>
|
||||||
|
<p className="text-sm text-white/40">No members yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default MemberSidebar;
|
export default MemberSidebar;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
type: "system" | "user";
|
type: "system" | "user";
|
||||||
|
|
@ -9,33 +9,94 @@ interface MessageProps {
|
||||||
time?: string;
|
time?: string;
|
||||||
text: string;
|
text: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
avatarBg?: string;
|
avatarBg?: string;
|
||||||
|
showAvatar?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Message: React.FC<MessageProps> = (props) => {
|
const Message: React.FC<MessageProps> = (props) => {
|
||||||
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
const { showAvatar = true, compact = false } = props;
|
||||||
|
|
||||||
if (props.type === "system") {
|
if (props.type === "system") {
|
||||||
return (
|
return (
|
||||||
<div className={`message-system ${props.className} bg-[#0f0f0f] border-l-4 pl-4 pr-4 py-3 mb-4 text-sm`}>
|
<div className="flex items-center gap-3 py-2 px-4 my-2">
|
||||||
<div className={`system-label ${props.className} text-xs uppercase tracking-wider font-bold mb-1`}>[{props.label}] System Announcement</div>
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||||
<div>{props.text}</div>
|
<span className="text-xs text-white/40 uppercase tracking-wider font-medium">
|
||||||
|
{props.label || 'System'}: {props.text}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 h-px bg-gradient-to-r from-transparent via-white/10 to-transparent" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<div className="message flex gap-4 mb-5 p-3 rounded transition hover:bg-[#0f0f0f]">
|
<div
|
||||||
<div className={`message-avatar w-10 h-10 rounded-full flex items-center justify-center font-bold text-base flex-shrink-0 bg-gradient-to-tr ${props.avatarBg}`}>{props.avatar}</div>
|
className="message group flex items-start gap-4 py-0.5 px-4 hover:bg-white/[0.02] transition-colors relative"
|
||||||
<div className="message-content flex-1">
|
onMouseEnter={() => setShowActions(true)}
|
||||||
<div className="message-header flex items-baseline gap-3 mb-1">
|
onMouseLeave={() => setShowActions(false)}
|
||||||
<span className="message-author font-bold">{props.author}</span>
|
>
|
||||||
{props.badge && (
|
<div className="w-10 flex-shrink-0 text-right">
|
||||||
<span className={`message-badge ${props.className} text-xs px-2 py-1 rounded uppercase tracking-wider font-bold`}>{props.badge}</span>
|
<span className="text-[10px] text-white/30 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{props.time}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-[15px] text-white/80 leading-relaxed">{props.text}</div>
|
||||||
|
{showActions && <MessageActions />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="message group flex gap-4 py-2 px-4 mt-4 hover:bg-white/[0.02] transition-colors rounded-lg relative"
|
||||||
|
onMouseEnter={() => setShowActions(true)}
|
||||||
|
onMouseLeave={() => setShowActions(false)}
|
||||||
|
>
|
||||||
|
{showAvatar && (
|
||||||
|
<div className={`message-avatar w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm flex-shrink-0 bg-gradient-to-br ${props.avatarBg || 'from-indigo-600 to-violet-600'}`}>
|
||||||
|
{props.avatarUrl ? (
|
||||||
|
<img src={props.avatarUrl} alt={props.author} className="w-full h-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-white">{props.avatar}</span>
|
||||||
)}
|
)}
|
||||||
<span className="message-time text-xs text-gray-500">{props.time}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="message-text leading-relaxed text-gray-300">{props.text}</div>
|
)}
|
||||||
|
<div className="message-content flex-1 min-w-0">
|
||||||
|
<div className="message-header flex items-baseline gap-2 mb-0.5">
|
||||||
|
<span className="message-author font-semibold text-white hover:underline cursor-pointer">
|
||||||
|
{props.author}
|
||||||
|
</span>
|
||||||
|
{props.badge && (
|
||||||
|
<span className="message-badge text-[10px] px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-400 border border-indigo-500/30 uppercase tracking-wider font-bold">
|
||||||
|
{props.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="message-time text-xs text-white/30">{props.time}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="message-text text-[15px] leading-relaxed text-white/80 break-words">
|
||||||
|
{props.text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showActions && <MessageActions />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MessageActions: React.FC = () => (
|
||||||
|
<div className="absolute -top-4 right-4 flex items-center gap-0.5 p-1 rounded-lg bg-[#111113] border border-white/10 shadow-xl opacity-0 group-hover:opacity-100 transition-all">
|
||||||
|
<button className="w-8 h-8 rounded flex items-center justify-center text-white/50 hover:bg-white/10 hover:text-white/80 transition-all" title="Add Reaction">
|
||||||
|
😀
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded flex items-center justify-center text-white/50 hover:bg-white/10 hover:text-white/80 transition-all" title="Reply">
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
<button className="w-8 h-8 rounded flex items-center justify-center text-white/50 hover:bg-white/10 hover:text-white/80 transition-all" title="More">
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
export default Message;
|
export default Message;
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,136 @@
|
||||||
import React from "react";
|
import React, { useState, useRef } from "react";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
const MessageInput: React.FC = () => (
|
const MessageInput: React.FC = () => {
|
||||||
<div className="flex items-center gap-2">
|
const { currentChannel, sendMessage } = useApp();
|
||||||
<button className="attachButton w-10 h-10 flex items-center justify-center rounded bg-[#1a1a1a] text-xl text-gray-400 mr-2">+</button>
|
const [message, setMessage] = useState('');
|
||||||
<input
|
const [sending, setSending] = useState(false);
|
||||||
type="text"
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
className="message-input flex-1 bg-[#0f0f0f] border border-[#1a1a1a] rounded-lg px-4 py-3 text-gray-200 text-sm focus:outline-none focus:border-blue-500 placeholder:text-gray-500"
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
placeholder="Message #general (Foundation infrastructure channel)"
|
|
||||||
maxLength={2000}
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
/>
|
e.preventDefault();
|
||||||
<button className="sendButton w-10 h-10 flex items-center justify-center rounded bg-blue-600 text-xl text-white ml-2">🎤</button>
|
if (!message.trim() || sending) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
await sendMessage(message);
|
||||||
|
setMessage('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send message:', error);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEmoji = (emoji: string) => {
|
||||||
|
setMessage(prev => prev + emoji);
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickEmojis = ['😀', '😂', '❤️', '👍', '🔥', '✨', '🎉', '💯'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-end gap-3 bg-[#111113] rounded-xl p-2 border border-white/5">
|
||||||
|
{/* Left Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-all text-lg"
|
||||||
|
title="Attach File"
|
||||||
|
>
|
||||||
|
📎
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-all text-lg"
|
||||||
|
title="GIF"
|
||||||
|
>
|
||||||
|
🖼️
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full bg-transparent text-white text-[15px] leading-relaxed focus:outline-none placeholder:text-white/30 resize-none min-h-[24px] max-h-[200px] py-2"
|
||||||
|
placeholder={currentChannel ? `Message #${currentChannel.name}` : 'Select a channel to chat'}
|
||||||
|
maxLength={2000}
|
||||||
|
disabled={!currentChannel || sending}
|
||||||
|
rows={1}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
|
onInput={(e) => {
|
||||||
|
const target = e.target as HTMLTextAreaElement;
|
||||||
|
target.style.height = 'auto';
|
||||||
|
target.style.height = Math.min(target.scrollHeight, 200) + 'px';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Actions */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-all text-lg"
|
||||||
|
title="Emoji"
|
||||||
|
>
|
||||||
|
😊
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Quick Emoji Picker */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div className="absolute bottom-full right-0 mb-2 p-2 bg-[#111113] rounded-xl border border-white/10 shadow-xl flex gap-1">
|
||||||
|
{quickEmojis.map(emoji => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onClick={() => addEmoji(emoji)}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white/10 transition-all text-xl"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!message.trim() || sending}
|
||||||
|
className="w-10 h-10 flex items-center justify-center rounded-lg bg-gradient-to-r from-indigo-600 to-violet-600 text-white hover:from-indigo-500 hover:to-violet-500 disabled:opacity-30 disabled:cursor-not-allowed transition-all shadow-lg shadow-indigo-500/20"
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm">↑</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Character Count */}
|
||||||
|
{message.length > 1800 && (
|
||||||
|
<div className={`absolute -top-6 right-2 text-xs ${message.length > 1950 ? 'text-rose-400' : 'text-white/40'}`}>
|
||||||
|
{message.length}/2000
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default MessageInput;
|
export default MessageInput;
|
||||||
|
|
|
||||||
289
packages/desktop/src/renderer/components/QuickSwitcher.tsx
Normal file
289
packages/desktop/src/renderer/components/QuickSwitcher.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface QuickSwitcherItem {
|
||||||
|
id: string;
|
||||||
|
type: 'server' | 'channel' | 'dm' | 'action';
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickSwitcherProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
servers?: any[];
|
||||||
|
channels?: any[];
|
||||||
|
dms?: any[];
|
||||||
|
onSelectServer?: (serverId: string) => void;
|
||||||
|
onSelectChannel?: (channelId: string) => void;
|
||||||
|
onOpenSettings?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QuickSwitcher: React.FC<QuickSwitcherProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
servers = [],
|
||||||
|
channels = [],
|
||||||
|
dms = [],
|
||||||
|
onSelectServer,
|
||||||
|
onSelectChannel,
|
||||||
|
onOpenSettings,
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const actionItems: QuickSwitcherItem[] = [
|
||||||
|
{ id: 'settings', type: 'action', name: 'Open Settings', icon: '⚙️', action: onOpenSettings },
|
||||||
|
{ id: 'create-server', type: 'action', name: 'Create Server', icon: '➕' },
|
||||||
|
{ id: 'join-server', type: 'action', name: 'Join Server', icon: '🔗' },
|
||||||
|
{ id: 'add-friend', type: 'action', name: 'Add Friend', icon: '👤' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allItems = useMemo(() => {
|
||||||
|
const items: QuickSwitcherItem[] = [];
|
||||||
|
|
||||||
|
// Add servers
|
||||||
|
servers.forEach(server => {
|
||||||
|
items.push({
|
||||||
|
id: `server-${server.id}`,
|
||||||
|
type: 'server',
|
||||||
|
name: server.name,
|
||||||
|
icon: server.icon || '🏠',
|
||||||
|
subtitle: 'Server',
|
||||||
|
action: () => onSelectServer?.(server.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add channels
|
||||||
|
channels.forEach(channel => {
|
||||||
|
items.push({
|
||||||
|
id: `channel-${channel.id}`,
|
||||||
|
type: 'channel',
|
||||||
|
name: channel.name,
|
||||||
|
icon: channel.channel_type === 'voice' ? '🔊' : '#',
|
||||||
|
subtitle: 'Channel',
|
||||||
|
action: () => onSelectChannel?.(channel.id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add DMs
|
||||||
|
dms.forEach(dm => {
|
||||||
|
items.push({
|
||||||
|
id: `dm-${dm.id}`,
|
||||||
|
type: 'dm',
|
||||||
|
name: dm.name,
|
||||||
|
icon: '💬',
|
||||||
|
subtitle: 'Direct Message',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add actions
|
||||||
|
items.push(...actionItems);
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [servers, channels, dms, onSelectServer, onSelectChannel, onOpenSettings]);
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
// Show recent items and actions when no query
|
||||||
|
return allItems.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
return allItems.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(lowerQuery) ||
|
||||||
|
item.subtitle?.toLowerCase().includes(lowerQuery)
|
||||||
|
);
|
||||||
|
}, [query, allItems]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.min(prev + 1, filteredItems.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredItems[selectedIndex]?.action) {
|
||||||
|
filteredItems[selectedIndex].action();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, filteredItems, selectedIndex, onClose]);
|
||||||
|
|
||||||
|
// Scroll selected item into view
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
const selectedElement = listRef.current.children[selectedIndex] as HTMLElement;
|
||||||
|
if (selectedElement) {
|
||||||
|
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const getTypeIcon = (type: string, icon?: string) => {
|
||||||
|
if (icon && icon.length <= 2) return icon;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'server':
|
||||||
|
return '🏠';
|
||||||
|
case 'channel':
|
||||||
|
return '#';
|
||||||
|
case 'dm':
|
||||||
|
return '💬';
|
||||||
|
case 'action':
|
||||||
|
return icon || '⚡';
|
||||||
|
default:
|
||||||
|
return '📄';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'server':
|
||||||
|
return 'text-indigo-400';
|
||||||
|
case 'channel':
|
||||||
|
return 'text-gray-400';
|
||||||
|
case 'dm':
|
||||||
|
return 'text-green-400';
|
||||||
|
case 'action':
|
||||||
|
return 'text-purple-400';
|
||||||
|
default:
|
||||||
|
return 'text-gray-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-xl bg-[#0a0a0c] rounded-2xl border border-white/10 shadow-2xl overflow-hidden animate-fadeIn">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Where would you like to go?"
|
||||||
|
className="w-full pl-12 pr-4 py-4 bg-transparent text-white text-lg placeholder-gray-500 focus:outline-none border-b border-white/10"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/10 rounded border border-white/10">ESC</kbd>
|
||||||
|
to close
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
<div ref={listRef} className="max-h-80 overflow-y-auto py-2">
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-gray-500">
|
||||||
|
<p className="text-lg mb-1">No results found</p>
|
||||||
|
<p className="text-sm">Try searching for servers, channels, or friends</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => {
|
||||||
|
item.action?.();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-3 px-4 py-2.5 transition-colors
|
||||||
|
${index === selectedIndex ? 'bg-white/10' : 'hover:bg-white/5'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className={`w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center ${getTypeColor(item.type)}`}>
|
||||||
|
{getTypeIcon(item.type, item.icon)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left">
|
||||||
|
<p className="text-white font-medium">{item.name}</p>
|
||||||
|
{item.subtitle && (
|
||||||
|
<p className="text-gray-500 text-xs">{item.subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{index === selectedIndex && (
|
||||||
|
<div className="flex items-center gap-1 text-gray-500 text-xs">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/10 rounded border border-white/10">↵</kbd>
|
||||||
|
to select
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 py-3 border-t border-white/10 flex items-center gap-4 text-xs text-gray-500">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">↑</kbd>
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">↓</kbd>
|
||||||
|
<span className="ml-1">Navigate</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">↵</kbd>
|
||||||
|
<span className="ml-1">Select</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<kbd className="px-1.5 py-0.5 bg-white/5 rounded border border-white/10">ESC</kbd>
|
||||||
|
<span className="ml-1">Close</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuickSwitcher;
|
||||||
|
|
@ -1,30 +1,234 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
|
import { useApp } from "../context/AppContext";
|
||||||
|
|
||||||
const servers = [
|
interface ServerListProps {
|
||||||
{ id: "foundation", label: "F", active: true, className: "foundation" },
|
onOpenCreateServer?: () => void;
|
||||||
{ id: "corporation", label: "C", active: false, className: "corporation" },
|
}
|
||||||
{ id: "labs", label: "L", active: false, className: "labs" },
|
|
||||||
{ id: "divider" },
|
|
||||||
{ id: "community1", label: "AG", active: false, className: "community" },
|
|
||||||
{ id: "community2", label: "RD", active: false, className: "community" },
|
|
||||||
{ id: "add", label: "+", active: false, className: "community" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ServerList: React.FC = () => (
|
const ServerList: React.FC<ServerListProps> = ({ onOpenCreateServer }) => {
|
||||||
<div className="server-list flex flex-col items-center py-3 gap-3 w-20 bg-[#0d0d0d] border-r border-[#1a1a1a]">
|
const { servers, currentServer, selectServer, createServer, joinServer } = useApp();
|
||||||
{servers.map((srv, i) =>
|
const [showModal, setShowModal] = useState(false);
|
||||||
srv.id === "divider" ? (
|
const [modalMode, setModalMode] = useState<'create' | 'join'>('create');
|
||||||
<div key={i} className="server-divider w-10 h-0.5 bg-[#1a1a1a] my-1" />
|
const [serverName, setServerName] = useState('');
|
||||||
) : (
|
const [inviteCode, setInviteCode] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [hoveredServer, setHoveredServer] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!serverName.trim()) return;
|
||||||
|
try {
|
||||||
|
const server = await createServer(serverName);
|
||||||
|
selectServer(server);
|
||||||
|
setShowModal(false);
|
||||||
|
setServerName('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleJoin = async () => {
|
||||||
|
if (!inviteCode.trim()) return;
|
||||||
|
try {
|
||||||
|
const server = await joinServer(inviteCode);
|
||||||
|
selectServer(server);
|
||||||
|
setShowModal(false);
|
||||||
|
setInviteCode('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddServer = () => {
|
||||||
|
if (onOpenCreateServer) {
|
||||||
|
onOpenCreateServer();
|
||||||
|
} else {
|
||||||
|
setShowModal(true);
|
||||||
|
setModalMode('create');
|
||||||
|
setError('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="server-list flex flex-col items-center py-3 gap-2 w-[76px] bg-[#08080a]/80 backdrop-blur-xl border-r border-white/5">
|
||||||
|
{/* AeThex Home Button */}
|
||||||
|
<div className="relative group mb-1">
|
||||||
|
<div
|
||||||
|
className="server-icon w-12 h-12 rounded-2xl flex items-center justify-center font-bold text-lg cursor-pointer transition-all duration-300 bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-600 hover:rounded-xl shadow-lg shadow-indigo-500/20 hover:shadow-indigo-500/40"
|
||||||
|
title="Home"
|
||||||
|
>
|
||||||
|
<span className="text-white font-black text-xl">A</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-[10px] w-1 h-5 rounded-r-full bg-white opacity-0 group-hover:opacity-100 transition-all" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-8 h-0.5 bg-white/10 rounded-full my-1" />
|
||||||
|
|
||||||
|
{/* Server Icons */}
|
||||||
|
<div className="flex flex-col gap-2 flex-1 overflow-y-auto scrollbar-hide">
|
||||||
|
{servers.map((srv) => (
|
||||||
<div
|
<div
|
||||||
key={srv.id}
|
key={srv.id}
|
||||||
className={`server-icon ${srv.className} ${srv.active ? "active" : ""} w-14 h-14 rounded-xl flex items-center justify-center font-bold text-lg cursor-pointer transition-all relative`}
|
className="relative group"
|
||||||
|
onMouseEnter={() => setHoveredServer(srv.id)}
|
||||||
|
onMouseLeave={() => setHoveredServer(null)}
|
||||||
>
|
>
|
||||||
{srv.label}
|
<div className={`absolute left-0 top-1/2 -translate-y-1/2 -translate-x-[10px] w-1 rounded-r-full bg-white transition-all duration-200 ${
|
||||||
</div>
|
currentServer?.id === srv.id ? 'h-10 opacity-100' : hoveredServer === srv.id ? 'h-5 opacity-100' : 'h-0 opacity-0'
|
||||||
)
|
}`} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => selectServer(srv)}
|
||||||
|
className={`server-icon w-12 h-12 flex items-center justify-center font-bold text-base cursor-pointer transition-all duration-200 ${
|
||||||
|
currentServer?.id === srv.id
|
||||||
|
? 'rounded-xl bg-gradient-to-br from-indigo-600 to-violet-600 shadow-lg shadow-indigo-500/30'
|
||||||
|
: 'rounded-3xl bg-[#1a1a1e] hover:rounded-xl hover:bg-indigo-600'
|
||||||
|
}`}
|
||||||
|
title={srv.name}
|
||||||
|
>
|
||||||
|
{srv.icon_url ? (
|
||||||
|
<img src={srv.icon_url} alt={srv.name} className={`w-full h-full object-cover transition-all duration-200 ${
|
||||||
|
currentServer?.id === srv.id ? 'rounded-xl' : 'rounded-3xl group-hover:rounded-xl'
|
||||||
|
}`} />
|
||||||
|
) : (
|
||||||
|
<span className="text-white/90">{srv.name.charAt(0).toUpperCase()}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div className={`absolute left-full ml-4 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#111113] rounded-lg shadow-xl border border-white/10 whitespace-nowrap z-50 transition-all duration-200 ${
|
||||||
|
hoveredServer === srv.id ? 'opacity-100 translate-x-0' : 'opacity-0 -translate-x-2 pointer-events-none'
|
||||||
|
}`}>
|
||||||
|
<div className="text-sm font-semibold text-white">{srv.name}</div>
|
||||||
|
{srv.member_count && <div className="text-xs text-white/50">{srv.member_count} members</div>}
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1 w-2 h-2 bg-[#111113] rotate-45 border-l border-b border-white/10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-8 h-0.5 bg-white/10 rounded-full my-1" />
|
||||||
|
|
||||||
|
{/* Add Server Button */}
|
||||||
|
<div className="relative group">
|
||||||
|
<div
|
||||||
|
onClick={handleAddServer}
|
||||||
|
className="server-icon w-12 h-12 rounded-3xl flex items-center justify-center text-2xl cursor-pointer bg-[#1a1a1e] text-emerald-500 hover:bg-emerald-600 hover:text-white hover:rounded-xl transition-all duration-200"
|
||||||
|
title="Add a Server"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-[10px] w-1 h-0 rounded-r-full bg-white group-hover:h-5 transition-all" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Discovery Button */}
|
||||||
|
<div className="relative group">
|
||||||
|
<div
|
||||||
|
className="server-icon w-12 h-12 rounded-3xl flex items-center justify-center text-xl cursor-pointer bg-[#1a1a1e] text-white/60 hover:bg-emerald-600 hover:text-white hover:rounded-xl transition-all duration-200"
|
||||||
|
title="Explore Public Servers"
|
||||||
|
>
|
||||||
|
🧭
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Enhanced Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50" onClick={() => setShowModal(false)}>
|
||||||
|
<div className="bg-[#111113] rounded-2xl w-[440px] overflow-hidden shadow-2xl border border-white/10" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="relative p-8 pb-4 text-center bg-gradient-to-b from-indigo-600/20 to-transparent">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<button onClick={() => setShowModal(false)} className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/60 hover:text-white hover:bg-white/20 transition-all">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-bold text-white mb-2">
|
||||||
|
{modalMode === 'create' ? 'Create Your Server' : 'Join a Server'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-white/50 text-sm">
|
||||||
|
{modalMode === 'create'
|
||||||
|
? 'Your server is where you and your friends hang out.'
|
||||||
|
: 'Enter an invite code to join an existing server.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-8 pt-4">
|
||||||
|
<div className="flex rounded-xl bg-black/30 p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalMode('create')}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
modalMode === 'create' ? 'bg-white/10 text-white shadow' : 'text-white/50 hover:text-white/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Create New
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setModalMode('join')}
|
||||||
|
className={`flex-1 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
modalMode === 'join' ? 'bg-white/10 text-white shadow' : 'text-white/50 hover:text-white/70'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Join Existing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 pt-6">
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-rose-500/20 border border-rose-500/30 text-rose-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalMode === 'create' ? (
|
||||||
|
<>
|
||||||
|
<label className="block text-xs uppercase tracking-wider text-white/50 font-semibold mb-2">
|
||||||
|
Server Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serverName}
|
||||||
|
onChange={(e) => setServerName(e.target.value)}
|
||||||
|
placeholder="Enter server name..."
|
||||||
|
className="w-full px-4 py-3.5 bg-black/30 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500/50 transition-all"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={!serverName.trim()}
|
||||||
|
className="w-full mt-6 py-3.5 bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-all shadow-lg shadow-indigo-500/25"
|
||||||
|
>
|
||||||
|
Create Server
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="block text-xs uppercase tracking-wider text-white/50 font-semibold mb-2">
|
||||||
|
Invite Code
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inviteCode}
|
||||||
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
|
placeholder="abc123"
|
||||||
|
className="w-full px-4 py-3.5 bg-black/30 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500/50 transition-all font-mono text-lg tracking-wider text-center"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleJoin}
|
||||||
|
disabled={!inviteCode.trim()}
|
||||||
|
className="w-full mt-6 py-3.5 bg-gradient-to-r from-emerald-600 to-cyan-600 hover:from-emerald-500 hover:to-cyan-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-xl transition-all shadow-lg shadow-emerald-500/25"
|
||||||
|
>
|
||||||
|
Join Server
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ServerList;
|
export default ServerList;
|
||||||
|
|
|
||||||
605
packages/desktop/src/renderer/components/SettingsPanel.tsx
Normal file
605
packages/desktop/src/renderer/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,605 @@
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsTab = 'account' | 'appearance' | 'voice' | 'notifications' | 'keybinds' | 'advanced';
|
||||||
|
|
||||||
|
const SettingsPanel: React.FC<SettingsPanelProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const [activeTab, setActiveTab] = useState<SettingsTab>('account');
|
||||||
|
const [theme, setTheme] = useState('dark');
|
||||||
|
const [accentColor, setAccentColor] = useState('#6366f1');
|
||||||
|
const [notifications, setNotifications] = useState({
|
||||||
|
desktop: true,
|
||||||
|
sounds: true,
|
||||||
|
messages: true,
|
||||||
|
mentions: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const tabs: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||||
|
{
|
||||||
|
id: 'account',
|
||||||
|
label: 'My Account',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'appearance',
|
||||||
|
label: 'Appearance',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'voice',
|
||||||
|
label: 'Voice & Video',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notifications',
|
||||||
|
label: 'Notifications',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'keybinds',
|
||||||
|
label: 'Keybinds',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'advanced',
|
||||||
|
label: 'Advanced',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const accentColors = [
|
||||||
|
{ name: 'Indigo', value: '#6366f1' },
|
||||||
|
{ name: 'Purple', value: '#a855f7' },
|
||||||
|
{ name: 'Pink', value: '#ec4899' },
|
||||||
|
{ name: 'Red', value: '#ef4444' },
|
||||||
|
{ name: 'Orange', value: '#f97316' },
|
||||||
|
{ name: 'Green', value: '#22c55e' },
|
||||||
|
{ name: 'Cyan', value: '#06b6d4' },
|
||||||
|
{ name: 'Blue', value: '#3b82f6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'account':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Profile Card */}
|
||||||
|
<div className="bg-gradient-to-br from-indigo-600/20 to-purple-600/20 rounded-2xl p-6 border border-white/10">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-3xl font-bold">
|
||||||
|
{user?.displayName?.[0]?.toUpperCase() || user?.username?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
<button className="absolute -bottom-1 -right-1 w-8 h-8 bg-indigo-600 hover:bg-indigo-500 rounded-full flex items-center justify-center border-4 border-[#0a0a0c] transition-colors">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-xl font-bold text-white">{user?.displayName || user?.username}</h3>
|
||||||
|
<p className="text-gray-400">@{user?.username}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="px-2 py-0.5 bg-indigo-500/20 text-indigo-400 text-xs rounded-full border border-indigo-500/30">
|
||||||
|
PRO
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 text-sm">Member since 2026</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-gray-300 transition-colors border border-white/10">
|
||||||
|
Edit Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Details */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Account Details</h4>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<label className="text-xs text-gray-500 uppercase">Username</label>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-white">{user?.username}</span>
|
||||||
|
<button className="text-indigo-400 hover:text-indigo-300 text-sm">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<label className="text-xs text-gray-500 uppercase">Email</label>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-white">{(user as any)?.email || 'Not set'}</span>
|
||||||
|
<button className="text-indigo-400 hover:text-indigo-300 text-sm">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<label className="text-xs text-gray-500 uppercase">Phone Number</label>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-gray-500">Not set</span>
|
||||||
|
<button className="text-indigo-400 hover:text-indigo-300 text-sm">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password & Security */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Password & Authentication</h4>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">Password</p>
|
||||||
|
<p className="text-gray-500 text-sm">Last changed 30 days ago</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-white/5 hover:bg-white/10 rounded-lg text-sm text-gray-300 transition-colors border border-white/10">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">Two-Factor Authentication</p>
|
||||||
|
<p className="text-gray-500 text-sm">Add an extra layer of security</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 rounded-lg text-sm text-white transition-colors">
|
||||||
|
Enable 2FA
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-red-400 uppercase tracking-wider">Danger Zone</h4>
|
||||||
|
|
||||||
|
<div className="bg-red-500/10 rounded-xl p-4 border border-red-500/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">Delete Account</p>
|
||||||
|
<p className="text-gray-500 text-sm">Permanently delete your account and all data</p>
|
||||||
|
</div>
|
||||||
|
<button className="px-4 py-2 bg-red-600 hover:bg-red-500 rounded-lg text-sm text-white transition-colors">
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'appearance':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Theme Selection */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Theme</h4>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{['dark', 'light', 'amoled'].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`p-4 rounded-xl border transition-all ${
|
||||||
|
theme === t
|
||||||
|
? 'bg-indigo-500/20 border-indigo-500/50'
|
||||||
|
: 'bg-white/5 border-white/10 hover:border-white/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-full h-16 rounded-lg mb-2 ${
|
||||||
|
t === 'dark' ? 'bg-[#1a1a1a]' :
|
||||||
|
t === 'light' ? 'bg-gray-200' :
|
||||||
|
'bg-black'
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm text-gray-300 capitalize">{t}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Accent Color */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Accent Color</h4>
|
||||||
|
<div className="flex gap-3 flex-wrap">
|
||||||
|
{accentColors.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
onClick={() => setAccentColor(color.value)}
|
||||||
|
className={`w-10 h-10 rounded-full transition-all ${
|
||||||
|
accentColor === color.value ? 'ring-2 ring-white ring-offset-2 ring-offset-[#0a0a0c]' : ''
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Display */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Message Display</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Compact Mode</p>
|
||||||
|
<p className="text-gray-500 text-sm">Reduce spacing between messages</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Show Timestamps</p>
|
||||||
|
<p className="text-gray-500 text-sm">Display time next to messages</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" defaultChecked className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font Size */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Chat Font Size</h4>
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="12"
|
||||||
|
max="20"
|
||||||
|
defaultValue="14"
|
||||||
|
className="w-full accent-indigo-500"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-gray-500 mt-2">
|
||||||
|
<span>12px</span>
|
||||||
|
<span>14px</span>
|
||||||
|
<span>16px</span>
|
||||||
|
<span>18px</span>
|
||||||
|
<span>20px</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'voice':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Input Device */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Input Device</h4>
|
||||||
|
<select className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500/50">
|
||||||
|
<option>Default - Microphone Array</option>
|
||||||
|
<option>USB Audio Device</option>
|
||||||
|
<option>Headset Microphone</option>
|
||||||
|
</select>
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-400">Input Volume</span>
|
||||||
|
<span className="text-sm text-gray-400">75%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" defaultValue="75" className="w-full accent-indigo-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Device */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Output Device</h4>
|
||||||
|
<select className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white focus:outline-none focus:border-indigo-500/50">
|
||||||
|
<option>Default - Speakers</option>
|
||||||
|
<option>USB Audio Device</option>
|
||||||
|
<option>Headphones</option>
|
||||||
|
</select>
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-400">Output Volume</span>
|
||||||
|
<span className="text-sm text-gray-400">100%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" defaultValue="100" className="w-full accent-indigo-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Voice Settings */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Voice Settings</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Noise Suppression</p>
|
||||||
|
<p className="text-gray-500 text-sm">Powered by Krisp AI</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" defaultChecked className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Echo Cancellation</p>
|
||||||
|
<p className="text-gray-500 text-sm">Reduce echo during calls</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" defaultChecked className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Push to Talk</p>
|
||||||
|
<p className="text-gray-500 text-sm">Hold a key to transmit voice</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test Audio */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-600/20 to-purple-600/20 rounded-xl p-4 border border-indigo-500/20">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button className="w-12 h-12 bg-indigo-600 hover:bg-indigo-500 rounded-full flex items-center justify-center transition-colors">
|
||||||
|
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">Mic Test</p>
|
||||||
|
<p className="text-gray-400 text-sm">Click to test your microphone</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'notifications':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Desktop Notifications</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Object.entries(notifications).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white capitalize">{key.replace(/([A-Z])/g, ' $1')}</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={value}
|
||||||
|
onChange={() => setNotifications({ ...notifications, [key]: !value })}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Do Not Disturb */}
|
||||||
|
<div className="bg-orange-500/10 rounded-xl p-4 border border-orange-500/20">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-10 h-10 bg-orange-500/20 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="w-5 h-5 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-white font-medium">Do Not Disturb</p>
|
||||||
|
<p className="text-gray-400 text-sm">Mute all notifications</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-orange-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-orange-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'keybinds':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Keybinds</h4>
|
||||||
|
{[
|
||||||
|
{ action: 'Toggle Mute', keys: ['Ctrl', 'Shift', 'M'] },
|
||||||
|
{ action: 'Toggle Deafen', keys: ['Ctrl', 'Shift', 'D'] },
|
||||||
|
{ action: 'Push to Talk', keys: ['`'] },
|
||||||
|
{ action: 'Quick Switcher', keys: ['Ctrl', 'K'] },
|
||||||
|
{ action: 'Search', keys: ['Ctrl', 'F'] },
|
||||||
|
{ action: 'Mark as Read', keys: ['Esc'] },
|
||||||
|
].map((bind, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<span className="text-white">{bind.action}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{bind.keys.map((key, j) => (
|
||||||
|
<React.Fragment key={j}>
|
||||||
|
{j > 0 && <span className="text-gray-600">+</span>}
|
||||||
|
<kbd className="px-2 py-1 bg-white/10 rounded text-sm text-gray-300 border border-white/10">{key}</kbd>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<button className="ml-3 text-indigo-400 hover:text-indigo-300 text-sm">Edit</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'advanced':
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Developer Mode</h4>
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Developer Mode</p>
|
||||||
|
<p className="text-gray-500 text-sm">Show IDs and developer options</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Experimental</h4>
|
||||||
|
<div className="flex items-center justify-between bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div>
|
||||||
|
<p className="text-white">Hardware Acceleration</p>
|
||||||
|
<p className="text-gray-500 text-sm">Use GPU for rendering (requires restart)</p>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input type="checkbox" defaultChecked className="sr-only peer" />
|
||||||
|
<div className="w-11 h-6 bg-white/10 peer-focus:ring-2 peer-focus:ring-indigo-500/50 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:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="text-sm font-medium text-gray-400 uppercase tracking-wider">Data</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button className="p-4 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 text-left transition-colors">
|
||||||
|
<p className="text-white font-medium">Export Data</p>
|
||||||
|
<p className="text-gray-500 text-sm">Download your data</p>
|
||||||
|
</button>
|
||||||
|
<button className="p-4 bg-white/5 hover:bg-white/10 rounded-xl border border-white/10 text-left transition-colors">
|
||||||
|
<p className="text-white font-medium">Clear Cache</p>
|
||||||
|
<p className="text-gray-500 text-sm">Free up space</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Version Info */}
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">AeThex Connect</p>
|
||||||
|
<p className="text-gray-500 text-sm">Version 1.0.0-beta</p>
|
||||||
|
</div>
|
||||||
|
<button className="text-indigo-400 hover:text-indigo-300 text-sm">Check for Updates</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-5xl h-[85vh] bg-[#0a0a0c] rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex animate-fadeIn">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="w-56 bg-white/[0.02] border-r border-white/10 p-4 flex flex-col">
|
||||||
|
<h2 className="text-lg font-bold text-white px-3 mb-4">Settings</h2>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-white/10 text-white'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<div className="pt-4 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition-all"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
Log Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-6 border-b border-white/10 flex items-center justify-between">
|
||||||
|
<h3 className="text-xl font-bold text-white">
|
||||||
|
{tabs.find((t) => t.id === activeTab)?.label}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-8 h-8 bg-white/5 hover:bg-white/10 rounded-lg flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPanel;
|
||||||
219
packages/desktop/src/renderer/components/Skeletons.tsx
Normal file
219
packages/desktop/src/renderer/components/Skeletons.tsx
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// Base Skeleton component
|
||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Skeleton: React.FC<SkeletonProps> = ({ className = '', animate = true, style }) => (
|
||||||
|
<div
|
||||||
|
className={`bg-white/5 rounded ${animate ? 'animate-pulse' : ''} ${className}`}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Message Skeleton
|
||||||
|
export const MessageSkeleton: React.FC<{ compact?: boolean }> = ({ compact = false }) => (
|
||||||
|
<div className={`flex gap-4 px-4 ${compact ? 'py-1' : 'py-3'}`}>
|
||||||
|
{!compact && <Skeleton className="w-10 h-10 rounded-full flex-shrink-0" />}
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{!compact && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Channel Skeleton
|
||||||
|
export const ChannelSkeleton: React.FC = () => (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||||
|
<Skeleton className="w-5 h-5 rounded" />
|
||||||
|
<Skeleton className="h-4 flex-1" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Member Skeleton
|
||||||
|
export const MemberSkeleton: React.FC = () => (
|
||||||
|
<div className="flex items-center gap-3 px-2 py-1.5">
|
||||||
|
<Skeleton className="w-8 h-8 rounded-full flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
<Skeleton className="h-3.5 w-20" />
|
||||||
|
<Skeleton className="h-2.5 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server Skeleton
|
||||||
|
export const ServerSkeleton: React.FC = () => (
|
||||||
|
<Skeleton className="w-12 h-12 rounded-2xl flex-shrink-0" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Chat Area Loading
|
||||||
|
export const ChatAreaSkeleton: React.FC = () => (
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-12 border-b border-white/10 flex items-center px-4 gap-2">
|
||||||
|
<Skeleton className="w-5 h-5 rounded" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 p-4 space-y-4">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<MessageSkeleton key={i} compact={i % 3 === 2} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="p-4">
|
||||||
|
<Skeleton className="h-11 w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full Page Loading
|
||||||
|
export const PageLoadingSkeleton: React.FC = () => (
|
||||||
|
<div className="h-screen w-screen bg-[#050507] flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-16 h-16 bg-gradient-to-tr from-indigo-600 via-purple-600 to-pink-600 rounded-2xl animate-pulse" />
|
||||||
|
<div className="absolute inset-0 w-16 h-16 bg-gradient-to-tr from-indigo-600 via-purple-600 to-pink-600 rounded-2xl animate-ping opacity-20" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-2 h-2 bg-indigo-500 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||||
|
<div className="w-2 h-2 bg-purple-500 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||||
|
<div className="w-2 h-2 bg-pink-500 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-500 text-sm">Loading AeThex Connect...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sidebar Loading
|
||||||
|
export const SidebarSkeleton: React.FC = () => (
|
||||||
|
<div className="w-60 bg-white/[0.02] border-r border-white/10 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="h-12 border-b border-white/10 px-4 flex items-center">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channels */}
|
||||||
|
<div className="flex-1 p-2 space-y-4">
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 px-1 py-1">
|
||||||
|
<Skeleton className="w-3 h-3" />
|
||||||
|
<Skeleton className="h-3 w-20" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<ChannelSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 px-1 py-1">
|
||||||
|
<Skeleton className="w-3 h-3" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 mt-1">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<ChannelSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Panel */}
|
||||||
|
<div className="h-14 border-t border-white/10 px-3 flex items-center gap-2">
|
||||||
|
<Skeleton className="w-8 h-8 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<Skeleton className="h-3.5 w-20" />
|
||||||
|
<Skeleton className="h-2.5 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Members Sidebar Loading
|
||||||
|
export const MembersSidebarSkeleton: React.FC = () => (
|
||||||
|
<div className="w-60 bg-white/[0.02] border-l border-white/10 p-2">
|
||||||
|
{/* Role Group */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-3 w-20 mx-2" />
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<MemberSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Group */}
|
||||||
|
<div className="space-y-2 mt-4">
|
||||||
|
<Skeleton className="h-3 w-24 mx-2" />
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<MemberSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Server List Loading
|
||||||
|
export const ServerListSkeleton: React.FC = () => (
|
||||||
|
<div className="w-[72px] bg-white/[0.02] py-3 flex flex-col items-center gap-2">
|
||||||
|
<ServerSkeleton />
|
||||||
|
<div className="w-8 h-0.5 bg-white/10 rounded my-1" />
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<ServerSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Full App Loading
|
||||||
|
export const AppSkeleton: React.FC = () => (
|
||||||
|
<div className="h-screen w-screen bg-[#050507] flex">
|
||||||
|
<ServerListSkeleton />
|
||||||
|
<SidebarSkeleton />
|
||||||
|
<ChatAreaSkeleton />
|
||||||
|
<MembersSidebarSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Card Skeleton
|
||||||
|
export const CardSkeleton: React.FC<{ lines?: number }> = ({ lines = 3 }) => (
|
||||||
|
<div className="bg-white/5 rounded-xl p-4 border border-white/10 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="w-10 h-10 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-3 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-3 w-full" style={{ width: `${100 - i * 15}%` }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Image Skeleton
|
||||||
|
export const ImageSkeleton: React.FC<{ aspectRatio?: string }> = ({ aspectRatio = '16/9' }) => (
|
||||||
|
<div
|
||||||
|
className="bg-white/5 rounded-lg overflow-hidden animate-pulse"
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
>
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<svg className="w-10 h-10 text-white/10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Skeleton;
|
||||||
215
packages/desktop/src/renderer/components/ToastProvider.tsx
Normal file
215
packages/desktop/src/renderer/components/ToastProvider.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: string;
|
||||||
|
type: ToastType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastContextType {
|
||||||
|
toasts: Toast[];
|
||||||
|
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||||
|
removeToast: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const context = useContext(ToastContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useToast must be used within a ToastProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
|
|
||||||
|
const addToast = useCallback((toast: Omit<Toast, 'id'>) => {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
|
const newToast = { ...toast, id };
|
||||||
|
|
||||||
|
setToasts((prev) => [...prev, newToast]);
|
||||||
|
|
||||||
|
// Auto remove after duration
|
||||||
|
const duration = toast.duration || 5000;
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, duration);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: string) => {
|
||||||
|
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
|
||||||
|
{children}
|
||||||
|
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToastContainerProps {
|
||||||
|
toasts: Toast[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onRemove }) => {
|
||||||
|
if (toasts.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-6 right-6 z-[100] flex flex-col gap-3 max-w-sm">
|
||||||
|
{toasts.map((toast, index) => (
|
||||||
|
<ToastItem
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
onRemove={onRemove}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ToastItemProps {
|
||||||
|
toast: Toast;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastItem: React.FC<ToastItemProps> = ({ toast, onRemove, index }) => {
|
||||||
|
const iconMap = {
|
||||||
|
success: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorMap = {
|
||||||
|
success: {
|
||||||
|
bg: 'from-green-500/20 to-emerald-500/20',
|
||||||
|
border: 'border-green-500/30',
|
||||||
|
icon: 'bg-green-500/20 text-green-400',
|
||||||
|
progress: 'bg-green-500',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
bg: 'from-red-500/20 to-rose-500/20',
|
||||||
|
border: 'border-red-500/30',
|
||||||
|
icon: 'bg-red-500/20 text-red-400',
|
||||||
|
progress: 'bg-red-500',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
bg: 'from-orange-500/20 to-amber-500/20',
|
||||||
|
border: 'border-orange-500/30',
|
||||||
|
icon: 'bg-orange-500/20 text-orange-400',
|
||||||
|
progress: 'bg-orange-500',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
bg: 'from-indigo-500/20 to-blue-500/20',
|
||||||
|
border: 'border-indigo-500/30',
|
||||||
|
icon: 'bg-indigo-500/20 text-indigo-400',
|
||||||
|
progress: 'bg-indigo-500',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = colorMap[toast.type];
|
||||||
|
const duration = toast.duration || 5000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
relative overflow-hidden rounded-xl border backdrop-blur-xl
|
||||||
|
bg-gradient-to-r ${colors.bg} ${colors.border}
|
||||||
|
shadow-2xl animate-slideIn
|
||||||
|
`}
|
||||||
|
style={{ animationDelay: `${index * 50}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3 p-4">
|
||||||
|
{/* Icon */}
|
||||||
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${colors.icon}`}>
|
||||||
|
{iconMap[toast.type]}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-white text-sm">{toast.title}</p>
|
||||||
|
{toast.message && (
|
||||||
|
<p className="text-gray-400 text-xs mt-0.5 line-clamp-2">{toast.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onRemove(toast.id)}
|
||||||
|
className="w-6 h-6 rounded flex items-center justify-center text-gray-500 hover:text-white hover:bg-white/10 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="h-0.5 bg-white/5">
|
||||||
|
<div
|
||||||
|
className={`h-full ${colors.progress} animate-shrink`}
|
||||||
|
style={{
|
||||||
|
animationDuration: `${duration}ms`,
|
||||||
|
animationTimingFunction: 'linear',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add keyframes for animations */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes shrink {
|
||||||
|
from {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animate-slideIn {
|
||||||
|
animation: slideIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
.animate-shrink {
|
||||||
|
animation: shrink linear forwards;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToastProvider;
|
||||||
228
packages/desktop/src/renderer/components/UserProfileCard.tsx
Normal file
228
packages/desktop/src/renderer/components/UserProfileCard.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface UserProfileCardProps {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
status?: string;
|
||||||
|
bio?: string;
|
||||||
|
banner_color?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
position?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserProfileCard: React.FC<UserProfileCardProps> = ({ user, isOpen, onClose, position }) => {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const badges = [
|
||||||
|
{ name: 'Early Supporter', icon: '⭐', color: 'from-yellow-500 to-orange-500' },
|
||||||
|
{ name: 'Active Chatter', icon: '💬', color: 'from-indigo-500 to-purple-500' },
|
||||||
|
{ name: 'Bug Hunter', icon: '🐛', color: 'from-green-500 to-emerald-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activities = [
|
||||||
|
{ type: 'playing', name: 'Valorant', detail: 'Competitive Match', icon: '🎮' },
|
||||||
|
{ type: 'listening', name: 'Spotify', detail: 'Blinding Lights - The Weeknd', icon: '🎵' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mutualServers = [
|
||||||
|
{ name: 'Gaming Hub', icon: '🎮' },
|
||||||
|
{ name: 'Dev Community', icon: '💻' },
|
||||||
|
{ name: 'Music Lovers', icon: '🎵' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mutualFriends = [
|
||||||
|
{ name: 'Alex', avatar: 'A' },
|
||||||
|
{ name: 'Jordan', avatar: 'J' },
|
||||||
|
{ name: 'Sam', avatar: 'S' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className="relative w-[340px] bg-[#0a0a0c] rounded-2xl border border-white/10 shadow-2xl overflow-hidden animate-fadeIn"
|
||||||
|
style={position ? { position: 'absolute', left: position.x, top: position.y } : {}}
|
||||||
|
>
|
||||||
|
{/* Banner */}
|
||||||
|
<div
|
||||||
|
className="h-28 relative"
|
||||||
|
style={{
|
||||||
|
background: user.banner_color || 'linear-gradient(135deg, #6366f1, #a855f7, #ec4899)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Banner Pattern */}
|
||||||
|
<div className="absolute inset-0 opacity-20" style={{
|
||||||
|
backgroundImage: 'radial-gradient(circle at 20% 80%, rgba(255,255,255,0.3) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255,255,255,0.3) 0%, transparent 50%)'
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="relative px-4">
|
||||||
|
<div className="absolute -top-14 left-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-28 h-28 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-4xl font-bold border-[6px] border-[#0a0a0c]">
|
||||||
|
{user.avatar_url ? (
|
||||||
|
<img src={user.avatar_url} alt="" className="w-full h-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
user.display_name?.[0]?.toUpperCase() || user.username?.[0]?.toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Status Indicator */}
|
||||||
|
<div className={`absolute bottom-1 right-1 w-6 h-6 rounded-full border-4 border-[#0a0a0c] ${
|
||||||
|
user.status === 'online' ? 'bg-green-500' :
|
||||||
|
user.status === 'idle' ? 'bg-yellow-500' :
|
||||||
|
user.status === 'dnd' ? 'bg-red-500' :
|
||||||
|
'bg-gray-500'
|
||||||
|
}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end pt-3 gap-2">
|
||||||
|
<button className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors" title="Send Message">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button className="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors" title="Voice Call">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<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="p-2 bg-white/5 hover:bg-white/10 rounded-lg transition-colors" title="More">
|
||||||
|
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Info */}
|
||||||
|
<div className="px-4 pt-10 pb-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="mb-3">
|
||||||
|
<h3 className="text-xl font-bold text-white">{user.display_name || user.username}</h3>
|
||||||
|
<p className="text-gray-400 text-sm">@{user.username}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4">
|
||||||
|
{badges.map((badge, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`px-2 py-1 rounded-full bg-gradient-to-r ${badge.color} text-xs font-medium text-white flex items-center gap-1`}
|
||||||
|
title={badge.name}
|
||||||
|
>
|
||||||
|
<span>{badge.icon}</span>
|
||||||
|
<span>{badge.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio */}
|
||||||
|
{user.bio && (
|
||||||
|
<div className="bg-white/5 rounded-xl p-3 mb-4 border border-white/5">
|
||||||
|
<p className="text-sm text-gray-300">{user.bio}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-white/10 my-4" />
|
||||||
|
|
||||||
|
{/* Activity */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">Current Activity</h4>
|
||||||
|
{activities.slice(0, 1).map((activity, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 bg-white/5 rounded-xl p-3 border border-white/5">
|
||||||
|
<div className="w-10 h-10 bg-white/10 rounded-lg flex items-center justify-center text-xl">
|
||||||
|
{activity.icon}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-white font-medium capitalize">{activity.type} {activity.name}</p>
|
||||||
|
<p className="text-xs text-gray-400 truncate">{activity.detail}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mutual Servers */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">
|
||||||
|
{mutualServers.length} Mutual Servers
|
||||||
|
</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{mutualServers.map((server, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-lg hover:bg-white/20 cursor-pointer transition-colors"
|
||||||
|
title={server.name}
|
||||||
|
>
|
||||||
|
{server.icon}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mutualServers.length > 3 && (
|
||||||
|
<div className="w-10 h-10 bg-white/10 rounded-xl flex items-center justify-center text-xs text-gray-400">
|
||||||
|
+{mutualServers.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mutual Friends */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">
|
||||||
|
{mutualFriends.length} Mutual Friends
|
||||||
|
</h4>
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
{mutualFriends.slice(0, 5).map((friend, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="w-8 h-8 bg-gradient-to-br from-indigo-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold border-2 border-[#0a0a0c] hover:translate-y-[-2px] cursor-pointer transition-transform"
|
||||||
|
title={friend.name}
|
||||||
|
>
|
||||||
|
{friend.avatar}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{mutualFriends.length > 5 && (
|
||||||
|
<div className="w-8 h-8 bg-white/10 rounded-full flex items-center justify-center text-xs text-gray-400 border-2 border-[#0a0a0c]">
|
||||||
|
+{mutualFriends.length - 5}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Note Section */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-white/10">
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase mb-2">Note</h4>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-lg p-2 text-sm text-gray-300 placeholder-gray-500 resize-none focus:outline-none focus:border-indigo-500/50"
|
||||||
|
placeholder="Click to add a note"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<p className="text-xs text-gray-600 text-center">
|
||||||
|
Member since {user.created_at ? new Date(user.created_at).toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) : 'Unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfileCard;
|
||||||
199
packages/desktop/src/renderer/context/AppContext.tsx
Normal file
199
packages/desktop/src/renderer/context/AppContext.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
interface Server {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
icon_url?: string;
|
||||||
|
owner_id: string;
|
||||||
|
invite_code: string;
|
||||||
|
member_count: number;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Channel {
|
||||||
|
id: string;
|
||||||
|
server_id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
channel_type: 'text' | 'voice' | 'announcement' | 'stage';
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
status: string;
|
||||||
|
custom_status?: string;
|
||||||
|
role: string;
|
||||||
|
nickname?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
id: string;
|
||||||
|
channel_id: string;
|
||||||
|
user_id: string;
|
||||||
|
content: string;
|
||||||
|
username: string;
|
||||||
|
display_name?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppContextType {
|
||||||
|
servers: Server[];
|
||||||
|
currentServer: Server | null;
|
||||||
|
channels: Channel[];
|
||||||
|
currentChannel: Channel | null;
|
||||||
|
members: Member[];
|
||||||
|
messages: Message[];
|
||||||
|
loadingServers: boolean;
|
||||||
|
loadingChannels: boolean;
|
||||||
|
loadingMessages: boolean;
|
||||||
|
selectServer: (server: Server | null) => void;
|
||||||
|
selectChannel: (channel: Channel | null) => void;
|
||||||
|
createServer: (name: string, description?: string) => Promise<Server>;
|
||||||
|
joinServer: (inviteCode: string) => Promise<Server>;
|
||||||
|
sendMessage: (content: string) => Promise<void>;
|
||||||
|
refreshServers: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AppContext = createContext<AppContextType | null>(null);
|
||||||
|
|
||||||
|
export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
|
const [currentServer, setCurrentServer] = useState<Server | null>(null);
|
||||||
|
const [channels, setChannels] = useState<Channel[]>([]);
|
||||||
|
const [currentChannel, setCurrentChannel] = useState<Channel | null>(null);
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [loadingServers, setLoadingServers] = useState(false);
|
||||||
|
const [loadingChannels, setLoadingChannels] = useState(false);
|
||||||
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
|
|
||||||
|
const refreshServers = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
setLoadingServers(true);
|
||||||
|
try {
|
||||||
|
const data = await api.getServers();
|
||||||
|
setServers(data.servers || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load servers:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingServers(false);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// Load servers when user logs in
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
refreshServers();
|
||||||
|
} else {
|
||||||
|
setServers([]);
|
||||||
|
setCurrentServer(null);
|
||||||
|
setChannels([]);
|
||||||
|
setCurrentChannel(null);
|
||||||
|
setMembers([]);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
}, [user, refreshServers]);
|
||||||
|
|
||||||
|
// Load channels when server changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentServer) {
|
||||||
|
setChannels([]);
|
||||||
|
setCurrentChannel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingChannels(true);
|
||||||
|
Promise.all([
|
||||||
|
api.getChannels(currentServer.id),
|
||||||
|
api.getMembers(currentServer.id),
|
||||||
|
])
|
||||||
|
.then(([channelData, memberData]) => {
|
||||||
|
setChannels(channelData.channels || []);
|
||||||
|
setMembers(memberData.members || []);
|
||||||
|
// Auto-select first text channel
|
||||||
|
const firstText = (channelData.channels || []).find((c: Channel) => c.channel_type === 'text');
|
||||||
|
if (firstText) setCurrentChannel(firstText);
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoadingChannels(false));
|
||||||
|
}, [currentServer]);
|
||||||
|
|
||||||
|
// Load messages when channel changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentChannel) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingMessages(true);
|
||||||
|
api.getMessages(currentChannel.id)
|
||||||
|
.then(data => setMessages(data.messages || []))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoadingMessages(false));
|
||||||
|
}, [currentChannel]);
|
||||||
|
|
||||||
|
const selectServer = (server: Server | null) => {
|
||||||
|
setCurrentServer(server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectChannel = (channel: Channel | null) => {
|
||||||
|
setCurrentChannel(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createServer = async (name: string, description?: string) => {
|
||||||
|
const data = await api.createServer(name, description);
|
||||||
|
await refreshServers();
|
||||||
|
return data.server;
|
||||||
|
};
|
||||||
|
|
||||||
|
const joinServer = async (inviteCode: string) => {
|
||||||
|
const data = await api.joinServer(inviteCode);
|
||||||
|
await refreshServers();
|
||||||
|
return data.server;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async (content: string) => {
|
||||||
|
if (!currentChannel) return;
|
||||||
|
const data = await api.sendMessage(currentChannel.id, content);
|
||||||
|
setMessages(prev => [...prev, data.message]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppContext.Provider value={{
|
||||||
|
servers,
|
||||||
|
currentServer,
|
||||||
|
channels,
|
||||||
|
currentChannel,
|
||||||
|
members,
|
||||||
|
messages,
|
||||||
|
loadingServers,
|
||||||
|
loadingChannels,
|
||||||
|
loadingMessages,
|
||||||
|
selectServer,
|
||||||
|
selectChannel,
|
||||||
|
createServer,
|
||||||
|
joinServer,
|
||||||
|
sendMessage,
|
||||||
|
refreshServers,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApp() {
|
||||||
|
const context = useContext(AppContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useApp must be used within AppProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
77
packages/desktop/src/renderer/context/AuthContext.tsx
Normal file
77
packages/desktop/src/renderer/context/AuthContext.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
displayName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
customStatus?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
login: (username: string, password: string) => Promise<void>;
|
||||||
|
register: (username: string, password: string, displayName?: string) => Promise<void>;
|
||||||
|
demoLogin: () => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check for existing token
|
||||||
|
const token = api.getToken();
|
||||||
|
if (token) {
|
||||||
|
api.getMe()
|
||||||
|
.then(data => setUser(data.user || data))
|
||||||
|
.catch(() => {
|
||||||
|
api.logout();
|
||||||
|
setUser(null);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
const data = await api.login(username, password);
|
||||||
|
setUser(data.user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const register = async (username: string, password: string, displayName?: string) => {
|
||||||
|
const data = await api.register(username, password, displayName);
|
||||||
|
setUser(data.user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const demoLogin = async () => {
|
||||||
|
const data = await api.demoLogin();
|
||||||
|
setUser(data.user);
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
api.logout();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, register, demoLogin, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
@ -1,61 +1,139 @@
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&display=swap');
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap');
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: 'Roboto Mono', monospace;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: #0a0a0a;
|
background: #050507;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
/* Remove the scanline effect for cleaner look */
|
||||||
content: '';
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
rgba(0, 0, 0, 0.15),
|
|
||||||
rgba(0, 0, 0, 0.15) 1px,
|
|
||||||
transparent 1px,
|
|
||||||
transparent 2px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connect-container {
|
.connect-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
background: linear-gradient(135deg, #050507 0%, #0a0a0c 50%, #050507 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Glassmorphism and accent classes for key elements */
|
/* Custom Scrollbar */
|
||||||
.server-icon, .user-avatar, .member-avatar-small {
|
|
||||||
background: rgba(26,26,26,0.85);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-item.active, .channel-item:hover, .member-item:hover {
|
|
||||||
background: rgba(26,26,26,0.85);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-input, .message-input-container {
|
|
||||||
background: rgba(15,15,15,0.95);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide scrollbars for a cleaner look */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 6px;
|
||||||
background: #111;
|
height: 6px;
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #222;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utility classes for gradients, badges, etc. can be extended as needed */
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar when not hovering */
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
::selection {
|
||||||
|
background: rgba(99, 102, 241, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus outline */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid rgba(99, 102, 241, 0.5);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth scrolling */
|
||||||
|
* {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation for typing indicator */
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 80%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse animation for live indicators */
|
||||||
|
@keyframes pulse-soft {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text utility */
|
||||||
|
.text-gradient {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #a855f7, #ec4899);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glass effect utilities */
|
||||||
|
.glass {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-dark {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover lift effect */
|
||||||
|
.hover-lift {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hover-lift:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button press effect */
|
||||||
|
.press-effect:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fadeIn {
|
||||||
|
animation: fadeIn 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
|
||||||
148
packages/desktop/src/renderer/services/api.ts
Normal file
148
packages/desktop/src/renderer/services/api.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
const API_BASE = 'http://localhost:3000/api';
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiService {
|
||||||
|
private token: string | null = null;
|
||||||
|
|
||||||
|
setToken(token: string | null) {
|
||||||
|
this.token = token;
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getToken(): string | null {
|
||||||
|
if (!this.token) {
|
||||||
|
this.token = localStorage.getItem('auth_token');
|
||||||
|
}
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const token = this.getToken();
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.headers || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || json.success === false) {
|
||||||
|
throw new Error(json.error || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle wrapped response { success, data } format
|
||||||
|
if (json.data !== undefined) {
|
||||||
|
return json.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async register(username: string, password: string, displayName?: string) {
|
||||||
|
const data = await this.request<{ token: string; user: any }>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password, displayName }),
|
||||||
|
});
|
||||||
|
this.setToken(data.token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(username: string, password: string) {
|
||||||
|
const data = await this.request<{ token: string; user: any }>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
this.setToken(data.token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async demoLogin() {
|
||||||
|
const data = await this.request<{ token: string; user: any }>('/auth/demo', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
this.setToken(data.token);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe() {
|
||||||
|
return this.request<{ user: any }>('/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.setToken(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Servers
|
||||||
|
async getServers() {
|
||||||
|
return this.request<{ servers: any[] }>('/servers');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createServer(name: string, description?: string) {
|
||||||
|
return this.request<{ server: any }>('/servers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, description }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinServer(inviteCode: string) {
|
||||||
|
return this.request<{ server: any }>(`/servers/join/${inviteCode}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServer(serverId: string) {
|
||||||
|
return this.request<{ server: any }>(`/servers/${serverId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
async getChannels(serverId: string) {
|
||||||
|
return this.request<{ channels: any[] }>(`/servers/${serverId}/channels`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createChannel(serverId: string, name: string, type: 'text' | 'voice' = 'text') {
|
||||||
|
return this.request<{ channel: any }>(`/servers/${serverId}/channels`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, channel_type: type }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Members
|
||||||
|
async getMembers(serverId: string) {
|
||||||
|
return this.request<{ members: any[] }>(`/servers/${serverId}/members`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
async getMessages(channelId: string, limit = 50, before?: string) {
|
||||||
|
let url = `/servers/channels/${channelId}/messages?limit=${limit}`;
|
||||||
|
if (before) url += `&before=${before}`;
|
||||||
|
return this.request<{ messages: any[] }>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(channelId: string, content: string) {
|
||||||
|
return this.request<{ message: any }>(`/servers/channels/${channelId}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiService();
|
||||||
|
export default api;
|
||||||
10
packages/desktop/tailwind.config.js
Normal file
10
packages/desktop/tailwind.config.js
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./src/renderer/**/*.{js,ts,jsx,tsx,html}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
18
packages/desktop/tsconfig.main.json
Normal file
18
packages/desktop/tsconfig.main.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist/main",
|
||||||
|
"rootDir": "./src/main",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
21
packages/desktop/tsconfig.renderer.json
Normal file
21
packages/desktop/tsconfig.renderer.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/renderer/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
31
packages/desktop/vite.config.mjs
Normal file
31
packages/desktop/vite.config.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import tailwindcss from '@tailwindcss/postcss';
|
||||||
|
import autoprefixer from 'autoprefixer';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: './src/renderer',
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist/renderer',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
css: {
|
||||||
|
postcss: {
|
||||||
|
plugins: [tailwindcss(), autoprefixer()],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
hmr: {
|
||||||
|
overlay: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src/renderer'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
19
scripts/check-schema.js
Normal file
19
scripts/check-schema.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL?.replace('?sslmode=require', ''), ssl: true });
|
||||||
|
|
||||||
|
// Check existing columns in users table
|
||||||
|
const cols = await pool.query(`SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'users' ORDER BY ordinal_position`);
|
||||||
|
console.log('Users table columns:');
|
||||||
|
cols.rows.forEach(r => console.log(' -', r.column_name, '(' + r.data_type + ')'));
|
||||||
|
|
||||||
|
// Check existing tables
|
||||||
|
const tables = await pool.query(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`);
|
||||||
|
console.log('\nExisting tables:', tables.rows.map(r => r.table_name).join(', '));
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
check();
|
||||||
89
scripts/create-tables.js
Normal file
89
scripts/create-tables.js
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
|
async function createMissingTables() {
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL?.replace('?sslmode=require', ''), ssl: true });
|
||||||
|
|
||||||
|
console.log('Creating missing tables for AeThex Connect...\n');
|
||||||
|
|
||||||
|
// Create servers table
|
||||||
|
console.log('Creating servers table...');
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS servers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
icon_url TEXT,
|
||||||
|
banner_url TEXT,
|
||||||
|
owner_id UUID NOT NULL,
|
||||||
|
invite_code VARCHAR(20) UNIQUE,
|
||||||
|
is_public BOOLEAN DEFAULT false,
|
||||||
|
member_count INTEGER DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log(' ✓ servers created');
|
||||||
|
|
||||||
|
// Create server_members table
|
||||||
|
console.log('Creating server_members table...');
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS server_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
server_id UUID NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
nickname VARCHAR(100),
|
||||||
|
role VARCHAR(20) CHECK (role IN ('owner', 'admin', 'moderator', 'member')) DEFAULT 'member',
|
||||||
|
joined_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
CONSTRAINT unique_server_member UNIQUE(server_id, user_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log(' ✓ server_members created');
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
console.log('Creating indexes...');
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_servers_owner_id ON servers(owner_id)`);
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_servers_invite_code ON servers(invite_code)`);
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_server_members_server_id ON server_members(server_id)`);
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_server_members_user_id ON server_members(user_id)`);
|
||||||
|
console.log(' ✓ indexes created');
|
||||||
|
|
||||||
|
// Check existing channels table structure
|
||||||
|
console.log('\nChecking channels table structure...');
|
||||||
|
const channelCols = await pool.query(`
|
||||||
|
SELECT column_name FROM information_schema.columns WHERE table_name = 'channels'
|
||||||
|
`);
|
||||||
|
const cols = channelCols.rows.map(r => r.column_name);
|
||||||
|
console.log(' Columns:', cols.join(', '));
|
||||||
|
|
||||||
|
// Add server_id to channels if missing
|
||||||
|
if (!cols.includes('server_id')) {
|
||||||
|
console.log(' Adding server_id column to channels...');
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN server_id UUID REFERENCES servers(id) ON DELETE CASCADE`);
|
||||||
|
console.log(' ✓ server_id added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check messages table structure
|
||||||
|
console.log('\nChecking messages table structure...');
|
||||||
|
const msgCols = await pool.query(`
|
||||||
|
SELECT column_name FROM information_schema.columns WHERE table_name = 'messages'
|
||||||
|
`);
|
||||||
|
const msgColNames = msgCols.rows.map(r => r.column_name);
|
||||||
|
console.log(' Columns:', msgColNames.join(', '));
|
||||||
|
|
||||||
|
// Add channel_id to messages if missing
|
||||||
|
if (!msgColNames.includes('channel_id')) {
|
||||||
|
console.log(' Adding channel_id column to messages...');
|
||||||
|
await pool.query(`ALTER TABLE messages ADD COLUMN channel_id UUID REFERENCES channels(id) ON DELETE CASCADE`);
|
||||||
|
console.log(' ✓ channel_id added');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ All tables ready!');
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
createMissingTables().catch(err => {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
38
scripts/fix-channels.js
Normal file
38
scripts/fix-channels.js
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
require('dotenv').config();
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
|
async function fixChannels() {
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL?.replace('?sslmode=require', ''), ssl: true });
|
||||||
|
|
||||||
|
console.log('Adding missing columns to channels table...');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS channel_type VARCHAR(20) DEFAULT 'text'`);
|
||||||
|
console.log(' ✓ channel_type');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS position INTEGER DEFAULT 0`);
|
||||||
|
console.log(' ✓ position');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS description TEXT`);
|
||||||
|
console.log(' ✓ description');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false`);
|
||||||
|
console.log(' ✓ is_private');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS slowmode_seconds INTEGER DEFAULT 0`);
|
||||||
|
console.log(' ✓ slowmode_seconds');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS created_at TIMESTAMP DEFAULT NOW()`);
|
||||||
|
console.log(' ✓ created_at');
|
||||||
|
|
||||||
|
await pool.query(`ALTER TABLE channels ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW()`);
|
||||||
|
console.log(' ✓ updated_at');
|
||||||
|
|
||||||
|
console.log('\n✅ Channels table updated!');
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
fixChannels().catch(err => {
|
||||||
|
console.error('Error:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
61
scripts/run-migration.js
Normal file
61
scripts/run-migration.js
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// Disable SSL verification for Supabase pooler
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
|
async function runMigration() {
|
||||||
|
const connectionString = process.env.DATABASE_URL?.replace('?sslmode=require', '');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString,
|
||||||
|
ssl: true
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Connecting to database...');
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
console.log('Connected!\n');
|
||||||
|
|
||||||
|
// Read the migration file
|
||||||
|
const migrationPath = path.join(__dirname, '../supabase/migrations/20260206000000_users_and_servers.sql');
|
||||||
|
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||||
|
|
||||||
|
console.log('Running users_and_servers migration...\n');
|
||||||
|
|
||||||
|
// Execute the entire SQL file as a single transaction
|
||||||
|
await pool.query(sql);
|
||||||
|
|
||||||
|
console.log('✅ Migration complete!');
|
||||||
|
|
||||||
|
// Verify tables exist
|
||||||
|
const tables = await pool.query(`
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name IN ('users', 'servers', 'channels', 'messages', 'server_members')
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\nVerified tables created:');
|
||||||
|
tables.rows.forEach(row => console.log(' ✓', row.table_name));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration error:', error.message);
|
||||||
|
|
||||||
|
// If tables already exist error, try checking what exists
|
||||||
|
if (error.message.includes('already exists')) {
|
||||||
|
console.log('\nSome tables already exist. Checking current state...');
|
||||||
|
const tables = await pool.query(`
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
console.log('Existing tables:', tables.rows.map(r => r.table_name).join(', '));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigration();
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
// Disable SSL verification for Supabase pooler (development)
|
||||||
|
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||||
|
|
||||||
// Use in-memory dev DB if DATABASE_URL is not configured
|
// Use in-memory dev DB if DATABASE_URL is not configured
|
||||||
let pool = null;
|
let pool = null;
|
||||||
let useDevDb = false;
|
let useDevDb = false;
|
||||||
|
|
|
||||||
|
|
@ -6,47 +6,8 @@ const jwt = require('jsonwebtoken');
|
||||||
*/
|
*/
|
||||||
function authenticateUser(req, res, next) {
|
function authenticateUser(req, res, next) {
|
||||||
try {
|
try {
|
||||||
// In development mode, allow requests without auth for testing
|
// Production: Strict auth required by default
|
||||||
// SECURITY: This bypass only works if BOTH conditions are met:
|
// In development, still require valid tokens but give better error messages
|
||||||
// 1. NODE_ENV is 'development'
|
|
||||||
// 2. ALLOW_DEV_BYPASS is explicitly set to 'true'
|
|
||||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
||||||
const allowDevBypass = process.env.ALLOW_DEV_BYPASS === 'true';
|
|
||||||
|
|
||||||
if (isDevelopment && allowDevBypass) {
|
|
||||||
// Check for token, but if not present, use demo user
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
// Use demo user for development
|
|
||||||
console.warn('⚠️ Development mode: Using demo user without authentication');
|
|
||||||
req.user = {
|
|
||||||
id: 'demo-user-123',
|
|
||||||
email: 'demo@aethex.dev'
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
|
||||||
|
|
||||||
// Try to verify, but don't fail if invalid
|
|
||||||
try {
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
req.user = {
|
|
||||||
id: decoded.userId,
|
|
||||||
email: decoded.email
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
// Use demo user if token invalid
|
|
||||||
console.warn('⚠️ Development mode: Invalid token, using demo user');
|
|
||||||
req.user = {
|
|
||||||
id: 'demo-user-123',
|
|
||||||
email: 'demo@aethex.dev'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production: Strict auth required
|
// Production: Strict auth required
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,20 @@ const router = express.Router();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/register
|
* POST /api/auth/register
|
||||||
* Register a new user
|
* Register a new user (supports username or email)
|
||||||
*/
|
*/
|
||||||
router.post('/register', async (req, res) => {
|
router.post('/register', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, password, username, displayName } = req.body;
|
const { email, password, username, displayName } = req.body;
|
||||||
|
|
||||||
|
// Support both email and username-only registration
|
||||||
|
const userIdentifier = username || email;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password) {
|
if (!userIdentifier || !password) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Email and password are required'
|
error: 'Username/email and password are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,24 +37,11 @@ router.post('/register', async (req, res) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists
|
// Check if username is taken
|
||||||
const existingUser = await db.query(
|
const finalUsername = username?.toLowerCase() || email?.split('@')[0].toLowerCase();
|
||||||
'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(
|
const existingUsername = await db.query(
|
||||||
'SELECT id FROM users WHERE username = $1',
|
'SELECT id FROM users WHERE username = $1',
|
||||||
[username.toLowerCase()]
|
[finalUsername]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingUsername.rows.length > 0) {
|
if (existingUsername.rows.length > 0) {
|
||||||
|
|
@ -60,25 +50,24 @@ router.post('/register', async (req, res) => {
|
||||||
error: 'This username is already taken'
|
error: 'This username is already taken'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
const salt = await bcrypt.genSalt(12);
|
const salt = await bcrypt.genSalt(12);
|
||||||
const passwordHash = await bcrypt.hash(password, salt);
|
const passwordHash = await bcrypt.hash(password, salt);
|
||||||
|
|
||||||
// Create user
|
// Create user (using 'password' column to match existing schema)
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`INSERT INTO users (email, password_hash, username, display_name, created_at, updated_at)
|
`INSERT INTO users (username, password, display_name, status, created_at)
|
||||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
VALUES ($1, $2, $3, 'online', NOW())
|
||||||
RETURNING id, email, username, display_name, created_at`,
|
RETURNING id, username, display_name, created_at`,
|
||||||
[email.toLowerCase(), passwordHash, username?.toLowerCase() || null, displayName || username || email.split('@')[0]]
|
[finalUsername, passwordHash, displayName || finalUsername]
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user.id, email: user.email },
|
{ userId: user.id, username: user.username },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '7d' }
|
||||||
);
|
);
|
||||||
|
|
@ -88,7 +77,6 @@ router.post('/register', async (req, res) => {
|
||||||
data: {
|
data: {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.display_name
|
displayName: user.display_name
|
||||||
},
|
},
|
||||||
|
|
@ -106,57 +94,60 @@ router.post('/register', async (req, res) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/login
|
* POST /api/auth/login
|
||||||
* Login with email and password
|
* Login with username and password
|
||||||
*/
|
*/
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, password } = req.body;
|
const { email, password, username } = req.body;
|
||||||
|
|
||||||
|
// Support both email and username login
|
||||||
|
const loginId = username || email;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!email || !password) {
|
if (!loginId || !password) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Email and password are required'
|
error: 'Username and password are required'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find user
|
// Find user by username
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`SELECT id, email, password_hash, username, display_name, verified_domain, avatar_url, is_premium
|
`SELECT id, username, password, display_name, verified_domain, avatar_url, premium_tier, status
|
||||||
FROM users WHERE email = $1`,
|
FROM users WHERE username = $1`,
|
||||||
[email.toLowerCase()]
|
[loginId.toLowerCase()]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid email or password'
|
error: 'Invalid username or password'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
|
|
||||||
// Verify password
|
// Verify password (column is 'password' not 'password_hash')
|
||||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
const isValidPassword = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Invalid email or password'
|
error: 'Invalid username or password'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user.id, email: user.email },
|
{ userId: user.id, username: user.username },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '7d' }
|
{ expiresIn: '7d' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update last login
|
// Update status to online
|
||||||
await db.query(
|
await db.query(
|
||||||
'UPDATE users SET last_login = NOW() WHERE id = $1',
|
'UPDATE users SET status = $1 WHERE id = $2',
|
||||||
[user.id]
|
['online', user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|
@ -164,12 +155,12 @@ router.post('/login', async (req, res) => {
|
||||||
data: {
|
data: {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
verifiedDomain: user.verified_domain,
|
verifiedDomain: user.verified_domain,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
isPremium: user.is_premium
|
isPremium: user.premium_tier !== 'free',
|
||||||
|
status: 'online'
|
||||||
},
|
},
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
|
|
@ -189,13 +180,13 @@ router.post('/login', async (req, res) => {
|
||||||
*/
|
*/
|
||||||
router.post('/demo', async (req, res) => {
|
router.post('/demo', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const demoEmail = 'demo@aethex.dev';
|
const demoUsername = 'demo';
|
||||||
|
|
||||||
// Check if demo user exists
|
// Check if demo user exists
|
||||||
let result = await db.query(
|
let result = await db.query(
|
||||||
`SELECT id, email, username, display_name, verified_domain, avatar_url, is_premium
|
`SELECT id, username, display_name, verified_domain, avatar_url, premium_tier, status
|
||||||
FROM users WHERE email = $1`,
|
FROM users WHERE username = $1`,
|
||||||
[demoEmail]
|
[demoUsername]
|
||||||
);
|
);
|
||||||
|
|
||||||
let user;
|
let user;
|
||||||
|
|
@ -206,10 +197,10 @@ router.post('/demo', async (req, res) => {
|
||||||
const passwordHash = await bcrypt.hash('demo123456', salt);
|
const passwordHash = await bcrypt.hash('demo123456', salt);
|
||||||
|
|
||||||
result = await db.query(
|
result = await db.query(
|
||||||
`INSERT INTO users (email, password_hash, username, display_name, verified_domain, created_at, updated_at)
|
`INSERT INTO users (username, password, display_name, verified_domain, status, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
|
VALUES ($1, $2, $3, $4, 'online', NOW())
|
||||||
RETURNING id, email, username, display_name, verified_domain, avatar_url, is_premium`,
|
RETURNING id, username, display_name, verified_domain, avatar_url, premium_tier, status`,
|
||||||
[demoEmail, passwordHash, 'demo', 'Demo User', 'demo.aethex']
|
[demoUsername, passwordHash, 'Demo User', 'demo.aethex']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +208,7 @@ router.post('/demo', async (req, res) => {
|
||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user.id, email: user.email },
|
{ userId: user.id, username: user.username },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '24h' }
|
{ expiresIn: '24h' }
|
||||||
);
|
);
|
||||||
|
|
@ -227,12 +218,12 @@ router.post('/demo', async (req, res) => {
|
||||||
data: {
|
data: {
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
|
||||||
username: user.username,
|
username: user.username,
|
||||||
displayName: user.display_name,
|
displayName: user.display_name,
|
||||||
verifiedDomain: user.verified_domain,
|
verifiedDomain: user.verified_domain,
|
||||||
avatarUrl: user.avatar_url,
|
avatarUrl: user.avatar_url,
|
||||||
isPremium: user.is_premium
|
isPremium: user.premium_tier !== 'free',
|
||||||
|
status: user.status
|
||||||
},
|
},
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
|
|
|
||||||
411
src/backend/routes/serverRoutes.js
Normal file
411
src/backend/routes/serverRoutes.js
Normal file
|
|
@ -0,0 +1,411 @@
|
||||||
|
/**
|
||||||
|
* Server Routes
|
||||||
|
* API endpoints for servers (guilds), channels, and members
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { authenticateUser } = require('../middleware/auth');
|
||||||
|
const db = require('../database/db');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/servers
|
||||||
|
* Get all servers the current user is a member of
|
||||||
|
*/
|
||||||
|
router.get('/', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT s.*, sm.role, sm.nickname, sm.joined_at as member_joined_at
|
||||||
|
FROM servers s
|
||||||
|
INNER JOIN server_members sm ON s.id = sm.server_id
|
||||||
|
WHERE sm.user_id = $1
|
||||||
|
ORDER BY sm.joined_at ASC
|
||||||
|
`, [req.user.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
servers: result.rows
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get servers:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/servers
|
||||||
|
* Create a new server
|
||||||
|
*/
|
||||||
|
router.post('/', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, icon_url, is_public } = req.body;
|
||||||
|
|
||||||
|
if (!name || name.trim().length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Server name is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate invite code
|
||||||
|
const invite_code = uuidv4().substring(0, 8).toUpperCase();
|
||||||
|
|
||||||
|
// Create server
|
||||||
|
const serverResult = await db.query(`
|
||||||
|
INSERT INTO servers (name, description, icon_url, owner_id, invite_code, is_public)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *
|
||||||
|
`, [name.trim(), description || null, icon_url || null, req.user.id, invite_code, is_public || false]);
|
||||||
|
|
||||||
|
const server = serverResult.rows[0];
|
||||||
|
|
||||||
|
// Add owner as member
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO server_members (server_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'owner')
|
||||||
|
`, [server.id, req.user.id]);
|
||||||
|
|
||||||
|
// Create default channels
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO channels (server_id, name, channel_type, position) VALUES
|
||||||
|
($1, 'general', 'text', 0),
|
||||||
|
($1, 'voice', 'voice', 1)
|
||||||
|
`, [server.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
server
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create server:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/servers/:id
|
||||||
|
* Get server details
|
||||||
|
*/
|
||||||
|
router.get('/:id', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if user is a member
|
||||||
|
const memberCheck = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2
|
||||||
|
`, [req.params.id, req.user.id]);
|
||||||
|
|
||||||
|
if (memberCheck.rows.length === 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Not a member of this server'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT * FROM servers WHERE id = $1
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
server: result.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/servers/join/:inviteCode
|
||||||
|
* Join a server by invite code
|
||||||
|
*/
|
||||||
|
router.post('/join/:inviteCode', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const server = await db.query(`
|
||||||
|
SELECT * FROM servers WHERE invite_code = $1
|
||||||
|
`, [req.params.inviteCode.toUpperCase()]);
|
||||||
|
|
||||||
|
if (server.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid invite code'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already a member
|
||||||
|
const existingMember = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2
|
||||||
|
`, [server.rows[0].id, req.user.id]);
|
||||||
|
|
||||||
|
if (existingMember.rows.length > 0) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
server: server.rows[0],
|
||||||
|
message: 'Already a member'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add as member
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO server_members (server_id, user_id, role)
|
||||||
|
VALUES ($1, $2, 'member')
|
||||||
|
`, [server.rows[0].id, req.user.id]);
|
||||||
|
|
||||||
|
// Update member count
|
||||||
|
await db.query(`
|
||||||
|
UPDATE servers SET member_count = member_count + 1 WHERE id = $1
|
||||||
|
`, [server.rows[0].id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
server: server.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/servers/:id/channels
|
||||||
|
* Get channels for a server
|
||||||
|
*/
|
||||||
|
router.get('/:id/channels', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check membership
|
||||||
|
const memberCheck = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2
|
||||||
|
`, [req.params.id, req.user.id]);
|
||||||
|
|
||||||
|
if (memberCheck.rows.length === 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Not a member of this server'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const channels = await db.query(`
|
||||||
|
SELECT * FROM channels WHERE server_id = $1 ORDER BY position ASC
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
channels: channels.rows
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/servers/:id/channels
|
||||||
|
* Create a new channel in a server
|
||||||
|
*/
|
||||||
|
router.post('/:id/channels', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, channel_type } = req.body;
|
||||||
|
|
||||||
|
// Check if user is admin/owner
|
||||||
|
const memberCheck = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
|
||||||
|
`, [req.params.id, req.user.id]);
|
||||||
|
|
||||||
|
if (memberCheck.rows.length === 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Permission denied'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get next position
|
||||||
|
const posResult = await db.query(`
|
||||||
|
SELECT COALESCE(MAX(position), -1) + 1 as next_pos FROM channels WHERE server_id = $1
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
const channel = await db.query(`
|
||||||
|
INSERT INTO channels (server_id, name, description, channel_type, position)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *
|
||||||
|
`, [req.params.id, name, description || null, channel_type || 'text', posResult.rows[0].next_pos]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
channel: channel.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/servers/:id/members
|
||||||
|
* Get members of a server
|
||||||
|
*/
|
||||||
|
router.get('/:id/members', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check membership
|
||||||
|
const memberCheck = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2
|
||||||
|
`, [req.params.id, req.user.id]);
|
||||||
|
|
||||||
|
if (memberCheck.rows.length === 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Not a member of this server'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await db.query(`
|
||||||
|
SELECT u.id, u.username, u.display_name, u.avatar_url, u.status, u.custom_status,
|
||||||
|
sm.role, sm.nickname, sm.joined_at
|
||||||
|
FROM server_members sm
|
||||||
|
INNER JOIN users u ON sm.user_id = u.id
|
||||||
|
WHERE sm.server_id = $1
|
||||||
|
ORDER BY
|
||||||
|
CASE sm.role
|
||||||
|
WHEN 'owner' THEN 0
|
||||||
|
WHEN 'admin' THEN 1
|
||||||
|
WHEN 'moderator' THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END,
|
||||||
|
sm.joined_at ASC
|
||||||
|
`, [req.params.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
members: members.rows
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/channels/:channelId/messages
|
||||||
|
* Get messages from a channel
|
||||||
|
*/
|
||||||
|
router.get('/channels/:channelId/messages', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { limit = 50, before } = req.query;
|
||||||
|
|
||||||
|
// Get channel and check membership
|
||||||
|
const channel = await db.query(`SELECT server_id FROM channels WHERE id = $1`, [req.params.channelId]);
|
||||||
|
if (channel.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Channel not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCheck = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2
|
||||||
|
`, [channel.rows[0].server_id, req.user.id]);
|
||||||
|
|
||||||
|
if (memberCheck.rows.length === 0) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Not a member' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT m.*, u.username, u.display_name, u.avatar_url
|
||||||
|
FROM messages m
|
||||||
|
INNER JOIN users u ON m.user_id = u.id
|
||||||
|
WHERE m.channel_id = $1
|
||||||
|
`;
|
||||||
|
const params = [req.params.channelId];
|
||||||
|
|
||||||
|
if (before) {
|
||||||
|
query += ` AND m.created_at < $2`;
|
||||||
|
params.push(before);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY m.created_at DESC LIMIT $${params.length + 1}`;
|
||||||
|
params.push(parseInt(limit));
|
||||||
|
|
||||||
|
const messages = await db.query(query, params);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
messages: messages.rows.reverse()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/channels/:channelId/messages
|
||||||
|
* Send a message to a channel
|
||||||
|
*/
|
||||||
|
router.post('/channels/:channelId/messages', authenticateUser, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { content } = req.body;
|
||||||
|
|
||||||
|
if (!content || content.trim().length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: 'Message content is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get channel and check membership
|
||||||
|
const channel = await db.query(`SELECT server_id FROM channels WHERE id = $1`, [req.params.channelId]);
|
||||||
|
if (channel.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Channel not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberCheck = await db.query(`
|
||||||
|
SELECT * FROM server_members WHERE server_id = $1 AND user_id = $2
|
||||||
|
`, [channel.rows[0].server_id, req.user.id]);
|
||||||
|
|
||||||
|
if (memberCheck.rows.length === 0) {
|
||||||
|
return res.status(403).json({ success: false, error: 'Not a member' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = await db.query(`
|
||||||
|
INSERT INTO messages (channel_id, user_id, content)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING *
|
||||||
|
`, [req.params.channelId, req.user.id, content.trim()]);
|
||||||
|
|
||||||
|
// Get user info
|
||||||
|
const user = await db.query(`SELECT username, display_name, avatar_url FROM users WHERE id = $1`, [req.user.id]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: {
|
||||||
|
...message.rows[0],
|
||||||
|
...user.rows[0]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -19,6 +19,7 @@ const realtimeMessagesRoutes = require('./routes/realtimeMessagesRoutes');
|
||||||
const voiceCallRoutes = require('./routes/voiceCallRoutes');
|
const voiceCallRoutes = require('./routes/voiceCallRoutes');
|
||||||
const liveKitRoutes = require('./routes/liveKitRoutes');
|
const liveKitRoutes = require('./routes/liveKitRoutes');
|
||||||
const chatRoutes = require('./routes/chatRoutes');
|
const chatRoutes = require('./routes/chatRoutes');
|
||||||
|
const serverRoutes = require('./routes/serverRoutes');
|
||||||
const socketService = require('./services/socketService');
|
const socketService = require('./services/socketService');
|
||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
@ -91,6 +92,7 @@ app.use('/api/realtime/messages', realtimeMessagesRoutes);
|
||||||
app.use('/api/voice', voiceCallRoutes);
|
app.use('/api/voice', voiceCallRoutes);
|
||||||
app.use('/api/livekit', liveKitRoutes);
|
app.use('/api/livekit', liveKitRoutes);
|
||||||
app.use('/api/chat', chatRoutes);
|
app.use('/api/chat', chatRoutes);
|
||||||
|
app.use('/api/servers', serverRoutes);
|
||||||
|
|
||||||
// Initialize Socket.io
|
// Initialize Socket.io
|
||||||
const io = socketService.initialize(httpServer);
|
const io = socketService.initialize(httpServer);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue