new file: src/frontend/contexts/MessagingContext.jsx
This commit is contained in:
parent
d4456915f0
commit
839d68c20f
72 changed files with 22125 additions and 246 deletions
|
|
@ -1,84 +1,33 @@
|
|||
import React from 'react';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import DomainVerification from './components/DomainVerification';
|
||||
import VerifiedDomainBadge from './components/VerifiedDomainBadge';
|
||||
import './App.css';
|
||||
import { MessagingProvider } from './contexts/MessagingContext';
|
||||
import MainLayout from './mockup/MainLayout';
|
||||
import './index.css';
|
||||
|
||||
/**
|
||||
* Main application component
|
||||
* Demo of domain verification feature
|
||||
* AeThex Connect - Discord-style communication platform
|
||||
*/
|
||||
function App() {
|
||||
function AppContent() {
|
||||
const { user, loading } = useAuth();
|
||||
const [showVerification, setShowVerification] = React.useState(false);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-screen">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="app-header">
|
||||
<h1>AeThex Passport</h1>
|
||||
<p>Domain Verification</p>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
{user && (
|
||||
<div className="user-profile">
|
||||
<div className="profile-header">
|
||||
<h2>{user.email}</h2>
|
||||
{user.verifiedDomain && (
|
||||
<VerifiedDomainBadge
|
||||
verifiedDomain={user.verifiedDomain}
|
||||
verifiedAt={user.domainVerifiedAt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-section">
|
||||
<button
|
||||
onClick={() => setShowVerification(!showVerification)}
|
||||
className="toggle-button"
|
||||
>
|
||||
{showVerification ? 'Hide' : 'Show'} Domain Verification
|
||||
</button>
|
||||
|
||||
{showVerification && (
|
||||
<div className="verification-container">
|
||||
<DomainVerification />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="info-section">
|
||||
<h3>About Domain Verification</h3>
|
||||
<p>
|
||||
Domain verification allows you to prove ownership of a domain by adding
|
||||
a DNS TXT record or connecting a wallet that owns a .aethex blockchain domain.
|
||||
</p>
|
||||
<ul>
|
||||
<li>✓ Verify traditional domains via DNS TXT records</li>
|
||||
<li>✓ Verify .aethex domains via blockchain</li>
|
||||
<li>✓ Display verified domain on your profile</li>
|
||||
<li>✓ Prevent domain impersonation</li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="app-footer">
|
||||
<p>© 2026 AeThex Corporation. All rights reserved.</p>
|
||||
</footer>
|
||||
<div className="flex items-center justify-center h-screen bg-zinc-900">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-purple-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppWrapper() {
|
||||
return <MainLayout />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<MessagingProvider>
|
||||
<AppContent />
|
||||
</MessagingProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-cont#0a0a0f;
|
||||
justify-content: center;
|
||||
background: #0a0a0f;
|
||||
color: #e4e4e7;
|
||||
}
|
||||
|
||||
|
|
|
|||
159
src/frontend/contexts/MessagingContext.jsx
Normal file
159
src/frontend/contexts/MessagingContext.jsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { messagingService, socket, connectSocket, disconnectSocket } from '../services/messaging';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
const MessagingContext = createContext();
|
||||
|
||||
export function useMessaging() {
|
||||
const context = useContext(MessagingContext);
|
||||
if (!context) {
|
||||
throw new Error('useMessaging must be used within a MessagingProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function MessagingProvider({ children }) {
|
||||
const { user } = useAuth();
|
||||
const [conversations, setConversations] = useState([]);
|
||||
const [currentConversation, setCurrentConversation] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load conversations when user logs in
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
connectSocket(user.id);
|
||||
loadConversations();
|
||||
} else {
|
||||
disconnectSocket();
|
||||
setConversations([]);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
return () => {
|
||||
disconnectSocket();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Listen for real-time messages
|
||||
useEffect(() => {
|
||||
const handleNewMessage = (message) => {
|
||||
if (message.conversation_id === currentConversation?.id) {
|
||||
setMessages(prev => [...prev, message]);
|
||||
}
|
||||
// Update conversation's last message
|
||||
setConversations(prev =>
|
||||
prev.map(conv =>
|
||||
conv.id === message.conversation_id
|
||||
? { ...conv, lastMessage: message, updated_at: message.created_at }
|
||||
: conv
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
socket.on('message:new', handleNewMessage);
|
||||
return () => {
|
||||
socket.off('message:new', handleNewMessage);
|
||||
};
|
||||
}, [currentConversation?.id]);
|
||||
|
||||
// Subscribe to current conversation's messages
|
||||
useEffect(() => {
|
||||
if (!currentConversation?.id) return;
|
||||
|
||||
const unsubscribe = messagingService.subscribeToMessages(
|
||||
currentConversation.id,
|
||||
(newMessage) => {
|
||||
setMessages(prev => {
|
||||
// Avoid duplicates
|
||||
if (prev.find(m => m.id === newMessage.id)) return prev;
|
||||
return [...prev, newMessage];
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return unsubscribe;
|
||||
}, [currentConversation?.id]);
|
||||
|
||||
const loadConversations = useCallback(async () => {
|
||||
if (!user?.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await messagingService.getConversations(user.id);
|
||||
setConversations(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to load conversations:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const selectConversation = useCallback(async (conversation) => {
|
||||
setCurrentConversation(conversation);
|
||||
setLoading(true);
|
||||
try {
|
||||
const msgs = await messagingService.getMessages(conversation.id);
|
||||
setMessages(msgs);
|
||||
} catch (err) {
|
||||
console.error('Failed to load messages:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async (content) => {
|
||||
if (!currentConversation?.id || !user?.id || !content.trim()) return;
|
||||
|
||||
try {
|
||||
const message = await messagingService.sendMessage(
|
||||
currentConversation.id,
|
||||
user.id,
|
||||
content
|
||||
);
|
||||
// Optimistically add to local state
|
||||
setMessages(prev => [...prev, message]);
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
setError(err.message);
|
||||
}
|
||||
}, [currentConversation?.id, user?.id]);
|
||||
|
||||
const createConversation = useCallback(async (type, title, participantIds) => {
|
||||
if (!user?.id) return;
|
||||
|
||||
try {
|
||||
const conversation = await messagingService.createConversation(
|
||||
type,
|
||||
title,
|
||||
[user.id, ...participantIds]
|
||||
);
|
||||
setConversations(prev => [conversation, ...prev]);
|
||||
return conversation;
|
||||
} catch (err) {
|
||||
console.error('Failed to create conversation:', err);
|
||||
setError(err.message);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
const value = {
|
||||
conversations,
|
||||
currentConversation,
|
||||
messages,
|
||||
loading,
|
||||
error,
|
||||
selectConversation,
|
||||
sendMessage,
|
||||
createConversation,
|
||||
refreshConversations: loadConversations,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessagingContext.Provider value={value}>
|
||||
{children}
|
||||
</MessagingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -5,39 +7,25 @@
|
|||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #000000;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #e4e4e7;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #18181b;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 5px;
|
||||
background: #333;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #e4e4e7;
|
||||
background: #444;
|
||||
}
|
||||
|
|
|
|||
186
src/frontend/mockup/ActivityStatus.jsx
Normal file
186
src/frontend/mockup/ActivityStatus.jsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ActivityStatus({ activity, size = 'normal' }) {
|
||||
if (!activity) return null;
|
||||
|
||||
const activityTypes = {
|
||||
playing: { icon: '🎮', label: 'Playing' },
|
||||
streaming: { icon: '📺', label: 'Streaming' },
|
||||
listening: { icon: '🎧', label: 'Listening to' },
|
||||
watching: { icon: '📺', label: 'Watching' },
|
||||
competing: { icon: '🏆', label: 'Competing in' },
|
||||
custom: { icon: null, label: null },
|
||||
developing: { icon: '💻', label: 'Developing' },
|
||||
building: { icon: '🔨', label: 'Building' },
|
||||
};
|
||||
|
||||
const typeInfo = activityTypes[activity.type] || activityTypes.custom;
|
||||
|
||||
if (size === 'small') {
|
||||
return (
|
||||
<div className="activity-status-small">
|
||||
{typeInfo.icon && <span className="activity-icon-small">{typeInfo.icon}</span>}
|
||||
<span className="activity-text-small">
|
||||
{typeInfo.label ? `${typeInfo.label} ${activity.name}` : activity.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (size === 'mini') {
|
||||
return (
|
||||
<span className="activity-status-mini">
|
||||
{activity.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="activity-status">
|
||||
<div className="activity-header">
|
||||
{typeInfo.icon && <span className="activity-type-icon">{typeInfo.icon}</span>}
|
||||
<span className="activity-type-label">{typeInfo.label || 'Activity'}</span>
|
||||
</div>
|
||||
|
||||
<div className="activity-content">
|
||||
{activity.largeImage && (
|
||||
<div className="activity-image">
|
||||
<img src={activity.largeImage} alt="" />
|
||||
{activity.smallImage && (
|
||||
<img src={activity.smallImage} alt="" className="activity-image-small" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="activity-details">
|
||||
<div className="activity-name">{activity.name}</div>
|
||||
{activity.details && <div className="activity-detail">{activity.details}</div>}
|
||||
{activity.state && <div className="activity-state">{activity.state}</div>}
|
||||
{activity.startTime && (
|
||||
<div className="activity-time">
|
||||
<ActivityTimer startTime={activity.startTime} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activity.buttons && activity.buttons.length > 0 && (
|
||||
<div className="activity-buttons">
|
||||
{activity.buttons.map((btn, idx) => (
|
||||
<button key={idx} className="activity-btn">
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityTimer({ startTime }) {
|
||||
const [elapsed, setElapsed] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateElapsed = () => {
|
||||
const now = Date.now();
|
||||
const diff = now - startTime;
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const minutes = Math.floor((diff % 3600000) / 60000);
|
||||
const seconds = Math.floor((diff % 60000) / 1000);
|
||||
|
||||
if (hours > 0) {
|
||||
setElapsed(`${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')} elapsed`);
|
||||
} else {
|
||||
setElapsed(`${minutes}:${seconds.toString().padStart(2, '0')} elapsed`);
|
||||
}
|
||||
};
|
||||
|
||||
updateElapsed();
|
||||
const interval = setInterval(updateElapsed, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [startTime]);
|
||||
|
||||
return <span>{elapsed}</span>;
|
||||
}
|
||||
|
||||
// Rich Presence card for games
|
||||
export function RichPresence({ game }) {
|
||||
if (!game) return null;
|
||||
|
||||
return (
|
||||
<div className="rich-presence">
|
||||
<div className="rp-header">
|
||||
<span className="rp-icon">🎮</span>
|
||||
<span className="rp-label">Playing a game</span>
|
||||
</div>
|
||||
|
||||
<div className="rp-content">
|
||||
<div className="rp-image">
|
||||
{game.icon ? (
|
||||
<img src={game.icon} alt={game.name} />
|
||||
) : (
|
||||
<span className="rp-placeholder">🎮</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rp-info">
|
||||
<div className="rp-name">{game.name}</div>
|
||||
{game.details && <div className="rp-details">{game.details}</div>}
|
||||
{game.state && <div className="rp-state">{game.state}</div>}
|
||||
<div className="rp-elapsed">
|
||||
<ActivityTimer startTime={game.startTime || Date.now() - 3600000} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Spotify-style listening activity
|
||||
export function SpotifyActivity({ track }) {
|
||||
if (!track) return null;
|
||||
|
||||
return (
|
||||
<div className="spotify-activity">
|
||||
<div className="spotify-header">
|
||||
<span className="spotify-icon">🎧</span>
|
||||
<span className="spotify-label">Listening to Spotify</span>
|
||||
</div>
|
||||
|
||||
<div className="spotify-content">
|
||||
<div className="spotify-album">
|
||||
{track.albumArt ? (
|
||||
<img src={track.albumArt} alt={track.album} />
|
||||
) : (
|
||||
<span className="spotify-placeholder">🎵</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="spotify-info">
|
||||
<div className="spotify-song">{track.name}</div>
|
||||
<div className="spotify-artist">by {track.artist}</div>
|
||||
<div className="spotify-album-name">on {track.album}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spotify-progress">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${(track.elapsed / track.duration) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-times">
|
||||
<span>{formatTime(track.elapsed)}</span>
|
||||
<span>{formatTime(track.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ms) {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
220
src/frontend/mockup/AppDirectory.jsx
Normal file
220
src/frontend/mockup/AppDirectory.jsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All Apps', icon: '🔷' },
|
||||
{ id: 'moderation', label: 'Moderation', icon: '🛡️' },
|
||||
{ id: 'entertainment', label: 'Entertainment', icon: '🎮' },
|
||||
{ id: 'social', label: 'Social', icon: '💬' },
|
||||
{ id: 'utility', label: 'Utility', icon: '🔧' },
|
||||
{ id: 'music', label: 'Music', icon: '🎵' },
|
||||
];
|
||||
|
||||
const apps = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'MEE6',
|
||||
icon: '🤖',
|
||||
color: '#5865f2',
|
||||
description: 'The most popular bot for moderation, leveling, and social commands.',
|
||||
category: 'moderation',
|
||||
verified: true,
|
||||
servers: '19M+',
|
||||
rating: 4.5,
|
||||
features: ['Moderation', 'Leveling', 'Custom Commands', 'Music'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Dyno',
|
||||
icon: '⚡',
|
||||
color: '#ffa500',
|
||||
description: 'Fully customizable server moderation bot with a web dashboard.',
|
||||
category: 'moderation',
|
||||
verified: true,
|
||||
servers: '4.7M+',
|
||||
rating: 4.3,
|
||||
features: ['Auto-mod', 'Dashboard', 'Logs', 'Announcements'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Rythm',
|
||||
icon: '🎵',
|
||||
color: '#e91e63',
|
||||
description: 'High quality music bot with support for multiple platforms.',
|
||||
category: 'music',
|
||||
verified: true,
|
||||
servers: '6M+',
|
||||
rating: 4.7,
|
||||
features: ['Music', 'Playlists', 'Lyrics', 'Volume Control'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Dank Memer',
|
||||
icon: '🐸',
|
||||
color: '#3ba55d',
|
||||
description: 'Meme posting, currency system, and fun commands.',
|
||||
category: 'entertainment',
|
||||
verified: true,
|
||||
servers: '8M+',
|
||||
rating: 4.6,
|
||||
features: ['Economy', 'Memes', 'Games', 'Gambling'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Carl-bot',
|
||||
icon: '🎭',
|
||||
color: '#9b59b6',
|
||||
description: 'Reaction roles, logging, automod, and utility commands.',
|
||||
category: 'utility',
|
||||
verified: true,
|
||||
servers: '3.5M+',
|
||||
rating: 4.4,
|
||||
features: ['Reaction Roles', 'Logging', 'Tags', 'Embeds'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Pokétwo',
|
||||
icon: '🔴',
|
||||
color: '#ff0000',
|
||||
description: 'Catch, trade, and battle Pokémon in Discord.',
|
||||
category: 'entertainment',
|
||||
verified: true,
|
||||
servers: '1.2M+',
|
||||
rating: 4.5,
|
||||
features: ['Pokémon', 'Trading', 'Battles', 'Events'],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Tickets',
|
||||
icon: '🎫',
|
||||
color: '#5865f2',
|
||||
description: 'Simple and powerful ticket system for support.',
|
||||
category: 'utility',
|
||||
verified: false,
|
||||
servers: '500K+',
|
||||
rating: 4.2,
|
||||
features: ['Tickets', 'Transcripts', 'Categories', 'Staff Roles'],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Arcane',
|
||||
icon: '🔮',
|
||||
color: '#eb459e',
|
||||
description: 'XP leveling, role rewards, and leaderboards.',
|
||||
category: 'social',
|
||||
verified: false,
|
||||
servers: '800K+',
|
||||
rating: 4.1,
|
||||
features: ['Leveling', 'Role Rewards', 'Leaderboard', 'Card Design'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function AppDirectory({ onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
const [selectedApp, setSelectedApp] = useState(null);
|
||||
|
||||
const filteredApps = apps.filter(app => {
|
||||
const matchesSearch = !search ||
|
||||
app.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
app.description.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = activeCategory === 'all' || app.category === activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="app-directory">
|
||||
<div className="app-dir-header">
|
||||
<h2>🔷 App Directory</h2>
|
||||
<button className="app-dir-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="app-dir-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search apps..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="app-dir-categories">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`app-cat-btn ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
<span>{cat.icon}</span> {cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="app-dir-content">
|
||||
{selectedApp ? (
|
||||
<div className="app-detail">
|
||||
<button className="back-btn" onClick={() => setSelectedApp(null)}>
|
||||
← Back
|
||||
</button>
|
||||
<div className="app-detail-header">
|
||||
<div
|
||||
className="app-icon-large"
|
||||
style={{ background: selectedApp.color }}
|
||||
>
|
||||
{selectedApp.icon}
|
||||
</div>
|
||||
<div className="app-detail-info">
|
||||
<div className="app-name-row">
|
||||
<h2>{selectedApp.name}</h2>
|
||||
{selectedApp.verified && <span className="verified-badge">✓ Verified</span>}
|
||||
</div>
|
||||
<p className="app-description">{selectedApp.description}</p>
|
||||
<div className="app-stats">
|
||||
<span>📊 {selectedApp.servers} servers</span>
|
||||
<span>⭐ {selectedApp.rating}/5</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="add-to-server-btn">Add to Server</button>
|
||||
</div>
|
||||
<div className="app-features">
|
||||
<h3>Features</h3>
|
||||
<div className="feature-tags">
|
||||
{selectedApp.features.map((feature, idx) => (
|
||||
<span key={idx} className="feature-tag">{feature}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="app-grid">
|
||||
{filteredApps.map(app => (
|
||||
<div
|
||||
key={app.id}
|
||||
className="app-card"
|
||||
onClick={() => setSelectedApp(app)}
|
||||
>
|
||||
<div
|
||||
className="app-card-icon"
|
||||
style={{ background: app.color }}
|
||||
>
|
||||
{app.icon}
|
||||
</div>
|
||||
<div className="app-card-info">
|
||||
<div className="app-card-name">
|
||||
{app.name}
|
||||
{app.verified && <span className="verified-small">✓</span>}
|
||||
</div>
|
||||
<p className="app-card-desc">{app.description}</p>
|
||||
<div className="app-card-stats">
|
||||
<span>{app.servers} servers</span>
|
||||
<span>⭐ {app.rating}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
src/frontend/mockup/AuditLog.jsx
Normal file
170
src/frontend/mockup/AuditLog.jsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockLogs = [
|
||||
{ id: 1, action: 'member_kick', user: { name: 'ModUser', avatar: 'M', color: '#5865f2' }, target: 'SpamBot#1234', time: '2 minutes ago', reason: 'Spam' },
|
||||
{ id: 2, action: 'channel_create', user: { name: 'Admin', avatar: 'A', color: '#ff0000' }, target: '#announcements', time: '15 minutes ago' },
|
||||
{ id: 3, action: 'role_update', user: { name: 'Admin', avatar: 'A', color: '#ff0000' }, target: 'Moderator', time: '1 hour ago', changes: 'Added kick permission' },
|
||||
{ id: 4, action: 'member_ban', user: { name: 'ModUser', avatar: 'M', color: '#5865f2' }, target: 'ToxicUser#9999', time: '2 hours ago', reason: 'Harassment' },
|
||||
{ id: 5, action: 'message_delete', user: { name: 'AutoMod', avatar: '🤖', color: '#3ba55d' }, target: '5 messages', time: '3 hours ago', reason: 'Spam filter' },
|
||||
{ id: 6, action: 'invite_create', user: { name: 'Member', avatar: 'M', color: '#ffa500' }, target: 'discord.gg/abc123', time: '4 hours ago' },
|
||||
{ id: 7, action: 'member_timeout', user: { name: 'ModUser', avatar: 'M', color: '#5865f2' }, target: 'RuleBreaker#0001', time: '5 hours ago', reason: 'Breaking rules', duration: '10 minutes' },
|
||||
{ id: 8, action: 'server_update', user: { name: 'Owner', avatar: 'O', color: '#e91e63' }, target: 'Server name', time: '1 day ago', changes: 'Changed to AeThex Foundation' },
|
||||
{ id: 9, action: 'emoji_create', user: { name: 'Admin', avatar: 'A', color: '#ff0000' }, target: ':custom_emoji:', time: '2 days ago' },
|
||||
{ id: 10, action: 'webhook_create', user: { name: 'Developer', avatar: 'D', color: '#0066ff' }, target: 'GitHub Webhook', time: '3 days ago' },
|
||||
];
|
||||
|
||||
const actionTypes = [
|
||||
{ id: 'all', label: 'All Actions' },
|
||||
{ id: 'member', label: 'Member Actions' },
|
||||
{ id: 'channel', label: 'Channel Actions' },
|
||||
{ id: 'role', label: 'Role Actions' },
|
||||
{ id: 'message', label: 'Message Actions' },
|
||||
{ id: 'server', label: 'Server Actions' },
|
||||
{ id: 'invite', label: 'Invite Actions' },
|
||||
];
|
||||
|
||||
const actionIcons = {
|
||||
member_kick: '👢',
|
||||
member_ban: '🔨',
|
||||
member_timeout: '⏰',
|
||||
member_join: '📥',
|
||||
member_leave: '📤',
|
||||
channel_create: '➕',
|
||||
channel_delete: '🗑️',
|
||||
channel_update: '📝',
|
||||
role_create: '🎭',
|
||||
role_update: '🎭',
|
||||
role_delete: '🎭',
|
||||
message_delete: '💬',
|
||||
message_pin: '📌',
|
||||
server_update: '⚙️',
|
||||
invite_create: '🔗',
|
||||
invite_delete: '🔗',
|
||||
emoji_create: '😀',
|
||||
emoji_delete: '😀',
|
||||
webhook_create: '🔌',
|
||||
webhook_delete: '🔌',
|
||||
};
|
||||
|
||||
const actionLabels = {
|
||||
member_kick: 'kicked',
|
||||
member_ban: 'banned',
|
||||
member_timeout: 'timed out',
|
||||
member_join: 'joined',
|
||||
member_leave: 'left',
|
||||
channel_create: 'created channel',
|
||||
channel_delete: 'deleted channel',
|
||||
channel_update: 'updated channel',
|
||||
role_create: 'created role',
|
||||
role_update: 'updated role',
|
||||
role_delete: 'deleted role',
|
||||
message_delete: 'deleted',
|
||||
message_pin: 'pinned',
|
||||
server_update: 'updated',
|
||||
invite_create: 'created invite',
|
||||
invite_delete: 'deleted invite',
|
||||
emoji_create: 'added emoji',
|
||||
emoji_delete: 'removed emoji',
|
||||
webhook_create: 'created webhook',
|
||||
webhook_delete: 'deleted webhook',
|
||||
};
|
||||
|
||||
export default function AuditLog({ onClose }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [searchUser, setSearchUser] = useState('');
|
||||
const [expandedLog, setExpandedLog] = useState(null);
|
||||
|
||||
const filteredLogs = mockLogs.filter(log => {
|
||||
if (filter !== 'all' && !log.action.startsWith(filter)) return false;
|
||||
if (searchUser && !log.user.name.toLowerCase().includes(searchUser.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="audit-log">
|
||||
<div className="audit-header">
|
||||
<h2>📋 Audit Log</h2>
|
||||
<button className="audit-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="audit-filters">
|
||||
<div className="audit-search">
|
||||
<span>🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by user..."
|
||||
value={searchUser}
|
||||
onChange={(e) => setSearchUser(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="audit-type-filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
>
|
||||
{actionTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="audit-list">
|
||||
{filteredLogs.map(log => (
|
||||
<div
|
||||
key={log.id}
|
||||
className={`audit-entry ${expandedLog === log.id ? 'expanded' : ''}`}
|
||||
onClick={() => setExpandedLog(expandedLog === log.id ? null : log.id)}
|
||||
>
|
||||
<div className="audit-entry-main">
|
||||
<span className="audit-icon">{actionIcons[log.action] || '📝'}</span>
|
||||
<div
|
||||
className="audit-user-avatar"
|
||||
style={{ background: log.user.color }}
|
||||
>
|
||||
{log.user.avatar}
|
||||
</div>
|
||||
<div className="audit-info">
|
||||
<span className="audit-user-name">{log.user.name}</span>
|
||||
<span className="audit-action">{actionLabels[log.action] || log.action}</span>
|
||||
<span className="audit-target">{log.target}</span>
|
||||
</div>
|
||||
<span className="audit-time">{log.time}</span>
|
||||
</div>
|
||||
|
||||
{expandedLog === log.id && (
|
||||
<div className="audit-details">
|
||||
{log.reason && (
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">Reason:</span>
|
||||
<span className="detail-value">{log.reason}</span>
|
||||
</div>
|
||||
)}
|
||||
{log.duration && (
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">Duration:</span>
|
||||
<span className="detail-value">{log.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
{log.changes && (
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">Changes:</span>
|
||||
<span className="detail-value">{log.changes}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="audit-detail-row">
|
||||
<span className="detail-label">ID:</span>
|
||||
<span className="detail-value audit-id">{log.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="audit-footer">
|
||||
<span className="audit-count">{filteredLogs.length} entries</span>
|
||||
<button className="audit-load-more">Load More</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/frontend/mockup/AutoModSettings.jsx
Normal file
259
src/frontend/mockup/AutoModSettings.jsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const autoModRules = [
|
||||
{
|
||||
id: 'profanity',
|
||||
name: 'Block Profanity',
|
||||
description: 'Automatically block common profane words',
|
||||
icon: '🤬',
|
||||
severity: 'high'
|
||||
},
|
||||
{
|
||||
id: 'spam',
|
||||
name: 'Block Spam',
|
||||
description: 'Prevent repeated messages, excessive mentions, and invite links',
|
||||
icon: '📧',
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
id: 'links',
|
||||
name: 'Block Links',
|
||||
description: 'Block messages containing links (with allowlist)',
|
||||
icon: '🔗',
|
||||
severity: 'low'
|
||||
},
|
||||
{
|
||||
id: 'mentions',
|
||||
name: 'Limit Mentions',
|
||||
description: 'Block messages with excessive @mentions',
|
||||
icon: '@',
|
||||
severity: 'medium'
|
||||
},
|
||||
{
|
||||
id: 'caps',
|
||||
name: 'Block Excessive Caps',
|
||||
description: 'Block messages that are mostly uppercase',
|
||||
icon: '🔠',
|
||||
severity: 'low'
|
||||
},
|
||||
{
|
||||
id: 'attachments',
|
||||
name: 'Block Attachments',
|
||||
description: 'Restrict file attachments in messages',
|
||||
icon: '📎',
|
||||
severity: 'low'
|
||||
},
|
||||
];
|
||||
|
||||
const defaultActions = [
|
||||
{ id: 'delete', label: 'Delete Message', icon: '🗑️' },
|
||||
{ id: 'alert', label: 'Send Alert to Channel', icon: '⚠️' },
|
||||
{ id: 'timeout', label: 'Timeout Member', icon: '⏰' },
|
||||
{ id: 'log', label: 'Log to Mod Channel', icon: '📝' },
|
||||
];
|
||||
|
||||
export default function AutoModSettings({ onSave, onClose }) {
|
||||
const [rules, setRules] = useState({
|
||||
profanity: { enabled: true, action: 'delete' },
|
||||
spam: { enabled: true, action: 'delete' },
|
||||
links: { enabled: false, action: 'delete', allowlist: [] },
|
||||
mentions: { enabled: true, action: 'alert', limit: 5 },
|
||||
caps: { enabled: false, action: 'delete', threshold: 70 },
|
||||
attachments: { enabled: false, action: 'delete' },
|
||||
});
|
||||
|
||||
const [customWords, setCustomWords] = useState(['badword1', 'badword2']);
|
||||
const [allowedLinks, setAllowedLinks] = useState(['youtube.com', 'twitter.com']);
|
||||
const [logChannel, setLogChannel] = useState('mod-logs');
|
||||
const [activeRule, setActiveRule] = useState(null);
|
||||
|
||||
const toggleRule = (ruleId) => {
|
||||
setRules(prev => ({
|
||||
...prev,
|
||||
[ruleId]: { ...prev[ruleId], enabled: !prev[ruleId]?.enabled }
|
||||
}));
|
||||
};
|
||||
|
||||
const updateRuleAction = (ruleId, action) => {
|
||||
setRules(prev => ({
|
||||
...prev,
|
||||
[ruleId]: { ...prev[ruleId], action }
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="automod-settings">
|
||||
<div className="automod-header">
|
||||
<h2>🤖 AutoMod</h2>
|
||||
<p className="automod-subtitle">Automatically moderate your server with configurable rules</p>
|
||||
<button className="automod-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="automod-content">
|
||||
<section className="automod-section">
|
||||
<h3>Moderation Rules</h3>
|
||||
<div className="rules-list">
|
||||
{autoModRules.map(rule => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`rule-card ${rules[rule.id]?.enabled ? 'enabled' : ''}`}
|
||||
>
|
||||
<div className="rule-main">
|
||||
<div className="rule-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rules[rule.id]?.enabled || false}
|
||||
onChange={() => toggleRule(rule.id)}
|
||||
/>
|
||||
</div>
|
||||
<div className="rule-icon">{rule.icon}</div>
|
||||
<div className="rule-info">
|
||||
<h4>{rule.name}</h4>
|
||||
<p>{rule.description}</p>
|
||||
</div>
|
||||
<div className={`severity-badge ${rule.severity}`}>
|
||||
{rule.severity}
|
||||
</div>
|
||||
<button
|
||||
className="configure-btn"
|
||||
onClick={() => setActiveRule(activeRule === rule.id ? null : rule.id)}
|
||||
>
|
||||
{activeRule === rule.id ? '▼' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeRule === rule.id && (
|
||||
<div className="rule-config">
|
||||
<div className="config-field">
|
||||
<label>Action when triggered</label>
|
||||
<div className="action-options">
|
||||
{defaultActions.map(action => (
|
||||
<button
|
||||
key={action.id}
|
||||
className={`action-btn ${rules[rule.id]?.action === action.id ? 'active' : ''}`}
|
||||
onClick={() => updateRuleAction(rule.id, action.id)}
|
||||
>
|
||||
<span className="action-icon">{action.icon}</span>
|
||||
<span>{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rule.id === 'mentions' && (
|
||||
<div className="config-field">
|
||||
<label>Maximum mentions per message</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={rules.mentions?.limit || 5}
|
||||
onChange={(e) => setRules(prev => ({
|
||||
...prev,
|
||||
mentions: { ...prev.mentions, limit: Number(e.target.value) }
|
||||
}))}
|
||||
className="number-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.id === 'caps' && (
|
||||
<div className="config-field">
|
||||
<label>Caps threshold (%)</label>
|
||||
<input
|
||||
type="range"
|
||||
min="50"
|
||||
max="100"
|
||||
value={rules.caps?.threshold || 70}
|
||||
onChange={(e) => setRules(prev => ({
|
||||
...prev,
|
||||
caps: { ...prev.caps, threshold: Number(e.target.value) }
|
||||
}))}
|
||||
/>
|
||||
<span>{rules.caps?.threshold || 70}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rule.id === 'links' && (
|
||||
<div className="config-field">
|
||||
<label>Allowed Domains</label>
|
||||
<div className="tag-list">
|
||||
{allowedLinks.map((link, idx) => (
|
||||
<span key={idx} className="tag">
|
||||
{link}
|
||||
<button onClick={() => setAllowedLinks(prev => prev.filter((_, i) => i !== idx))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add domain..."
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && e.target.value) {
|
||||
setAllowedLinks(prev => [...prev, e.target.value]);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="automod-section">
|
||||
<h3>Custom Blocked Words</h3>
|
||||
<p className="section-description">Add your own words to block in messages</p>
|
||||
<div className="custom-words">
|
||||
<div className="tag-list">
|
||||
{customWords.map((word, idx) => (
|
||||
<span key={idx} className="tag blocked">
|
||||
{word}
|
||||
<button onClick={() => setCustomWords(prev => prev.filter((_, i) => i !== idx))}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add word to block..."
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter' && e.target.value) {
|
||||
setCustomWords(prev => [...prev, e.target.value.toLowerCase()]);
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="automod-section">
|
||||
<h3>Log Channel</h3>
|
||||
<p className="section-description">Where to send AutoMod logs</p>
|
||||
<select
|
||||
value={logChannel}
|
||||
onChange={(e) => setLogChannel(e.target.value)}
|
||||
className="channel-select"
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value="mod-logs"># mod-logs</option>
|
||||
<option value="admin"># admin</option>
|
||||
<option value="bot-logs"># bot-logs</option>
|
||||
</select>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="automod-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="save-btn"
|
||||
onClick={() => onSave?.({ rules, customWords, allowedLinks, logChannel })}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/frontend/mockup/BanList.jsx
Normal file
127
src/frontend/mockup/BanList.jsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockBans = [
|
||||
{ id: 1, user: { name: 'SpamBot_123', tag: '#0000', avatar: 'S' }, reason: 'Spamming invite links', bannedBy: 'Trevor', bannedAt: '2025-12-15T14:30:00' },
|
||||
{ id: 2, user: { name: 'ToxicUser', tag: '#6666', avatar: 'T' }, reason: 'Harassment and hate speech', bannedBy: 'Sarah', bannedAt: '2025-11-20T09:15:00' },
|
||||
{ id: 3, user: { name: 'RaidLeader', tag: '#9999', avatar: 'R' }, reason: 'Organizing raid against server', bannedBy: 'Trevor', bannedAt: '2025-10-05T18:45:00' },
|
||||
{ id: 4, user: { name: 'ScammerX', tag: '#1111', avatar: 'X' }, reason: 'Attempting to scam members', bannedBy: 'Sarah', bannedAt: '2025-09-12T11:00:00' },
|
||||
{ id: 5, user: { name: 'BadActor', tag: '#4321', avatar: 'B' }, reason: 'No reason given', bannedBy: 'Trevor', bannedAt: '2025-08-01T16:20:00' },
|
||||
];
|
||||
|
||||
export default function BanList({ onClose }) {
|
||||
const [bans, setBans] = useState(mockBans);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedBan, setSelectedBan] = useState(null);
|
||||
const [showRevokeConfirm, setShowRevokeConfirm] = useState(false);
|
||||
|
||||
const filteredBans = bans.filter(ban =>
|
||||
ban.user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
ban.reason.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const handleRevoke = (banId) => {
|
||||
setBans(prev => prev.filter(b => b.id !== banId));
|
||||
setShowRevokeConfirm(false);
|
||||
setSelectedBan(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="ban-list-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ban-list-header">
|
||||
<h2>🚫 Server Bans</h2>
|
||||
<button className="ban-list-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="ban-list-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search bans..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ban-list-content">
|
||||
{filteredBans.length === 0 ? (
|
||||
<div className="no-bans">
|
||||
<span className="no-bans-icon">✨</span>
|
||||
<p>No bans found</p>
|
||||
<span className="no-bans-hint">This server has no banned users</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="ban-count">{filteredBans.length} banned user{filteredBans.length !== 1 ? 's' : ''}</div>
|
||||
{filteredBans.map(ban => (
|
||||
<div
|
||||
key={ban.id}
|
||||
className={`ban-item ${selectedBan === ban.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedBan(selectedBan === ban.id ? null : ban.id)}
|
||||
>
|
||||
<div className="ban-user-info">
|
||||
<div className="ban-avatar">{ban.user.avatar}</div>
|
||||
<div className="ban-user-details">
|
||||
<div className="ban-username">
|
||||
{ban.user.name}<span className="ban-tag">{ban.user.tag}</span>
|
||||
</div>
|
||||
<div className="ban-reason">{ban.reason}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ban-meta">
|
||||
<div className="ban-date">{formatDate(ban.bannedAt)}</div>
|
||||
<div className="ban-by">by {ban.bannedBy}</div>
|
||||
</div>
|
||||
|
||||
{selectedBan === ban.id && (
|
||||
<div className="ban-actions">
|
||||
<button
|
||||
className="revoke-ban-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowRevokeConfirm(true);
|
||||
}}
|
||||
>
|
||||
Revoke Ban
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRevokeConfirm && selectedBan && (
|
||||
<div className="revoke-confirm-overlay" onClick={() => setShowRevokeConfirm(false)}>
|
||||
<div className="revoke-confirm-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Revoke Ban</h3>
|
||||
<p>
|
||||
Are you sure you want to unban{' '}
|
||||
<strong>{bans.find(b => b.id === selectedBan)?.user.name}</strong>?
|
||||
</p>
|
||||
<p className="revoke-warning">They will be able to rejoin the server with an invite.</p>
|
||||
<div className="revoke-actions">
|
||||
<button className="cancel-btn" onClick={() => setShowRevokeConfirm(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="confirm-revoke-btn" onClick={() => handleRevoke(selectedBan)}>
|
||||
Revoke Ban
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/frontend/mockup/CategoryEditor.jsx
Normal file
155
src/frontend/mockup/CategoryEditor.jsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function CategoryEditor({ category, onClose, onSave, onDelete }) {
|
||||
const categoryData = category || {
|
||||
id: 'new',
|
||||
name: '',
|
||||
channels: [],
|
||||
};
|
||||
|
||||
const [name, setName] = useState(categoryData.name);
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [syncPermissions, setSyncPermissions] = useState(true);
|
||||
|
||||
const availableRoles = [
|
||||
{ id: 'founder', name: 'Founder', color: '#ff0000' },
|
||||
{ id: 'foundation', name: 'Foundation', color: '#ff0000' },
|
||||
{ id: 'corporation', name: 'Corporation', color: '#0066ff' },
|
||||
{ id: 'labs', name: 'Labs', color: '#ffa500' },
|
||||
{ id: 'everyone', name: '@everyone', color: '#99aab5' },
|
||||
];
|
||||
|
||||
const [allowedRoles, setAllowedRoles] = useState(['everyone']);
|
||||
|
||||
const toggleRole = (roleId) => {
|
||||
setAllowedRoles(prev =>
|
||||
prev.includes(roleId)
|
||||
? prev.filter(id => id !== roleId)
|
||||
: [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({
|
||||
id: categoryData.id,
|
||||
name: name.toUpperCase(),
|
||||
isPrivate,
|
||||
allowedRoles,
|
||||
syncPermissions,
|
||||
});
|
||||
};
|
||||
|
||||
const isNew = !category?.name;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="category-editor" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="category-editor-header">
|
||||
<h2>{isNew ? 'Create Category' : 'Edit Category'}</h2>
|
||||
<button className="category-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="category-editor-content">
|
||||
<div className="setting-group">
|
||||
<label>Category Name</label>
|
||||
<input
|
||||
type="text"
|
||||
className="category-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="New Category"
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group toggle-group">
|
||||
<div className="toggle-info">
|
||||
<label>Private Category</label>
|
||||
<p className="setting-hint">Only selected roles can view this category</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isPrivate && (
|
||||
<div className="setting-group">
|
||||
<label>Who can view this category?</label>
|
||||
<div className="role-selector">
|
||||
{availableRoles.map(role => (
|
||||
<label
|
||||
key={role.id}
|
||||
className={`role-checkbox ${allowedRoles.includes(role.id) ? 'checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowedRoles.includes(role.id)}
|
||||
onChange={() => toggleRole(role.id)}
|
||||
/>
|
||||
<span className="role-color" style={{ background: role.color }}></span>
|
||||
<span className="role-name">{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && categoryData.channels?.length > 0 && (
|
||||
<div className="setting-group toggle-group">
|
||||
<div className="toggle-info">
|
||||
<label>Sync Permissions</label>
|
||||
<p className="setting-hint">
|
||||
Sync permissions with {categoryData.channels.length} channels in this category
|
||||
</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncPermissions}
|
||||
onChange={(e) => setSyncPermissions(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isNew && (
|
||||
<div className="category-channels">
|
||||
<label>Channels in this Category</label>
|
||||
<div className="channel-list-preview">
|
||||
{(categoryData.channels?.length > 0 ? categoryData.channels : [
|
||||
{ id: 'general', name: 'general', type: 'text' },
|
||||
{ id: 'api-discussion', name: 'api-discussion', type: 'text' },
|
||||
]).map(ch => (
|
||||
<div key={ch.id} className="channel-preview-item">
|
||||
<span className="channel-icon">{ch.type === 'voice' ? '🔊' : '#'}</span>
|
||||
<span>{ch.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="category-editor-footer">
|
||||
{!isNew && (
|
||||
<button className="delete-category-btn" onClick={onDelete}>
|
||||
Delete Category
|
||||
</button>
|
||||
)}
|
||||
<div className="footer-actions">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button className="save-btn" onClick={handleSave} disabled={!name.trim()}>
|
||||
{isNew ? 'Create Category' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
src/frontend/mockup/ChannelPermissions.jsx
Normal file
182
src/frontend/mockup/ChannelPermissions.jsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const permissionCategories = [
|
||||
{
|
||||
name: 'General Channel Permissions',
|
||||
permissions: [
|
||||
{ id: 'view_channel', label: 'View Channel', description: 'Allows members to view this channel' },
|
||||
{ id: 'manage_channel', label: 'Manage Channel', description: 'Allows members to change name, description, and settings' },
|
||||
{ id: 'manage_permissions', label: 'Manage Permissions', description: 'Allows members to change permission overrides' },
|
||||
{ id: 'manage_webhooks', label: 'Manage Webhooks', description: 'Allows members to create, edit and delete webhooks' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Text Channel Permissions',
|
||||
permissions: [
|
||||
{ id: 'send_messages', label: 'Send Messages', description: 'Allows members to send messages in this channel' },
|
||||
{ id: 'send_messages_threads', label: 'Send Messages in Threads', description: 'Allows members to send messages in threads' },
|
||||
{ id: 'create_public_threads', label: 'Create Public Threads', description: 'Allows members to create public threads' },
|
||||
{ id: 'create_private_threads', label: 'Create Private Threads', description: 'Allows members to create private threads' },
|
||||
{ id: 'embed_links', label: 'Embed Links', description: 'Links will show preview embeds' },
|
||||
{ id: 'attach_files', label: 'Attach Files', description: 'Allows members to upload files and images' },
|
||||
{ id: 'add_reactions', label: 'Add Reactions', description: 'Allows members to add reactions to messages' },
|
||||
{ id: 'use_external_emoji', label: 'Use External Emoji', description: 'Allows members to use emoji from other servers' },
|
||||
{ id: 'use_external_stickers', label: 'Use External Stickers', description: 'Allows members to use stickers from other servers' },
|
||||
{ id: 'mention_everyone', label: 'Mention @everyone', description: 'Allows members to mention @everyone and @here' },
|
||||
{ id: 'manage_messages', label: 'Manage Messages', description: 'Allows members to delete and pin messages' },
|
||||
{ id: 'manage_threads', label: 'Manage Threads', description: 'Allows members to rename, delete, and archive threads' },
|
||||
{ id: 'read_message_history', label: 'Read Message History', description: 'Allows reading of message history' },
|
||||
{ id: 'send_tts', label: 'Send TTS Messages', description: 'Allows sending text-to-speech messages' },
|
||||
{ id: 'use_application_commands', label: 'Use Application Commands', description: 'Allows using slash commands' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Voice Channel Permissions',
|
||||
permissions: [
|
||||
{ id: 'connect', label: 'Connect', description: 'Allows members to join this voice channel' },
|
||||
{ id: 'speak', label: 'Speak', description: 'Allows members to speak in this voice channel' },
|
||||
{ id: 'video', label: 'Video', description: 'Allows members to share video or stream' },
|
||||
{ id: 'mute_members', label: 'Mute Members', description: 'Allows muting other members' },
|
||||
{ id: 'deafen_members', label: 'Deafen Members', description: 'Allows deafening other members' },
|
||||
{ id: 'move_members', label: 'Move Members', description: 'Allows moving members between voice channels' },
|
||||
{ id: 'use_vad', label: 'Use Voice Activity', description: 'Allows using voice activity detection instead of push-to-talk' },
|
||||
{ id: 'priority_speaker', label: 'Priority Speaker', description: 'Allows using priority speaker mode' },
|
||||
{ id: 'request_to_speak', label: 'Request to Speak', description: 'Allows requesting to speak in stage channels' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function ChannelPermissions({ target, channelName, onClose, onSave }) {
|
||||
const targetData = target || {
|
||||
id: 'mod',
|
||||
name: 'Moderator',
|
||||
type: 'role',
|
||||
color: '#5865f2',
|
||||
};
|
||||
|
||||
const [permissions, setPermissions] = useState({});
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const getPermState = (permId) => permissions[permId] || 'neutral';
|
||||
|
||||
const cyclePermission = (permId) => {
|
||||
setPermissions(prev => {
|
||||
const current = prev[permId] || 'neutral';
|
||||
const next = current === 'neutral' ? 'allow' : current === 'allow' ? 'deny' : 'neutral';
|
||||
return { ...prev, [permId]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const setAllPermissions = (state) => {
|
||||
const newPerms = {};
|
||||
permissionCategories.forEach(cat => {
|
||||
cat.permissions.forEach(perm => {
|
||||
newPerms[perm.id] = state;
|
||||
});
|
||||
});
|
||||
setPermissions(newPerms);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.(permissions);
|
||||
};
|
||||
|
||||
const filteredCategories = permissionCategories.map(cat => ({
|
||||
...cat,
|
||||
permissions: cat.permissions.filter(p =>
|
||||
p.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
})).filter(cat => cat.permissions.length > 0);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="channel-permissions-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="permissions-header">
|
||||
<div className="permissions-title">
|
||||
<h2>Edit Permissions</h2>
|
||||
<span className="permissions-context">
|
||||
{targetData.type === 'role' ? '@' : ''}
|
||||
<span style={targetData.color ? { color: targetData.color } : {}}>
|
||||
{targetData.name}
|
||||
</span>
|
||||
<span className="channel-ref">in #{channelName || 'general'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button className="permissions-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="permissions-toolbar">
|
||||
<input
|
||||
type="text"
|
||||
className="permissions-search"
|
||||
placeholder="Search permissions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="bulk-actions">
|
||||
<button onClick={() => setAllPermissions('allow')}>Allow All</button>
|
||||
<button onClick={() => setAllPermissions('neutral')}>Clear All</button>
|
||||
<button onClick={() => setAllPermissions('deny')}>Deny All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="permissions-legend">
|
||||
<div className="legend-item">
|
||||
<span className="legend-icon allow">✓</span>
|
||||
<span>Allow</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<span className="legend-icon neutral">/</span>
|
||||
<span>Inherit</span>
|
||||
</div>
|
||||
<div className="legend-item">
|
||||
<span className="legend-icon deny">✕</span>
|
||||
<span>Deny</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="permissions-list">
|
||||
{filteredCategories.map(category => (
|
||||
<div key={category.name} className="permission-category">
|
||||
<h3>{category.name}</h3>
|
||||
{category.permissions.map(perm => (
|
||||
<div key={perm.id} className="permission-row">
|
||||
<div className="permission-info">
|
||||
<span className="permission-label">{perm.label}</span>
|
||||
<span className="permission-desc">{perm.description}</span>
|
||||
</div>
|
||||
<div className="permission-toggles">
|
||||
<button
|
||||
className={`perm-btn allow ${getPermState(perm.id) === 'allow' ? 'active' : ''}`}
|
||||
onClick={() => setPermissions(p => ({ ...p, [perm.id]: 'allow' }))}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
<button
|
||||
className={`perm-btn neutral ${getPermState(perm.id) === 'neutral' ? 'active' : ''}`}
|
||||
onClick={() => setPermissions(p => ({ ...p, [perm.id]: 'neutral' }))}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
className={`perm-btn deny ${getPermState(perm.id) === 'deny' ? 'active' : ''}`}
|
||||
onClick={() => setPermissions(p => ({ ...p, [perm.id]: 'deny' }))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="permissions-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button className="save-btn" onClick={handleSave}>Save Permissions</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
271
src/frontend/mockup/ChannelSettings.jsx
Normal file
271
src/frontend/mockup/ChannelSettings.jsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function ChannelSettings({ channel, onClose, onSave }) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const channelData = channel || {
|
||||
id: 'general',
|
||||
name: 'general',
|
||||
type: 'text',
|
||||
topic: 'Welcome to the general chat!',
|
||||
slowMode: 0,
|
||||
nsfw: false,
|
||||
category: 'Development',
|
||||
};
|
||||
|
||||
const [name, setName] = useState(channelData.name);
|
||||
const [topic, setTopic] = useState(channelData.topic);
|
||||
const [slowMode, setSlowMode] = useState(channelData.slowMode);
|
||||
const [nsfw, setNsfw] = useState(channelData.nsfw);
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: '📋' },
|
||||
{ id: 'permissions', label: 'Permissions', icon: '🔒' },
|
||||
{ id: 'invites', label: 'Invites', icon: '✉️' },
|
||||
{ id: 'integrations', label: 'Integrations', icon: '🔗' },
|
||||
];
|
||||
|
||||
const slowModeOptions = [
|
||||
{ value: 0, label: 'Off' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
{ value: 15, label: '15s' },
|
||||
{ value: 30, label: '30s' },
|
||||
{ value: 60, label: '1m' },
|
||||
{ value: 300, label: '5m' },
|
||||
{ value: 600, label: '10m' },
|
||||
{ value: 900, label: '15m' },
|
||||
{ value: 1800, label: '30m' },
|
||||
{ value: 3600, label: '1h' },
|
||||
{ value: 7200, label: '2h' },
|
||||
{ value: 21600, label: '6h' },
|
||||
];
|
||||
|
||||
const permissionOverrides = [
|
||||
{ id: 'founder', name: 'Founder', color: '#ff0000', type: 'role', allow: ['all'], deny: [] },
|
||||
{ id: 'mod', name: 'Moderator', color: '#5865f2', type: 'role', allow: ['manage_messages'], deny: [] },
|
||||
{ id: 'user123', name: 'SpecificUser', color: null, type: 'member', allow: [], deny: ['send_messages'] },
|
||||
];
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({ name, topic, slowMode, nsfw });
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="channel-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="settings-sidebar">
|
||||
<div className="settings-channel-info">
|
||||
<span className="channel-hash">#</span>
|
||||
<span className="settings-channel-name">{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-nav">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`settings-nav-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="nav-icon">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
<div className="settings-nav-item danger">
|
||||
<span className="nav-icon">🗑️</span>
|
||||
<span>Delete Channel</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="settings-header">
|
||||
<h2>{tabs.find(t => t.id === activeTab)?.label}</h2>
|
||||
<button className="settings-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="channel-overview">
|
||||
<div className="setting-group">
|
||||
<label>Channel Name</label>
|
||||
<div className="channel-name-input">
|
||||
<span className="input-prefix">#</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Channel Topic</label>
|
||||
<textarea
|
||||
className="setting-textarea"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="Let everyone know what this channel is about"
|
||||
maxLength={1024}
|
||||
/>
|
||||
<span className="char-count">{topic.length}/1024</span>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Slowmode</label>
|
||||
<div className="slowmode-select">
|
||||
<select value={slowMode} onChange={(e) => setSlowMode(parseInt(e.target.value))}>
|
||||
{slowModeOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="setting-hint">Members can only send one message per this interval</p>
|
||||
</div>
|
||||
|
||||
<div className="setting-group toggle-group">
|
||||
<div className="toggle-info">
|
||||
<label>NSFW Channel</label>
|
||||
<p className="setting-hint">Users must verify their age to view content</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={nsfw} onChange={(e) => setNsfw(e.target.checked)} />
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Category</label>
|
||||
<select className="setting-select" defaultValue={channelData.category}>
|
||||
<option>Announcements</option>
|
||||
<option>Development</option>
|
||||
<option>Support</option>
|
||||
<option>Voice Channels</option>
|
||||
<option>No Category</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="channel-permissions">
|
||||
<div className="permissions-info">
|
||||
<p>Channel permissions override server-level permissions for specific roles or members.</p>
|
||||
</div>
|
||||
|
||||
<div className="permissions-section">
|
||||
<div className="section-header">
|
||||
<h3>Roles/Members</h3>
|
||||
<button className="add-override-btn">+ Add Role or Member</button>
|
||||
</div>
|
||||
|
||||
<div className="overrides-list">
|
||||
{permissionOverrides.map(override => (
|
||||
<div key={override.id} className="override-item">
|
||||
<div className="override-info">
|
||||
{override.type === 'role' ? (
|
||||
<span className="role-indicator" style={{ background: override.color }}>@</span>
|
||||
) : (
|
||||
<span className="member-indicator">👤</span>
|
||||
)}
|
||||
<span className="override-name" style={override.color ? { color: override.color } : {}}>
|
||||
{override.name}
|
||||
</span>
|
||||
<span className="override-type">{override.type}</span>
|
||||
</div>
|
||||
<div className="override-actions">
|
||||
<button className="edit-override">Edit</button>
|
||||
<button className="remove-override">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="permissions-section">
|
||||
<h3>Advanced Permissions</h3>
|
||||
<div className="advanced-perms-grid">
|
||||
<div className="perm-category">
|
||||
<h4>General Channel Permissions</h4>
|
||||
<div className="perm-item">
|
||||
<span>View Channel</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow">✓</button>
|
||||
<button className="perm-btn neutral active">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-item">
|
||||
<span>Manage Channel</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow">✓</button>
|
||||
<button className="perm-btn neutral active">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="perm-category">
|
||||
<h4>Text Channel Permissions</h4>
|
||||
<div className="perm-item">
|
||||
<span>Send Messages</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow active">✓</button>
|
||||
<button className="perm-btn neutral">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-item">
|
||||
<span>Embed Links</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow active">✓</button>
|
||||
<button className="perm-btn neutral">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="perm-item">
|
||||
<span>Attach Files</span>
|
||||
<div className="perm-toggles">
|
||||
<button className="perm-btn allow">✓</button>
|
||||
<button className="perm-btn neutral active">/</button>
|
||||
<button className="perm-btn deny">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'invites' && (
|
||||
<div className="channel-invites">
|
||||
<p className="no-invites">No invites have been created for this channel yet.</p>
|
||||
<button className="create-invite-btn">Create Invite</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'integrations' && (
|
||||
<div className="channel-integrations">
|
||||
<div className="integration-section">
|
||||
<h3>Webhooks</h3>
|
||||
<p className="integration-desc">Webhooks allow external services to send messages to this channel.</p>
|
||||
<button className="create-webhook-btn">Create Webhook</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-save-bar">
|
||||
<span>Careful — you have unsaved changes!</span>
|
||||
<div className="save-actions">
|
||||
<button className="reset-btn" onClick={onClose}>Reset</button>
|
||||
<button className="save-btn" onClick={handleSave}>Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +1,112 @@
|
|||
import React from "react";
|
||||
import React, { useState } from 'react';
|
||||
import UserSettingsBar from './UserSettingsBar';
|
||||
import VoiceChannel from './VoiceChannel';
|
||||
|
||||
const channels = [
|
||||
{ category: 'Announcements', items: [
|
||||
{ id: 'updates', icon: '📢', name: 'updates', badge: 3 },
|
||||
{ id: 'changelog', icon: '📜', name: 'changelog' },
|
||||
]},
|
||||
{ category: 'Development', items: [
|
||||
{ id: 'general', icon: '#', name: 'general' },
|
||||
{ id: 'api-discussion', icon: '#', name: 'api-discussion' },
|
||||
{ id: 'passport-development', icon: '#', name: 'passport-development' },
|
||||
]},
|
||||
{ category: 'Support', items: [
|
||||
{ id: 'help', icon: '❓', name: 'help' },
|
||||
{ id: 'bug-reports', icon: '🐛', name: 'bug-reports' },
|
||||
]},
|
||||
];
|
||||
|
||||
const voiceChannels = [
|
||||
{ id: 'nexus-lounge', name: 'Nexus Lounge', users: [
|
||||
{ id: 1, name: 'Trevor', avatar: 'T', color: '#ff0000', speaking: true, muted: false },
|
||||
{ id: 2, name: 'Sarah', avatar: 'S', color: '#ffa500', speaking: false, muted: true },
|
||||
{ id: 3, name: 'DevUser_2847', avatar: 'D', color: '#666', speaking: false, muted: false, streaming: true },
|
||||
]},
|
||||
{ id: 'collab-space', name: 'Collab Space', users: [] },
|
||||
];
|
||||
|
||||
export default function ChannelSidebar({ onCreateChannel, onSettingsClick, onJoinVoice }) {
|
||||
const [activeChannel, setActiveChannel] = useState('general');
|
||||
const [expandedVoice, setExpandedVoice] = useState(['nexus-lounge']);
|
||||
const [hoveredCategory, setHoveredCategory] = useState(null);
|
||||
|
||||
export default function ChannelSidebar() {
|
||||
return (
|
||||
<div className="channel-sidebar w-72 bg-[#0f0f0f] border-r border-[#1a1a1a] flex flex-col">
|
||||
{/* Server Header */}
|
||||
<div className="server-header p-4 border-b border-[#1a1a1a] font-bold text-base flex items-center justify-between">
|
||||
<div className="channel-sidebar">
|
||||
<div className="server-header">
|
||||
<span>AeThex Foundation</span>
|
||||
<span className="server-badge foundation text-xs px-2 py-1 rounded bg-red-900/20 text-red-500 border border-red-500 uppercase tracking-wider">Official</span>
|
||||
<span className="server-badge foundation">Official</span>
|
||||
</div>
|
||||
{/* Channel List */}
|
||||
<div className="channel-list flex-1 overflow-y-auto py-2">
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Announcements</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">📢</span>
|
||||
<span className="channel-name flex-1">updates</span>
|
||||
<span className="channel-badge text-xs bg-red-600 text-white px-2 rounded-full">3</span>
|
||||
|
||||
<div className="channel-list">
|
||||
{channels.map((category) => (
|
||||
<div key={category.category}>
|
||||
<div
|
||||
className="channel-category"
|
||||
onMouseEnter={() => setHoveredCategory(category.category)}
|
||||
onMouseLeave={() => setHoveredCategory(null)}
|
||||
>
|
||||
{category.category}
|
||||
{hoveredCategory === category.category && onCreateChannel && (
|
||||
<button
|
||||
className="category-add-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onCreateChannel(); }}
|
||||
title="Create Channel"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{category.items.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={`channel-item${activeChannel === channel.id ? ' active' : ''}`}
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
>
|
||||
<span className="channel-icon">{channel.icon}</span>
|
||||
<span className="channel-name">{channel.name}</span>
|
||||
{channel.badge && <span className="channel-badge">{channel.badge}</span>}
|
||||
</div>
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Development</div>
|
||||
<div className="channel-item active flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm bg-[#1a1a1a]">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name flex-1">general</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name flex-1">api-discussion</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name flex-1">passport-development</span>
|
||||
</div>
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Support</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">❓</span>
|
||||
<span className="channel-name flex-1">help</span>
|
||||
</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">🐛</span>
|
||||
<span className="channel-name flex-1">bug-reports</span>
|
||||
</div>
|
||||
<div className="channel-category px-4 pt-4 pb-2 text-xs uppercase tracking-widest text-gray-500 font-bold">Voice Channels</div>
|
||||
<div className="channel-item flex items-center gap-2 px-4 py-2 mx-2 rounded cursor-pointer text-sm hover:bg-[#1a1a1a]">
|
||||
<span className="channel-icon">🔊</span>
|
||||
<span className="channel-name flex-1">Nexus Lounge</span>
|
||||
<span className="text-gray-500 text-xs">3</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* User Presence */}
|
||||
<div className="user-presence p-3 border-t border-[#1a1a1a] flex items-center gap-3 text-sm">
|
||||
<div className="user-avatar w-10 h-10 rounded-full flex items-center justify-center font-bold bg-gradient-to-tr from-red-600 via-blue-600 to-orange-400">A</div>
|
||||
<div className="user-info flex-1">
|
||||
<div className="user-name font-bold mb-0.5">Anderson</div>
|
||||
<div className="user-status flex items-center gap-1 text-xs text-gray-500">
|
||||
<span className="status-dot w-2 h-2 rounded-full bg-green-400 shadow-green-400/50 shadow" />
|
||||
<span>Building AeThex</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Voice Channels */}
|
||||
<div
|
||||
className="channel-category"
|
||||
onMouseEnter={() => setHoveredCategory('voice')}
|
||||
onMouseLeave={() => setHoveredCategory(null)}
|
||||
>
|
||||
Voice Channels
|
||||
{hoveredCategory === 'voice' && onCreateChannel && (
|
||||
<button
|
||||
className="category-add-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onCreateChannel(); }}
|
||||
title="Create Channel"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceChannels.map((vc) => (
|
||||
<VoiceChannel
|
||||
key={vc.id}
|
||||
channel={vc}
|
||||
expanded={expandedVoice.includes(vc.id)}
|
||||
onToggle={() => {
|
||||
setExpandedVoice((prev) =>
|
||||
prev.includes(vc.id)
|
||||
? prev.filter((id) => id !== vc.id)
|
||||
: [...prev, vc.id]
|
||||
);
|
||||
}}
|
||||
onJoin={onJoinVoice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<UserSettingsBar onSettingsClick={onSettingsClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,282 @@
|
|||
import React from "react";
|
||||
import Message from "./Message";
|
||||
import MessageInput from "./MessageInput";
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import MessageActions from './MessageActions';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
|
||||
const messages = [
|
||||
{ type: "system", label: "FOUNDATION", text: "Foundation authentication services upgraded to v2.1.0. Enhanced security protocols now active across all AeThex infrastructure.", className: "foundation" },
|
||||
{ type: "user", author: "Trevor", badge: "Foundation", time: "10:34 AM", text: "Just pushed the authentication updates. All services should automatically migrate to the new protocols within 24 hours.", avatar: "T", avatarBg: "from-red-600 to-red-800" },
|
||||
{ type: "user", author: "Marcus", time: "10:41 AM", text: "Excellent work! I've been testing the new Passport integration and it's incredibly smooth. The Trinity color-coding in the UI makes it really clear which division is handling what.", avatar: "M", avatarBg: "from-blue-600 to-blue-900" },
|
||||
{ type: "system", label: "LABS", text: "Nexus Engine v2.0-beta now available for testing. New cross-platform sync reduces latency by 40%. Join #labs-testing to participate.", className: "labs" },
|
||||
{ type: "user", author: "Sarah", badge: "Labs", time: "11:15 AM", text: "The Nexus v2 parallel compilation is insane. Cut my build time from 3 minutes to under 2. Still some edge cases with complex state synchronization but wow... ⚠️", avatar: "S", avatarBg: "from-orange-400 to-orange-700" },
|
||||
{ type: "user", author: "Anderson", badge: "Founder", time: "11:47 AM", text: "Love seeing the Trinity infrastructure working in harmony. Foundation keeping everything secure, Labs pushing the boundaries, Corporation delivering production-ready tools. This is exactly the vision.", avatar: "A", avatarBg: "from-red-600 via-blue-600 to-orange-400" },
|
||||
{ type: "user", author: "DevUser_2847", time: "12:03 PM", text: "Quick question - when using AeThex Studio, does the Terminal automatically connect to all three Trinity divisions, or do I need to configure that?", avatar: "D", avatarBg: "bg-[#1a1a1a]" },
|
||||
{ type: "system", label: "CORPORATION", text: "AeThex Studio Pro users: New Railway deployment templates available. Optimized configurations for Foundation APIs, Corporation services, and Labs experiments.", className: "corporation" },
|
||||
const initialMessages = [
|
||||
{
|
||||
type: 'system',
|
||||
division: 'foundation',
|
||||
label: '[FOUNDATION] System Announcement',
|
||||
text: 'Foundation authentication services upgraded to v2.1.0. Enhanced security protocols now active across all AeThex infrastructure.',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 1,
|
||||
author: 'Trevor',
|
||||
badge: 'foundation',
|
||||
badgeLabel: 'Foundation',
|
||||
time: '10:34 AM',
|
||||
avatar: { initial: 'T', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)' },
|
||||
text: 'Just pushed the authentication updates. All services should automatically migrate to the new protocols within 24 hours.',
|
||||
reactions: [{ emoji: '🔥', count: 3, reacted: true }, { emoji: '👍', count: 2, reacted: false }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 2,
|
||||
author: 'Marcus',
|
||||
time: '10:41 AM',
|
||||
avatar: { initial: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)' },
|
||||
text: "Excellent work! I've been testing the new Passport integration and it's incredibly smooth. The Trinity color-coding in the UI makes it really clear which division is handling what.",
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
division: 'labs',
|
||||
label: '[LABS] Experimental Feature Alert',
|
||||
text: 'Nexus Engine v2.0-beta now available for testing. New cross-platform sync reduces latency by 40%. Join #labs-testing to participate.',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 3,
|
||||
author: 'Sarah',
|
||||
badge: 'labs',
|
||||
badgeLabel: 'Labs',
|
||||
time: '11:15 AM',
|
||||
avatar: { initial: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)' },
|
||||
text: 'The Nexus v2 parallel compilation is insane. Cut my build time from 3 minutes to under 2. Still some edge cases with complex state synchronization but wow...',
|
||||
reactions: [{ emoji: '🚀', count: 5, reacted: true }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 4,
|
||||
author: 'Anderson',
|
||||
badge: 'foundation',
|
||||
badgeLabel: 'Founder',
|
||||
time: '11:47 AM',
|
||||
avatar: { initial: 'A', gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)' },
|
||||
text: 'Love seeing the Trinity infrastructure working in harmony. Foundation keeping everything secure, Labs pushing the boundaries, Corporation delivering production-ready tools. This is exactly the vision.',
|
||||
reactions: [{ emoji: '❤️', count: 8, reacted: false }, { emoji: '🔥', count: 4, reacted: true }],
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
id: 5,
|
||||
author: 'DevUser_2847',
|
||||
time: '12:03 PM',
|
||||
avatar: { initial: 'D' },
|
||||
text: 'Quick question - when using AeThex Studio, does the Terminal automatically connect to all three Trinity divisions, or do I need to configure that?',
|
||||
},
|
||||
{
|
||||
type: 'system',
|
||||
division: 'corporation',
|
||||
label: '[CORPORATION] Service Update',
|
||||
text: 'AeThex Studio Pro users: New Railway deployment templates available. Optimized configurations for Foundation APIs, Corporation services, and Labs experiments.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ChatArea() {
|
||||
export default function ChatArea({ onOpenSearch, onOpenThread, onPinnedClick, onNotificationsClick, onContextMenu }) {
|
||||
const [messages, setMessages] = useState(initialMessages);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [hoveredMessage, setHoveredMessage] = useState(null);
|
||||
const [typingUsers] = useState(['Sarah', 'Marcus']); // Simulated typing
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = () => {
|
||||
if (!inputValue.trim()) return;
|
||||
|
||||
const newMessage = {
|
||||
type: 'message',
|
||||
id: Date.now(),
|
||||
author: 'You',
|
||||
time: new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }),
|
||||
avatar: { initial: 'Y', gradient: 'linear-gradient(135deg, #0066ff, #003380)' },
|
||||
text: inputValue,
|
||||
};
|
||||
|
||||
setMessages([...messages, newMessage]);
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmojiSelect = (emoji) => {
|
||||
setInputValue((prev) => prev + emoji);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
const handleReaction = (messageId, emoji) => {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== messageId) return msg;
|
||||
const reactions = msg.reactions || [];
|
||||
const existing = reactions.find((r) => r.emoji === emoji);
|
||||
if (existing) {
|
||||
return {
|
||||
...msg,
|
||||
reactions: reactions.map((r) =>
|
||||
r.emoji === emoji
|
||||
? { ...r, count: r.reacted ? r.count - 1 : r.count + 1, reacted: !r.reacted }
|
||||
: r
|
||||
).filter((r) => r.count > 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...msg,
|
||||
reactions: [...reactions, { emoji, count: 1, reacted: true }],
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-area flex flex-col flex-1 bg-[#0a0a0a]">
|
||||
{/* Chat Header */}
|
||||
<div className="chat-header px-5 py-4 border-b border-[#1a1a1a] flex items-center gap-3">
|
||||
<span className="channel-name-header flex-1 font-bold text-base"># general</span>
|
||||
<div className="chat-tools flex gap-4 text-sm text-gray-500">
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">🔔</span>
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">📌</span>
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">👥 128</span>
|
||||
<span className="chat-tool cursor-pointer hover:text-blue-500">🔍</span>
|
||||
<div className="chat-area">
|
||||
<div className="chat-header">
|
||||
<span className="channel-name-header"># general</span>
|
||||
<div className="chat-tools">
|
||||
<span
|
||||
className="chat-tool"
|
||||
onClick={onNotificationsClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Notifications"
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
<span
|
||||
className="chat-tool"
|
||||
onClick={onPinnedClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Pinned Messages"
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
<span className="chat-tool">👥 128</span>
|
||||
<span className="chat-tool" onClick={onOpenSearch} style={{ cursor: 'pointer' }} title="Search">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="chat-messages flex-1 overflow-y-auto px-5 py-5">
|
||||
{messages.map((msg, i) => (
|
||||
<Message key={i} {...msg} />
|
||||
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, idx) => {
|
||||
if (msg.type === 'system') {
|
||||
return (
|
||||
<div key={`system-${idx}`} className={`message-system ${msg.division}`}>
|
||||
<div className={`system-label ${msg.division}`}>{msg.label}</div>
|
||||
<div>{msg.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="message"
|
||||
onMouseEnter={() => setHoveredMessage(msg.id)}
|
||||
onMouseLeave={() => setHoveredMessage(null)}
|
||||
>
|
||||
<div
|
||||
className="message-avatar"
|
||||
style={msg.avatar.gradient ? { background: msg.avatar.gradient } : undefined}
|
||||
>
|
||||
{msg.avatar.initial}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<span className="message-author">{msg.author}</span>
|
||||
{msg.badge && (
|
||||
<span className={`message-badge ${msg.badge}`}>{msg.badgeLabel}</span>
|
||||
)}
|
||||
<span className="message-time">{msg.time}</span>
|
||||
</div>
|
||||
<div className="message-text">{msg.text}</div>
|
||||
|
||||
{/* Reactions */}
|
||||
{msg.reactions && msg.reactions.length > 0 && (
|
||||
<div className="message-reactions" style={{ display: 'flex', gap: '6px', marginTop: '6px' }}>
|
||||
{msg.reactions.map((r) => (
|
||||
<button
|
||||
key={r.emoji}
|
||||
onClick={() => handleReaction(msg.id, r.emoji)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
padding: '2px 8px',
|
||||
background: r.reacted ? 'rgba(88, 101, 242, 0.3)' : '#2f3136',
|
||||
border: r.reacted ? '1px solid #5865f2' : '1px solid transparent',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.85em',
|
||||
}}
|
||||
>
|
||||
<span>{r.emoji}</span>
|
||||
<span style={{ color: r.reacted ? '#5865f2' : '#b9bbbe' }}>{r.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Message Input */}
|
||||
<div className="message-input-container px-5 py-5 border-t border-[#1a1a1a]">
|
||||
<MessageInput />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Actions on Hover */}
|
||||
{hoveredMessage === msg.id && (
|
||||
<MessageActions
|
||||
onReact={(emoji) => handleReaction(msg.id, emoji)}
|
||||
onReply={() => onOpenThread && onOpenThread(msg)}
|
||||
onThread={() => onOpenThread && onOpenThread(msg)}
|
||||
isOwn={msg.author === 'You'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{typingUsers.length > 0 && <TypingIndicator users={typingUsers} />}
|
||||
|
||||
<div className="message-input-container" style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: '1.2em',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
😊
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
className="message-input"
|
||||
placeholder="Message #general (Foundation infrastructure channel)"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
style={{ paddingRight: '48px' }}
|
||||
/>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker
|
||||
onSelect={handleEmojiSelect}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
133
src/frontend/mockup/ContextMenu.jsx
Normal file
133
src/frontend/mockup/ContextMenu.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export default function ContextMenu({ x, y, type, data, onClose, onAction }) {
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') onClose?.();
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const menuItems = {
|
||||
message: [
|
||||
{ id: 'add-reaction', label: 'Add Reaction', icon: '😀' },
|
||||
{ divider: true },
|
||||
{ id: 'reply', label: 'Reply', icon: '↩️' },
|
||||
{ id: 'create-thread', label: 'Create Thread', icon: '🧵' },
|
||||
{ divider: true },
|
||||
{ id: 'edit', label: 'Edit Message', icon: '✏️', ownerOnly: true },
|
||||
{ id: 'pin', label: 'Pin Message', icon: '📌' },
|
||||
{ id: 'mark-unread', label: 'Mark Unread', icon: '📩' },
|
||||
{ id: 'copy-text', label: 'Copy Text', icon: '📋' },
|
||||
{ id: 'copy-link', label: 'Copy Message Link', icon: '🔗' },
|
||||
{ divider: true },
|
||||
{ id: 'speak', label: 'Speak Message', icon: '🔊' },
|
||||
{ divider: true },
|
||||
{ id: 'delete', label: 'Delete Message', icon: '🗑️', danger: true, ownerOnly: true },
|
||||
{ id: 'report', label: 'Report Message', icon: '🚩', danger: true },
|
||||
],
|
||||
user: [
|
||||
{ id: 'profile', label: 'Profile', icon: '👤' },
|
||||
{ id: 'mention', label: 'Mention', icon: '@' },
|
||||
{ id: 'message', label: 'Message', icon: '💬' },
|
||||
{ divider: true },
|
||||
{ id: 'call', label: 'Call', icon: '📞' },
|
||||
{ id: 'video-call', label: 'Video Call', icon: '📹' },
|
||||
{ divider: true },
|
||||
{ id: 'add-note', label: 'Add Note', icon: '📝' },
|
||||
{ id: 'add-friend', label: 'Add Friend', icon: '👋' },
|
||||
{ divider: true },
|
||||
{ id: 'invite', label: 'Invite to Server', icon: '➕', submenu: true },
|
||||
{ divider: true },
|
||||
{ id: 'mute', label: 'Mute', icon: '🔇' },
|
||||
{ id: 'block', label: 'Block', icon: '🚫', danger: true },
|
||||
{ id: 'report', label: 'Report', icon: '🚩', danger: true },
|
||||
],
|
||||
channel: [
|
||||
{ id: 'mark-read', label: 'Mark As Read', icon: '✓' },
|
||||
{ divider: true },
|
||||
{ id: 'mute', label: 'Mute Channel', icon: '🔇', submenu: true },
|
||||
{ id: 'notification-settings', label: 'Notification Settings', icon: '🔔' },
|
||||
{ divider: true },
|
||||
{ id: 'edit', label: 'Edit Channel', icon: '✏️' },
|
||||
{ id: 'duplicate', label: 'Duplicate Channel', icon: '📋' },
|
||||
{ id: 'create-invite', label: 'Create Invite', icon: '✉️' },
|
||||
{ divider: true },
|
||||
{ id: 'copy-link', label: 'Copy Channel Link', icon: '🔗' },
|
||||
{ id: 'copy-id', label: 'Copy Channel ID', icon: '#️⃣' },
|
||||
{ divider: true },
|
||||
{ id: 'delete', label: 'Delete Channel', icon: '🗑️', danger: true },
|
||||
],
|
||||
server: [
|
||||
{ id: 'mark-read', label: 'Mark As Read', icon: '✓' },
|
||||
{ divider: true },
|
||||
{ id: 'invite-people', label: 'Invite People', icon: '👋' },
|
||||
{ divider: true },
|
||||
{ id: 'mute', label: 'Mute Server', icon: '🔇', submenu: true },
|
||||
{ id: 'notification-settings', label: 'Notification Settings', icon: '🔔' },
|
||||
{ id: 'hide-muted', label: 'Hide Muted Channels', icon: '👁️' },
|
||||
{ divider: true },
|
||||
{ id: 'privacy-settings', label: 'Privacy Settings', icon: '🔒' },
|
||||
{ id: 'edit-nickname', label: 'Edit Server Nickname', icon: '✏️' },
|
||||
{ divider: true },
|
||||
{ id: 'server-settings', label: 'Server Settings', icon: '⚙️' },
|
||||
{ id: 'create-channel', label: 'Create Channel', icon: '➕' },
|
||||
{ id: 'create-category', label: 'Create Category', icon: '📁' },
|
||||
{ divider: true },
|
||||
{ id: 'copy-id', label: 'Copy Server ID', icon: '#️⃣' },
|
||||
{ divider: true },
|
||||
{ id: 'leave', label: 'Leave Server', icon: '🚪', danger: true },
|
||||
],
|
||||
};
|
||||
|
||||
const items = menuItems[type] || [];
|
||||
const isOwn = data?.isOwn;
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
const adjustedStyle = {
|
||||
position: 'fixed',
|
||||
top: y,
|
||||
left: x,
|
||||
zIndex: 10000,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="context-menu" ref={menuRef} style={adjustedStyle}>
|
||||
{items.map((item, idx) => {
|
||||
if (item.divider) {
|
||||
return <div key={`divider-${idx}`} className="context-menu-divider" />;
|
||||
}
|
||||
|
||||
if (item.ownerOnly && !isOwn) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`context-menu-item ${item.danger ? 'danger' : ''}`}
|
||||
onClick={() => {
|
||||
onAction?.(item.id, data);
|
||||
onClose?.();
|
||||
}}
|
||||
>
|
||||
<span className="context-menu-icon">{item.icon}</span>
|
||||
<span className="context-menu-label">{item.label}</span>
|
||||
{item.submenu && <span className="context-menu-arrow">▶</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
src/frontend/mockup/CreateChannelModal.jsx
Normal file
115
src/frontend/mockup/CreateChannelModal.jsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function CreateChannelModal({ category, onClose, onCreate }) {
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
|
||||
const channelTypes = [
|
||||
{ id: 'text', icon: '#', label: 'Text', description: 'Send messages, images, GIFs, emoji, opinions, and puns' },
|
||||
{ id: 'voice', icon: '🔊', label: 'Voice', description: 'Hang out together with voice, video, and screen share' },
|
||||
{ id: 'forum', icon: '💬', label: 'Forum', description: 'Create a space for organized discussions' },
|
||||
{ id: 'announcement', icon: '📢', label: 'Announcement', description: 'Important updates followers can subscribe to' },
|
||||
{ id: 'stage', icon: '🎭', label: 'Stage', description: 'Host events with moderated discussions' },
|
||||
];
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!channelName.trim()) return;
|
||||
|
||||
onCreate?.({
|
||||
type: channelType,
|
||||
name: channelName.toLowerCase().replace(/\s+/g, '-'),
|
||||
isPrivate,
|
||||
category,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create Channel</h2>
|
||||
{category && <span className="category-name">in {category}</span>}
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="channel-type-section">
|
||||
<label className="section-label">CHANNEL TYPE</label>
|
||||
<div className="channel-types">
|
||||
{channelTypes.map((type) => (
|
||||
<label
|
||||
key={type.id}
|
||||
className={`channel-type-option ${channelType === type.id ? 'selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="channelType"
|
||||
value={type.id}
|
||||
checked={channelType === type.id}
|
||||
onChange={() => setChannelType(type.id)}
|
||||
/>
|
||||
<span className="type-icon">{type.icon}</span>
|
||||
<div className="type-info">
|
||||
<span className="type-label">{type.label}</span>
|
||||
<span className="type-description">{type.description}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-name-section">
|
||||
<label className="section-label">CHANNEL NAME</label>
|
||||
<div className="channel-name-input">
|
||||
<span className="channel-prefix">
|
||||
{channelType === 'text' ? '#' :
|
||||
channelType === 'voice' ? '🔊' :
|
||||
channelType === 'forum' ? '💬' :
|
||||
channelType === 'announcement' ? '📢' : '🎭'}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="new-channel"
|
||||
value={channelName}
|
||||
onChange={(e) => setChannelName(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="channel-privacy-section">
|
||||
<label className="privacy-toggle">
|
||||
<div className="privacy-info">
|
||||
<span className="privacy-icon">🔒</span>
|
||||
<div>
|
||||
<span className="privacy-label">Private Channel</span>
|
||||
<span className="privacy-description">
|
||||
Only selected members and roles will be able to view this channel.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPrivate}
|
||||
onChange={(e) => setIsPrivate(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="modal-cancel" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="modal-submit"
|
||||
onClick={handleCreate}
|
||||
disabled={!channelName.trim()}
|
||||
>
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
src/frontend/mockup/DMsView.jsx
Normal file
242
src/frontend/mockup/DMsView.jsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const friends = [
|
||||
{ id: 1, name: 'Trevor', tag: 'Trevor#0042', avatar: 'T', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)', status: 'online', activity: 'Working on AeThex' },
|
||||
{ id: 2, name: 'Sarah', tag: 'Sarah#1337', avatar: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)', status: 'online', activity: 'Playing Valorant' },
|
||||
{ id: 3, name: 'Marcus', tag: 'Marcus#2048', avatar: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)', status: 'idle' },
|
||||
{ id: 4, name: 'DevUser_2847', tag: 'DevUser_2847#2847', avatar: 'D', status: 'dnd', activity: 'Do Not Disturb' },
|
||||
{ id: 5, name: 'JohnDev', tag: 'JohnDev#9999', avatar: 'J', status: 'offline' },
|
||||
];
|
||||
|
||||
const dmConversations = [
|
||||
{ id: 1, user: friends[0], lastMessage: 'The auth system is ready!', time: '2m', unread: 2 },
|
||||
{ id: 2, user: friends[1], lastMessage: 'Check out the new Nexus build', time: '15m', unread: 0 },
|
||||
{ id: 3, user: friends[2], lastMessage: 'Thanks for the help!', time: '1h', unread: 0 },
|
||||
{ id: 4, user: { name: 'Group Chat', avatar: '👥', isGroup: true, members: ['Trevor', 'Sarah', 'Marcus'] }, lastMessage: 'Sarah: Let\'s sync up tomorrow', time: '3h', unread: 5 },
|
||||
];
|
||||
|
||||
const pendingRequests = [
|
||||
{ id: 1, name: 'NewDev123', tag: 'NewDev123#4567', avatar: 'N', type: 'incoming', mutualServers: 2 },
|
||||
{ id: 2, name: 'CoolCoder', tag: 'CoolCoder#8899', avatar: 'C', type: 'outgoing' },
|
||||
];
|
||||
|
||||
export default function DMsView({ onSelectDM, onOpenProfile }) {
|
||||
const [activeTab, setActiveTab] = useState('online');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'online', label: 'Online' },
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'pending', label: 'Pending', count: pendingRequests.length },
|
||||
{ id: 'blocked', label: 'Blocked' },
|
||||
{ id: 'add', label: 'Add Friend', isAction: true },
|
||||
];
|
||||
|
||||
const statusColors = {
|
||||
online: '#3ba55d',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
offline: '#747f8d',
|
||||
};
|
||||
|
||||
const filteredFriends = friends.filter((f) => {
|
||||
if (activeTab === 'online') return f.status === 'online';
|
||||
if (activeTab === 'all') return true;
|
||||
return true;
|
||||
}).filter((f) =>
|
||||
f.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="dms-view">
|
||||
{/* DMs Sidebar */}
|
||||
<div className="dms-sidebar">
|
||||
<div className="dms-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Find or start a conversation"
|
||||
className="dms-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dms-section">
|
||||
<div className="dms-section-header">
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
<div
|
||||
className="dms-nav-item"
|
||||
onClick={() => setActiveTab('online')}
|
||||
>
|
||||
<span className="nav-icon">👥</span>
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
<div className="dms-nav-item">
|
||||
<span className="nav-icon">🚀</span>
|
||||
<span>Nitro</span>
|
||||
</div>
|
||||
<div className="dms-nav-item">
|
||||
<span className="nav-icon">🛒</span>
|
||||
<span>Shop</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dms-section">
|
||||
<div className="dms-section-header">
|
||||
<span>Direct Messages</span>
|
||||
<button className="add-dm-btn">+</button>
|
||||
</div>
|
||||
|
||||
{dmConversations.map((dm) => (
|
||||
<div
|
||||
key={dm.id}
|
||||
className="dm-item"
|
||||
onClick={() => onSelectDM?.(dm)}
|
||||
>
|
||||
<div className="dm-avatar" style={{ background: dm.user.gradient || '#36393f' }}>
|
||||
{dm.user.avatar}
|
||||
{!dm.user.isGroup && (
|
||||
<div
|
||||
className="dm-status"
|
||||
style={{ background: statusColors[dm.user.status] || statusColors.offline }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="dm-info">
|
||||
<div className="dm-name">
|
||||
{dm.user.name}
|
||||
{dm.user.isGroup && <span className="group-count">{dm.user.members?.length}</span>}
|
||||
</div>
|
||||
<div className="dm-preview">{dm.lastMessage}</div>
|
||||
</div>
|
||||
{dm.unread > 0 && (
|
||||
<div className="dm-unread">{dm.unread}</div>
|
||||
)}
|
||||
<button className="dm-close" onClick={(e) => { e.stopPropagation(); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Friends List Panel */}
|
||||
<div className="friends-panel">
|
||||
<div className="friends-header">
|
||||
<div className="friends-title">
|
||||
<span className="friends-icon">👥</span>
|
||||
<span>Friends</span>
|
||||
</div>
|
||||
<div className="friends-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`friends-tab ${activeTab === tab.id ? 'active' : ''} ${tab.isAction ? 'action' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count > 0 && <span className="tab-count">{tab.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'add' ? (
|
||||
<div className="add-friend-panel">
|
||||
<h2>Add Friend</h2>
|
||||
<p>You can add friends with their AeThex username.</p>
|
||||
<div className="add-friend-input">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter a Username#0000"
|
||||
className="friend-input"
|
||||
/>
|
||||
<button className="send-request-btn">Send Friend Request</button>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'pending' ? (
|
||||
<div className="pending-requests">
|
||||
<div className="friends-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="friends-count">PENDING — {pendingRequests.length}</div>
|
||||
{pendingRequests.map((req) => (
|
||||
<div key={req.id} className="friend-item">
|
||||
<div className="friend-avatar">{req.avatar}</div>
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{req.name}</div>
|
||||
<div className="friend-tag">
|
||||
{req.type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="friend-actions">
|
||||
{req.type === 'incoming' && (
|
||||
<>
|
||||
<button className="friend-action accept">✓</button>
|
||||
<button className="friend-action decline">✕</button>
|
||||
</>
|
||||
)}
|
||||
{req.type === 'outgoing' && (
|
||||
<button className="friend-action decline">✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="friends-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="friends-count">
|
||||
{activeTab === 'online' ? 'ONLINE' : 'ALL FRIENDS'} — {filteredFriends.length}
|
||||
</div>
|
||||
<div className="friends-list">
|
||||
{filteredFriends.map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="friend-item"
|
||||
onClick={() => onOpenProfile?.(friend)}
|
||||
>
|
||||
<div className="friend-avatar" style={{ background: friend.gradient || '#36393f' }}>
|
||||
{friend.avatar}
|
||||
<div
|
||||
className="friend-status"
|
||||
style={{ background: statusColors[friend.status] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="friend-info">
|
||||
<div className="friend-name">{friend.name}</div>
|
||||
<div className="friend-activity">{friend.activity || friend.status}</div>
|
||||
</div>
|
||||
<div className="friend-actions">
|
||||
<button className="friend-action" title="Message">💬</button>
|
||||
<button className="friend-action" title="Voice Call">📞</button>
|
||||
<button className="friend-action" title="Video Call">📹</button>
|
||||
<button className="friend-action" title="More">⋯</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Activity/Now Playing Panel */}
|
||||
<div className="activity-panel">
|
||||
<h3>Active Now</h3>
|
||||
<div className="activity-placeholder">
|
||||
<p>It's quiet for now...</p>
|
||||
<p className="activity-hint">When a friend starts an activity—like playing a game or hanging out on voice—we'll show it here!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/frontend/mockup/EmojiPicker.jsx
Normal file
68
src/frontend/mockup/EmojiPicker.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const emojiCategories = [
|
||||
{ id: 'recent', icon: '🕐', name: 'Recent', emojis: ['👍', '❤️', '😂', '🔥', '✅', '👀', '🎉', '💯'] },
|
||||
{ id: 'smileys', icon: '😀', name: 'Smileys', emojis: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘', '😗', '😋', '😛', '😜', '🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐', '🤨', '😐', '😑', '😶', '😏', '😒', '🙄', '😬', '😮', '🤤', '😪', '😴', '😷', '🤒', '🤕', '🤢', '🤮', '🥵', '🥶', '😵', '🤯', '😎', '🥳', '😱', '😨', '😰', '😥', '😢', '😭', '😤', '😡', '🤬'] },
|
||||
{ id: 'people', icon: '👋', name: 'People', emojis: ['👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏', '✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆', '🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦿', '🦵', '🦶', '👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️', '👅', '👄'] },
|
||||
{ id: 'nature', icon: '🌿', name: 'Nature', emojis: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🌸', '🌺', '🌻', '🌹', '🌷', '🌲', '🌳', '🌴', '🌵', '🌾', '☀️', '🌙', '⭐', '🌈', '☁️', '⛈️', '❄️', '🔥', '💧'] },
|
||||
{ id: 'food', icon: '🍔', name: 'Food', emojis: ['🍎', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🍆', '🥑', '🥦', '🌽', '🌶️', '🫑', '🥒', '🥬', '🧄', '🧅', '🥔', '🍠', '🥐', '🍞', '🥖', '🥨', '🧀', '🍖', '🍗', '🥩', '🥓', '🍔', '🍟', '🍕', '🌭', '🥪', '🌮', '🌯', '🫔', '🥙', '🧆', '🥚', '🍳', '🥘', '🍲', '🫕', '🥣', '🥗', '🍿', '🧈', '🧂', '🥫'] },
|
||||
{ id: 'activities', icon: '⚽', name: 'Activities', emojis: ['⚽', '🏀', '🏈', '⚾', '🥎', '🎾', '🏐', '🏉', '🥏', '🎱', '🪀', '🏓', '🏸', '🏒', '🏑', '🥍', '🏏', '🪃', '🥅', '⛳', '🪁', '🏹', '🎣', '🤿', '🥊', '🥋', '🎽', '🛹', '🛼', '🛷', '⛸️', '🥌', '🎿', '⛷️', '🏂', '🎮', '🕹️', '🎲', '🧩', '♟️', '🎯', '🎳', '🎭', '🎨', '🎬', '🎤', '🎧', '🎼', '🎹', '🥁', '🪘', '🎷', '🎺', '🪗', '🎸', '🪕', '🎻'] },
|
||||
{ id: 'objects', icon: '💡', name: 'Objects', emojis: ['💻', '🖥️', '🖨️', '⌨️', '🖱️', '💽', '💾', '💿', '📀', '📱', '📲', '☎️', '📞', '📟', '📠', '🔋', '🔌', '💡', '🔦', '🕯️', '🧯', '🛢️', '💸', '💵', '💴', '💶', '💷', '🪙', '💰', '💳', '💎', '⚖️', '🪜', '🧰', '🪛', '🔧', '🔨', '⚒️', '🛠️', '⛏️', '🪚', '🔩', '⚙️', '🪤', '🧱', '⛓️', '🧲', '🔫', '💣', '🧨', '🪓', '🔪', '🗡️', '⚔️', '🛡️', '🚬', '⚰️', '🪦', '⚱️', '🏺'] },
|
||||
{ id: 'symbols', icon: '❤️', name: 'Symbols', emojis: ['❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❣️', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️', '☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎', '♈', '♉', '♊', '♋', '♌', '♍', '♎', '♏', '♐', '♑', '♒', '♓', '🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶', '🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐', '㊙️', '㊗️'] },
|
||||
{ id: 'custom', icon: '⭐', name: 'AeThex', emojis: ['🔴', '🔵', '🟠', '⚡', '🛡️', '🧪', '🏢', '🚀', '💻', '🔐', '🌐', '⚙️'] },
|
||||
];
|
||||
|
||||
export default function EmojiPicker({ onSelect, onClose }) {
|
||||
const [activeCategory, setActiveCategory] = useState('smileys');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const currentCategory = emojiCategories.find(c => c.id === activeCategory);
|
||||
const filteredEmojis = searchQuery
|
||||
? emojiCategories.flatMap(c => c.emojis)
|
||||
: currentCategory?.emojis || [];
|
||||
|
||||
return (
|
||||
<div className="emoji-picker">
|
||||
<div className="emoji-picker-header">
|
||||
<input
|
||||
type="text"
|
||||
className="emoji-search"
|
||||
placeholder="Search emoji..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="emoji-categories">
|
||||
{emojiCategories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`emoji-category-btn ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => { setActiveCategory(cat.id); setSearchQuery(''); }}
|
||||
title={cat.name}
|
||||
>
|
||||
{cat.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="emoji-grid">
|
||||
{filteredEmojis.slice(0, 64).map((emoji, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="emoji-btn"
|
||||
onClick={() => onSelect?.(emoji)}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="emoji-picker-footer">
|
||||
<span className="emoji-preview">
|
||||
{currentCategory?.name || 'Search Results'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/frontend/mockup/EmojiUpload.jsx
Normal file
185
src/frontend/mockup/EmojiUpload.jsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import React, { useState, useRef } from 'react';
|
||||
|
||||
const existingEmojis = [
|
||||
{ id: 1, name: 'pepehype', image: '🐸', animated: false },
|
||||
{ id: 2, name: 'monkaS', image: '😰', animated: false },
|
||||
{ id: 3, name: 'KEKW', image: '🤣', animated: false },
|
||||
{ id: 4, name: 'Pog', image: '😮', animated: true },
|
||||
{ id: 5, name: 'catJAM', image: '🐱', animated: true },
|
||||
{ id: 6, name: 'aethex_logo', image: '🔺', animated: false },
|
||||
];
|
||||
|
||||
const existingStickers = [
|
||||
{ id: 1, name: 'Wave', emoji: '👋', description: 'A friendly wave' },
|
||||
{ id: 2, name: 'Love', emoji: '❤️', description: 'Show some love' },
|
||||
{ id: 3, name: 'Party', emoji: '🎉', description: 'Party time!' },
|
||||
];
|
||||
|
||||
export default function EmojiUpload({ type = 'emoji', onUpload, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState(type);
|
||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||
const [emojiName, setEmojiName] = useState('');
|
||||
const [stickerDescription, setStickerDescription] = useState('');
|
||||
const [relatedEmoji, setRelatedEmoji] = useState('');
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
const newFiles = files.map(file => ({
|
||||
id: Date.now() + Math.random(),
|
||||
file,
|
||||
name: file.name.split('.')[0],
|
||||
preview: URL.createObjectURL(file),
|
||||
size: (file.size / 1024).toFixed(1) + ' KB',
|
||||
animated: file.type === 'image/gif',
|
||||
}));
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const newFiles = files.map(file => ({
|
||||
id: Date.now() + Math.random(),
|
||||
file,
|
||||
name: file.name.split('.')[0],
|
||||
preview: URL.createObjectURL(file),
|
||||
size: (file.size / 1024).toFixed(1) + ' KB',
|
||||
animated: file.type === 'image/gif',
|
||||
}));
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (fileId) => {
|
||||
setUploadedFiles(prev => prev.filter(f => f.id !== fileId));
|
||||
};
|
||||
|
||||
const updateFileName = (fileId, name) => {
|
||||
setUploadedFiles(prev => prev.map(f =>
|
||||
f.id === fileId ? { ...f, name } : f
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="emoji-upload-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="emoji-upload-header">
|
||||
<h2>Upload {activeTab === 'emoji' ? 'Emoji' : 'Sticker'}</h2>
|
||||
<button className="close-btn" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="emoji-upload-tabs">
|
||||
<button
|
||||
className={`upload-tab ${activeTab === 'emoji' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('emoji')}
|
||||
>
|
||||
😀 Emoji
|
||||
</button>
|
||||
<button
|
||||
className={`upload-tab ${activeTab === 'sticker' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('sticker')}
|
||||
>
|
||||
🎨 Sticker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="emoji-upload-content">
|
||||
{/* Existing items */}
|
||||
<div className="existing-items">
|
||||
<h3>{activeTab === 'emoji' ? 'Server Emoji' : 'Server Stickers'}</h3>
|
||||
<div className="items-grid">
|
||||
{activeTab === 'emoji' ? (
|
||||
existingEmojis.map(emoji => (
|
||||
<div key={emoji.id} className="item-card">
|
||||
<div className="item-preview">{emoji.image}</div>
|
||||
<span className="item-name">:{emoji.name}:</span>
|
||||
{emoji.animated && <span className="animated-badge">GIF</span>}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
existingStickers.map(sticker => (
|
||||
<div key={sticker.id} className="item-card sticker">
|
||||
<div className="item-preview">{sticker.emoji}</div>
|
||||
<span className="item-name">{sticker.name}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="slots-info">
|
||||
{activeTab === 'emoji' ? (
|
||||
<span>6/50 slots used ({existingEmojis.filter(e => e.animated).length}/50 animated)</span>
|
||||
) : (
|
||||
<span>3/15 sticker slots used</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload zone */}
|
||||
<div
|
||||
className="upload-zone"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={activeTab === 'emoji' ? 'image/png,image/gif' : 'image/png,image/apng'}
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
hidden
|
||||
/>
|
||||
<div className="upload-icon">📤</div>
|
||||
<p>Drag and drop or click to upload</p>
|
||||
<span className="upload-hint">
|
||||
{activeTab === 'emoji'
|
||||
? 'PNG or GIF, 256KB max, 128x128 recommended'
|
||||
: 'PNG or APNG, 512KB max, 320x320 size'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pending uploads */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="pending-uploads">
|
||||
<h3>Ready to Upload</h3>
|
||||
{uploadedFiles.map(file => (
|
||||
<div key={file.id} className="pending-file">
|
||||
<img src={file.preview} alt="" className="pending-preview" />
|
||||
<div className="pending-info">
|
||||
<input
|
||||
type="text"
|
||||
value={file.name}
|
||||
onChange={(e) => updateFileName(file.id, e.target.value)}
|
||||
placeholder="Name"
|
||||
className="pending-name-input"
|
||||
/>
|
||||
<span className="pending-size">{file.size}</span>
|
||||
{file.animated && <span className="animated-badge">GIF</span>}
|
||||
</div>
|
||||
<button
|
||||
className="pending-remove"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="emoji-upload-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="upload-btn"
|
||||
disabled={uploadedFiles.length === 0}
|
||||
onClick={() => onUpload?.(uploadedFiles)}
|
||||
>
|
||||
Upload {uploadedFiles.length > 0 ? `(${uploadedFiles.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
392
src/frontend/mockup/EventsPanel.jsx
Normal file
392
src/frontend/mockup/EventsPanel.jsx
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockEvents = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Monthly Community Townhall',
|
||||
description: 'Join us for our monthly Q&A session with the AeThex team. Ask questions, get updates, and connect with the community.',
|
||||
type: 'stage',
|
||||
channel: 'community-stage',
|
||||
startTime: new Date(Date.now() + 2 * 60 * 60 * 1000), // 2 hours from now
|
||||
endTime: new Date(Date.now() + 4 * 60 * 60 * 1000),
|
||||
host: { name: 'Anderson', avatar: 'A', color: '#ff0000' },
|
||||
interested: 234,
|
||||
image: null,
|
||||
recurring: 'monthly',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Game Night: Among Us',
|
||||
description: 'Weekly game night! This week we\'re playing Among Us. Join the voice channel 10 minutes early.',
|
||||
type: 'voice',
|
||||
channel: 'gaming-voice',
|
||||
startTime: new Date(Date.now() + 26 * 60 * 60 * 1000), // Tomorrow
|
||||
endTime: new Date(Date.now() + 29 * 60 * 60 * 1000),
|
||||
host: { name: 'Sarah', avatar: 'S', color: '#ffa500' },
|
||||
interested: 89,
|
||||
image: null,
|
||||
recurring: 'weekly',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'AeThex SDK Workshop',
|
||||
description: 'Learn how to integrate the AeThex SDK into your projects. Hands-on coding session with live Q&A.',
|
||||
type: 'external',
|
||||
location: 'https://workshop.aethex.com',
|
||||
startTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000), // 3 days
|
||||
endTime: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000 + 2 * 60 * 60 * 1000),
|
||||
host: { name: 'Trevor', avatar: 'T', color: '#0066ff' },
|
||||
interested: 456,
|
||||
image: null,
|
||||
recurring: null,
|
||||
},
|
||||
];
|
||||
|
||||
export default function EventsPanel({ onClose, onCreateEvent }) {
|
||||
const [activeTab, setActiveTab] = useState('upcoming');
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
const formatDate = (date) => {
|
||||
const now = new Date();
|
||||
const diff = date - now;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
if (hours === 0) {
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
return `In ${minutes} minutes`;
|
||||
}
|
||||
return `In ${hours} hours`;
|
||||
} else if (days === 1) {
|
||||
return 'Tomorrow';
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const getTypeIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'stage': return '📢';
|
||||
case 'voice': return '🔊';
|
||||
case 'external': return '🔗';
|
||||
default: return '📅';
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedEvent) {
|
||||
return (
|
||||
<EventDetail
|
||||
event={selectedEvent}
|
||||
onBack={() => setSelectedEvent(null)}
|
||||
formatDate={formatDate}
|
||||
formatTime={formatTime}
|
||||
getTypeIcon={getTypeIcon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="events-panel">
|
||||
<div className="events-header">
|
||||
<h2>Events</h2>
|
||||
<div className="events-actions">
|
||||
<button className="create-event-btn" onClick={onCreateEvent}>
|
||||
+ Create Event
|
||||
</button>
|
||||
<button className="events-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="events-tabs">
|
||||
<button
|
||||
className={`events-tab ${activeTab === 'upcoming' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('upcoming')}
|
||||
>
|
||||
Upcoming
|
||||
</button>
|
||||
<button
|
||||
className={`events-tab ${activeTab === 'recurring' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('recurring')}
|
||||
>
|
||||
Recurring
|
||||
</button>
|
||||
<button
|
||||
className={`events-tab ${activeTab === 'past' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('past')}
|
||||
>
|
||||
Past
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="events-list">
|
||||
{mockEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="event-card"
|
||||
onClick={() => setSelectedEvent(event)}
|
||||
>
|
||||
<div className="event-date-badge">
|
||||
<span className="event-month">
|
||||
{event.startTime.toLocaleDateString('en-US', { month: 'short' })}
|
||||
</span>
|
||||
<span className="event-day">
|
||||
{event.startTime.getDate()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="event-info">
|
||||
<div className="event-timing">
|
||||
<span className="event-time-label">{formatDate(event.startTime)}</span>
|
||||
<span className="event-time">
|
||||
{formatTime(event.startTime)} - {formatTime(event.endTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="event-title">
|
||||
<span className="event-type-icon">{getTypeIcon(event.type)}</span>
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
<p className="event-description">{event.description}</p>
|
||||
|
||||
<div className="event-meta">
|
||||
<div className="event-host">
|
||||
<div
|
||||
className="host-avatar"
|
||||
style={event.host.color ? { background: event.host.color } : undefined}
|
||||
>
|
||||
{event.host.avatar}
|
||||
</div>
|
||||
<span>Hosted by {event.host.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="event-interested">
|
||||
<span className="interested-count">⭐ {event.interested} interested</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.recurring && (
|
||||
<div className="recurring-badge">
|
||||
🔄 Repeats {event.recurring}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mockEvents.length === 0 && (
|
||||
<div className="events-empty">
|
||||
<span className="empty-icon">📅</span>
|
||||
<h3>No upcoming events</h3>
|
||||
<p>Create an event to bring your community together!</p>
|
||||
<button className="create-event-btn" onClick={onCreateEvent}>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetail({ event, onBack, formatDate, formatTime, getTypeIcon }) {
|
||||
const [isInterested, setIsInterested] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="event-detail">
|
||||
<button className="back-btn" onClick={onBack}>
|
||||
← Back to Events
|
||||
</button>
|
||||
|
||||
<div className="event-detail-header">
|
||||
<div className="event-type-badge">
|
||||
{getTypeIcon(event.type)} {event.type.charAt(0).toUpperCase() + event.type.slice(1)}
|
||||
</div>
|
||||
<h1>{event.title}</h1>
|
||||
</div>
|
||||
|
||||
<div className="event-detail-content">
|
||||
<div className="detail-section">
|
||||
<h4>📅 Date & Time</h4>
|
||||
<p>{formatDate(event.startTime)}</p>
|
||||
<p className="time-range">
|
||||
{formatTime(event.startTime)} - {formatTime(event.endTime)}
|
||||
</p>
|
||||
{event.recurring && (
|
||||
<p className="recurring-info">🔄 Repeats {event.recurring}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>📍 Location</h4>
|
||||
{event.type === 'external' ? (
|
||||
<a href={event.location} className="external-link">
|
||||
{event.location}
|
||||
</a>
|
||||
) : (
|
||||
<p>#{event.channel}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>📝 Description</h4>
|
||||
<p>{event.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="detail-section">
|
||||
<h4>👤 Host</h4>
|
||||
<div className="host-info">
|
||||
<div
|
||||
className="host-avatar large"
|
||||
style={event.host.color ? { background: event.host.color } : undefined}
|
||||
>
|
||||
{event.host.avatar}
|
||||
</div>
|
||||
<span>{event.host.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="interested-section">
|
||||
<span>⭐ {event.interested + (isInterested ? 1 : 0)} interested</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="event-detail-actions">
|
||||
<button
|
||||
className={`interested-btn ${isInterested ? 'active' : ''}`}
|
||||
onClick={() => setIsInterested(!isInterested)}
|
||||
>
|
||||
{isInterested ? '⭐ Interested' : '☆ Mark as Interested'}
|
||||
</button>
|
||||
<button className="share-event-btn">
|
||||
📤 Share
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Event creation modal
|
||||
export function CreateEventModal({ onSubmit, onClose }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState('voice');
|
||||
const [date, setDate] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!title.trim() || !date || !startTime) return;
|
||||
|
||||
onSubmit?.({
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="create-event-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create Event</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">
|
||||
<div className="form-group">
|
||||
<label>Event Type</label>
|
||||
<div className="event-type-options">
|
||||
<button
|
||||
className={`type-option ${type === 'stage' ? 'active' : ''}`}
|
||||
onClick={() => setType('stage')}
|
||||
>
|
||||
📢 Stage
|
||||
</button>
|
||||
<button
|
||||
className={`type-option ${type === 'voice' ? 'active' : ''}`}
|
||||
onClick={() => setType('voice')}
|
||||
>
|
||||
🔊 Voice
|
||||
</button>
|
||||
<button
|
||||
className={`type-option ${type === 'external' ? 'active' : ''}`}
|
||||
onClick={() => setType('external')}
|
||||
>
|
||||
🔗 External
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Event Title</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What's your event called?"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
placeholder="Tell people what this event is about..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Start Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>End Time</label>
|
||||
<input
|
||||
type="time"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={!title.trim() || !date || !startTime}
|
||||
>
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/frontend/mockup/FileUpload.jsx
Normal file
180
src/frontend/mockup/FileUpload.jsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import React, { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export default function FileUpload({ onUpload, onClose, maxSize = 25 }) {
|
||||
const [files, setFiles] = useState([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [comment, setComment] = useState('');
|
||||
const [isSpoiler, setIsSpoiler] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const handleDragOver = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
addFiles(droppedFiles);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const selectedFiles = Array.from(e.target.files);
|
||||
addFiles(selectedFiles);
|
||||
};
|
||||
|
||||
const addFiles = (newFiles) => {
|
||||
const processedFiles = newFiles.map((file) => ({
|
||||
file,
|
||||
id: Date.now() + Math.random(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
||||
progress: 100, // Simulated upload progress
|
||||
}));
|
||||
|
||||
setFiles((prev) => [...prev, ...processedFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (id) => {
|
||||
setFiles((prev) => {
|
||||
const removed = prev.find((f) => f.id === id);
|
||||
if (removed?.preview) {
|
||||
URL.revokeObjectURL(removed.preview);
|
||||
}
|
||||
return prev.filter((f) => f.id !== id);
|
||||
});
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
if (type.startsWith('image/')) return '🖼️';
|
||||
if (type.startsWith('video/')) return '🎬';
|
||||
if (type.startsWith('audio/')) return '🎵';
|
||||
if (type.includes('pdf')) return '📄';
|
||||
if (type.includes('zip') || type.includes('rar')) return '📦';
|
||||
if (type.includes('text') || type.includes('document')) return '📝';
|
||||
return '📎';
|
||||
};
|
||||
|
||||
const handleUpload = () => {
|
||||
onUpload?.({
|
||||
files,
|
||||
comment,
|
||||
isSpoiler,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const totalSize = files.reduce((acc, f) => acc + f.size, 0);
|
||||
const isOverLimit = totalSize > maxSize * 1024 * 1024;
|
||||
|
||||
return (
|
||||
<div className="file-upload-modal">
|
||||
<div className="file-upload-header">
|
||||
<h3>Upload to #general</h3>
|
||||
<button className="upload-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`file-drop-zone ${isDragging ? 'dragging' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="drop-zone-content">
|
||||
<span className="drop-icon">📁</span>
|
||||
<p>Drag & drop files here or click to browse</p>
|
||||
<span className="drop-hint">Max file size: {maxSize} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="file-list">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="file-item">
|
||||
{file.preview ? (
|
||||
<img src={file.preview} alt={file.name} className="file-preview" />
|
||||
) : (
|
||||
<div className="file-icon">{getFileIcon(file.type)}</div>
|
||||
)}
|
||||
<div className="file-info">
|
||||
<div className="file-name">{file.name}</div>
|
||||
<div className="file-size">{formatSize(file.size)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="file-remove"
|
||||
onClick={() => removeFile(file.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="upload-options">
|
||||
<label className="spoiler-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSpoiler}
|
||||
onChange={(e) => setIsSpoiler(e.target.checked)}
|
||||
/>
|
||||
<span>Mark as spoiler</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="upload-comment">
|
||||
<textarea
|
||||
placeholder="Add a comment (optional)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOverLimit && (
|
||||
<div className="upload-warning">
|
||||
Total size exceeds {maxSize} MB limit. Some files may not upload.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="upload-footer">
|
||||
<div className="upload-summary">
|
||||
{files.length} file{files.length !== 1 ? 's' : ''} • {formatSize(totalSize)}
|
||||
</div>
|
||||
<div className="upload-actions">
|
||||
<button className="upload-cancel" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="upload-submit"
|
||||
disabled={files.length === 0}
|
||||
onClick={handleUpload}
|
||||
>
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
src/frontend/mockup/ForumChannel.jsx
Normal file
293
src/frontend/mockup/ForumChannel.jsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockPosts = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'How to set up AeThex Passport for your game?',
|
||||
author: { name: 'Trevor', avatar: 'T', color: '#ff0000' },
|
||||
content: 'I want to integrate Passport authentication into my Unity game. What are the steps?',
|
||||
tags: ['question', 'passport', 'unity'],
|
||||
replies: 12,
|
||||
reactions: { '👍': 24, '❤️': 5 },
|
||||
pinned: true,
|
||||
createdAt: '2 hours ago',
|
||||
lastActivity: '15 minutes ago',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '[Tutorial] Complete Nexus SDK Integration Guide',
|
||||
author: { name: 'Sarah', avatar: 'S', color: '#ffa500' },
|
||||
content: 'Step-by-step guide for integrating the Nexus SDK into your application...',
|
||||
tags: ['tutorial', 'nexus', 'sdk'],
|
||||
replies: 45,
|
||||
reactions: { '🔥': 89, '👍': 67, '❤️': 23 },
|
||||
pinned: true,
|
||||
createdAt: '1 day ago',
|
||||
lastActivity: '30 minutes ago',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Bug: Voice chat not working on Linux',
|
||||
author: { name: 'DevUser_456', avatar: 'D' },
|
||||
content: 'Getting an error when trying to join voice channels on Ubuntu 22.04...',
|
||||
tags: ['bug', 'linux', 'voice'],
|
||||
replies: 8,
|
||||
reactions: { '👍': 12 },
|
||||
pinned: false,
|
||||
createdAt: '5 hours ago',
|
||||
lastActivity: '1 hour ago',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Show off your AeThex-powered projects!',
|
||||
author: { name: 'Anderson', avatar: 'A', color: '#5865f2' },
|
||||
content: 'Share what you\'ve built with AeThex tools and get feedback from the community.',
|
||||
tags: ['showcase', 'community'],
|
||||
replies: 156,
|
||||
reactions: { '🚀': 234, '❤️': 145, '👏': 89 },
|
||||
pinned: false,
|
||||
createdAt: '3 days ago',
|
||||
lastActivity: '10 minutes ago',
|
||||
},
|
||||
];
|
||||
|
||||
const tags = [
|
||||
{ id: 'all', label: 'All Posts', color: '#5865f2' },
|
||||
{ id: 'question', label: 'Question', color: '#3ba55d' },
|
||||
{ id: 'tutorial', label: 'Tutorial', color: '#faa61a' },
|
||||
{ id: 'bug', label: 'Bug Report', color: '#ed4245' },
|
||||
{ id: 'showcase', label: 'Showcase', color: '#9b59b6' },
|
||||
{ id: 'discussion', label: 'Discussion', color: '#747f8d' },
|
||||
];
|
||||
|
||||
export default function ForumChannel({ channel, onClose }) {
|
||||
const [activeTag, setActiveTag] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('activity');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedPost, setSelectedPost] = useState(null);
|
||||
|
||||
const channelData = channel || {
|
||||
name: 'help-forum',
|
||||
description: 'Get help with AeThex products and services',
|
||||
guidelines: 'Be respectful, search before posting, use appropriate tags.',
|
||||
};
|
||||
|
||||
const filteredPosts = mockPosts.filter(post => {
|
||||
const matchesTag = activeTag === 'all' || post.tags.includes(activeTag);
|
||||
const matchesSearch = !search ||
|
||||
post.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
post.content.toLowerCase().includes(search.toLowerCase());
|
||||
return matchesTag && matchesSearch;
|
||||
});
|
||||
|
||||
const sortedPosts = [...filteredPosts].sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (selectedPost) {
|
||||
return (
|
||||
<ForumPost
|
||||
post={selectedPost}
|
||||
onBack={() => setSelectedPost(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="forum-channel">
|
||||
<div className="forum-header">
|
||||
<div className="forum-title">
|
||||
<span className="forum-icon">💬</span>
|
||||
<h2>{channelData.name}</h2>
|
||||
</div>
|
||||
<p className="forum-description">{channelData.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="forum-toolbar">
|
||||
<div className="forum-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search posts..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="forum-filters">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="sort-select"
|
||||
>
|
||||
<option value="activity">Recent Activity</option>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="new-post-btn">
|
||||
+ New Post
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="forum-tags">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
className={`tag-btn ${activeTag === tag.id ? 'active' : ''}`}
|
||||
style={activeTag === tag.id ? { background: tag.color } : undefined}
|
||||
onClick={() => setActiveTag(tag.id)}
|
||||
>
|
||||
{tag.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="forum-posts">
|
||||
{sortedPosts.map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className={`forum-post-card ${post.pinned ? 'pinned' : ''}`}
|
||||
onClick={() => setSelectedPost(post)}
|
||||
>
|
||||
{post.pinned && <span className="pinned-badge">📌 Pinned</span>}
|
||||
<div className="post-main">
|
||||
<div
|
||||
className="post-avatar"
|
||||
style={post.author.color ? { background: post.author.color } : undefined}
|
||||
>
|
||||
{post.author.avatar}
|
||||
</div>
|
||||
<div className="post-content">
|
||||
<h3 className="post-title">{post.title}</h3>
|
||||
<p className="post-preview">{post.content}</p>
|
||||
<div className="post-tags">
|
||||
{post.tags.map(tagId => {
|
||||
const tag = tags.find(t => t.id === tagId);
|
||||
return tag ? (
|
||||
<span
|
||||
key={tagId}
|
||||
className="post-tag"
|
||||
style={{ background: tag.color }}
|
||||
>
|
||||
{tag.label}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
<div className="post-stats">
|
||||
<span className="reply-count">💬 {post.replies}</span>
|
||||
{Object.entries(post.reactions).map(([emoji, count]) => (
|
||||
<span key={emoji} className="reaction-count">{emoji} {count}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="post-time">
|
||||
<span className="author-name">{post.author.name}</span>
|
||||
<span>•</span>
|
||||
<span>Last activity {post.lastActivity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredPosts.length === 0 && (
|
||||
<div className="forum-empty">
|
||||
<span className="empty-icon">📭</span>
|
||||
<p>No posts found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForumPost({ post, onBack }) {
|
||||
const [replyContent, setReplyContent] = useState('');
|
||||
|
||||
const replies = [
|
||||
{ id: 1, author: { name: 'Marcus', avatar: 'M' }, content: 'Have you checked the documentation? There\'s a great guide at docs.aethex.com/passport', time: '1 hour ago', reactions: { '👍': 5 } },
|
||||
{ id: 2, author: { name: 'Sarah', avatar: 'S', color: '#ffa500' }, content: 'I wrote a tutorial for this! Check out post #2 in this forum.', time: '45 minutes ago', reactions: { '❤️': 3 } },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="forum-post-detail">
|
||||
<button className="back-btn" onClick={onBack}>
|
||||
← Back to posts
|
||||
</button>
|
||||
|
||||
<div className="post-header">
|
||||
<h1>{post.title}</h1>
|
||||
<div className="post-author-info">
|
||||
<div
|
||||
className="author-avatar"
|
||||
style={post.author.color ? { background: post.author.color } : undefined}
|
||||
>
|
||||
{post.author.avatar}
|
||||
</div>
|
||||
<span className="author-name">{post.author.name}</span>
|
||||
<span className="post-date">Posted {post.createdAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="post-body">
|
||||
<p>{post.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="post-reactions">
|
||||
{Object.entries(post.reactions).map(([emoji, count]) => (
|
||||
<button key={emoji} className="reaction-btn">
|
||||
{emoji} {count}
|
||||
</button>
|
||||
))}
|
||||
<button className="reaction-btn add">+</button>
|
||||
</div>
|
||||
|
||||
<div className="post-replies">
|
||||
<h3>Replies ({replies.length})</h3>
|
||||
{replies.map(reply => (
|
||||
<div key={reply.id} className="reply-card">
|
||||
<div
|
||||
className="reply-avatar"
|
||||
style={reply.author.color ? { background: reply.author.color } : undefined}
|
||||
>
|
||||
{reply.author.avatar}
|
||||
</div>
|
||||
<div className="reply-content">
|
||||
<div className="reply-header">
|
||||
<span className="reply-author">{reply.author.name}</span>
|
||||
<span className="reply-time">{reply.time}</span>
|
||||
</div>
|
||||
<p>{reply.content}</p>
|
||||
<div className="reply-actions">
|
||||
{Object.entries(reply.reactions).map(([emoji, count]) => (
|
||||
<button key={emoji} className="reaction-btn small">
|
||||
{emoji} {count}
|
||||
</button>
|
||||
))}
|
||||
<button className="reply-btn">Reply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="reply-input">
|
||||
<textarea
|
||||
placeholder="Write a reply..."
|
||||
value={replyContent}
|
||||
onChange={(e) => setReplyContent(e.target.value)}
|
||||
/>
|
||||
<button className="send-reply-btn" disabled={!replyContent.trim()}>
|
||||
Send Reply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/frontend/mockup/GifPicker.jsx
Normal file
107
src/frontend/mockup/GifPicker.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const categories = [
|
||||
{ id: 'trending', label: 'Trending', icon: '🔥' },
|
||||
{ id: 'reactions', label: 'Reactions', icon: '😂' },
|
||||
{ id: 'actions', label: 'Actions', icon: '👋' },
|
||||
{ id: 'anime', label: 'Anime', icon: '🎌' },
|
||||
{ id: 'memes', label: 'Memes', icon: '🐸' },
|
||||
{ id: 'cats', label: 'Cats', icon: '🐱' },
|
||||
{ id: 'dogs', label: 'Dogs', icon: '🐕' },
|
||||
{ id: 'gaming', label: 'Gaming', icon: '🎮' },
|
||||
];
|
||||
|
||||
const mockGifs = [
|
||||
{ id: 1, url: '#', preview: '👍', title: 'Thumbs Up' },
|
||||
{ id: 2, url: '#', preview: '😂', title: 'Laughing' },
|
||||
{ id: 3, url: '#', preview: '🎉', title: 'Celebration' },
|
||||
{ id: 4, url: '#', preview: '❤️', title: 'Heart' },
|
||||
{ id: 5, url: '#', preview: '🔥', title: 'Fire' },
|
||||
{ id: 6, url: '#', preview: '👀', title: 'Eyes' },
|
||||
{ id: 7, url: '#', preview: '💀', title: 'Skull' },
|
||||
{ id: 8, url: '#', preview: '🙌', title: 'Raised Hands' },
|
||||
{ id: 9, url: '#', preview: '😭', title: 'Crying' },
|
||||
{ id: 10, url: '#', preview: '🤔', title: 'Thinking' },
|
||||
{ id: 11, url: '#', preview: '👏', title: 'Clapping' },
|
||||
{ id: 12, url: '#', preview: '😎', title: 'Cool' },
|
||||
];
|
||||
|
||||
export default function GifPicker({ onSelect, onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('trending');
|
||||
const [favorites, setFavorites] = useState([1, 5]);
|
||||
|
||||
const filteredGifs = search
|
||||
? mockGifs.filter(g => g.title.toLowerCase().includes(search.toLowerCase()))
|
||||
: mockGifs;
|
||||
|
||||
return (
|
||||
<div className="gif-picker">
|
||||
<div className="gif-picker-header">
|
||||
<div className="gif-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search Tenor"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button className="clear-search" onClick={() => setSearch('')}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!search && (
|
||||
<div className="gif-categories">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`gif-category ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
title={cat.label}
|
||||
>
|
||||
<span className="cat-icon">{cat.icon}</span>
|
||||
<span className="cat-label">{cat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="gif-tabs">
|
||||
<button className="gif-tab active">GIFs</button>
|
||||
<button className="gif-tab">Favorites</button>
|
||||
</div>
|
||||
|
||||
<div className="gif-grid">
|
||||
{filteredGifs.map(gif => (
|
||||
<div
|
||||
key={gif.id}
|
||||
className="gif-item"
|
||||
onClick={() => onSelect?.(gif)}
|
||||
title={gif.title}
|
||||
>
|
||||
<div className="gif-preview">{gif.preview}</div>
|
||||
<button
|
||||
className={`gif-favorite ${favorites.includes(gif.id) ? 'active' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setFavorites(prev =>
|
||||
prev.includes(gif.id)
|
||||
? prev.filter(id => id !== gif.id)
|
||||
: [...prev, gif.id]
|
||||
);
|
||||
}}
|
||||
>
|
||||
{favorites.includes(gif.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="gif-footer">
|
||||
<span className="powered-by">Powered by Tenor</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/frontend/mockup/InviteManager.jsx
Normal file
185
src/frontend/mockup/InviteManager.jsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockInvites = [
|
||||
{ code: 'aethex123', channel: 'general', uses: 45, maxUses: 0, expiresAt: null, createdBy: 'Trevor', createdAt: '2025-01-10T10:00:00' },
|
||||
{ code: 'devjoin', channel: 'api-discussion', uses: 12, maxUses: 50, expiresAt: '2026-02-15T00:00:00', createdBy: 'Sarah', createdAt: '2025-02-01T14:30:00' },
|
||||
{ code: 'labsonly', channel: 'labs-chat', uses: 8, maxUses: 25, expiresAt: '2026-03-01T00:00:00', createdBy: 'DevUser_2847', createdAt: '2025-02-03T09:15:00' },
|
||||
{ code: 'temp7day', channel: 'help', uses: 3, maxUses: 10, expiresAt: '2026-02-12T00:00:00', createdBy: 'Trevor', createdAt: '2025-02-05T16:45:00' },
|
||||
];
|
||||
|
||||
export default function InviteManager({ onClose, onCreate }) {
|
||||
const [invites, setInvites] = useState(mockInvites);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [copiedCode, setCopiedCode] = useState(null);
|
||||
|
||||
// Create invite form state
|
||||
const [selectedChannel, setSelectedChannel] = useState('general');
|
||||
const [maxUses, setMaxUses] = useState(0);
|
||||
const [expiresIn, setExpiresIn] = useState('never');
|
||||
|
||||
const channels = ['general', 'api-discussion', 'help', 'announcements', 'labs-chat'];
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Never';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = date.getTime() - now.getTime();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
if (days < 0) return 'Expired';
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Tomorrow';
|
||||
return `${days} days`;
|
||||
};
|
||||
|
||||
const copyInvite = (code) => {
|
||||
navigator.clipboard.writeText(`https://aethex.gg/invite/${code}`);
|
||||
setCopiedCode(code);
|
||||
setTimeout(() => setCopiedCode(null), 2000);
|
||||
};
|
||||
|
||||
const deleteInvite = (code) => {
|
||||
setInvites(prev => prev.filter(inv => inv.code !== code));
|
||||
};
|
||||
|
||||
const createInvite = () => {
|
||||
const newCode = Math.random().toString(36).substring(2, 10);
|
||||
const expiresAt = expiresIn === 'never' ? null :
|
||||
new Date(Date.now() + parseInt(expiresIn) * 1000).toISOString();
|
||||
|
||||
setInvites(prev => [{
|
||||
code: newCode,
|
||||
channel: selectedChannel,
|
||||
uses: 0,
|
||||
maxUses: maxUses,
|
||||
expiresAt,
|
||||
createdBy: 'You',
|
||||
createdAt: new Date().toISOString(),
|
||||
}, ...prev]);
|
||||
|
||||
setShowCreateModal(false);
|
||||
copyInvite(newCode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="invite-manager-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="invite-manager-header">
|
||||
<h2>✉️ Server Invites</h2>
|
||||
<button className="invite-manager-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="invite-manager-actions">
|
||||
<button className="create-invite-btn" onClick={() => setShowCreateModal(true)}>
|
||||
+ Create Invite
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="invite-list">
|
||||
{invites.length === 0 ? (
|
||||
<div className="no-invites">
|
||||
<p>No active invites</p>
|
||||
<span>Create an invite to share your server</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="invite-list-header">
|
||||
<span className="col-code">Invite Code</span>
|
||||
<span className="col-channel">Channel</span>
|
||||
<span className="col-uses">Uses</span>
|
||||
<span className="col-expires">Expires</span>
|
||||
<span className="col-creator">Created By</span>
|
||||
<span className="col-actions"></span>
|
||||
</div>
|
||||
{invites.map(invite => (
|
||||
<div key={invite.code} className="invite-item">
|
||||
<div className="invite-code">
|
||||
<code>{invite.code}</code>
|
||||
<button
|
||||
className={`copy-btn ${copiedCode === invite.code ? 'copied' : ''}`}
|
||||
onClick={() => copyInvite(invite.code)}
|
||||
>
|
||||
{copiedCode === invite.code ? '✓' : '📋'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="invite-channel">#{invite.channel}</div>
|
||||
<div className="invite-uses">
|
||||
{invite.uses}{invite.maxUses > 0 ? ` / ${invite.maxUses}` : ''}
|
||||
</div>
|
||||
<div className="invite-expires">{formatDate(invite.expiresAt)}</div>
|
||||
<div className="invite-creator">{invite.createdBy}</div>
|
||||
<div className="invite-actions">
|
||||
<button
|
||||
className="delete-invite-btn"
|
||||
onClick={() => deleteInvite(invite.code)}
|
||||
title="Delete invite"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="invite-manager-footer">
|
||||
<span className="invite-hint">
|
||||
Invite links: aethex.gg/invite/CODE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div className="create-invite-overlay" onClick={() => setShowCreateModal(false)}>
|
||||
<div className="create-invite-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>Create Invite Link</h3>
|
||||
|
||||
<div className="create-field">
|
||||
<label>Invite to Channel</label>
|
||||
<select value={selectedChannel} onChange={(e) => setSelectedChannel(e.target.value)}>
|
||||
{channels.map(ch => (
|
||||
<option key={ch} value={ch}>#{ch}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="create-field">
|
||||
<label>Expire After</label>
|
||||
<select value={expiresIn} onChange={(e) => setExpiresIn(e.target.value)}>
|
||||
<option value="1800">30 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">1 day</option>
|
||||
<option value="604800">7 days</option>
|
||||
<option value="never">Never</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="create-field">
|
||||
<label>Max Uses</label>
|
||||
<select value={maxUses} onChange={(e) => setMaxUses(parseInt(e.target.value))}>
|
||||
<option value="0">No limit</option>
|
||||
<option value="1">1 use</option>
|
||||
<option value="5">5 uses</option>
|
||||
<option value="10">10 uses</option>
|
||||
<option value="25">25 uses</option>
|
||||
<option value="50">50 uses</option>
|
||||
<option value="100">100 uses</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="create-actions">
|
||||
<button className="cancel-btn" onClick={() => setShowCreateModal(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="generate-btn" onClick={createInvite}>
|
||||
Generate Invite
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/frontend/mockup/InviteModal.jsx
Normal file
146
src/frontend/mockup/InviteModal.jsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function InviteModal({ server, channel, onClose }) {
|
||||
const [inviteLink, setInviteLink] = useState('https://aethex.gg/invite/Xk9Q3p5');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expiresIn, setExpiresIn] = useState('7');
|
||||
const [maxUses, setMaxUses] = useState('0');
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const serverName = server?.name || 'AeThex Foundation';
|
||||
const channelName = channel?.name || 'general';
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const generateNewLink = () => {
|
||||
// Simulate generating a new invite code
|
||||
const code = Math.random().toString(36).substring(2, 8);
|
||||
setInviteLink(`https://aethex.gg/invite/${code}`);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const friends = [
|
||||
{ id: 1, name: 'Sarah', avatar: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)', status: 'online' },
|
||||
{ id: 2, name: 'Marcus', avatar: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)', status: 'online' },
|
||||
{ id: 3, name: 'JohnDev', avatar: 'J', status: 'idle' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="invite-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="invite-header">
|
||||
<h2>Invite friends to {serverName}</h2>
|
||||
<span className="invite-channel">#{channelName}</span>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="invite-content">
|
||||
<div className="invite-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for friends"
|
||||
className="friend-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="invite-friends-list">
|
||||
{friends.map((friend) => (
|
||||
<div key={friend.id} className="invite-friend-item">
|
||||
<div
|
||||
className="invite-friend-avatar"
|
||||
style={{ background: friend.gradient || '#36393f' }}
|
||||
>
|
||||
{friend.avatar}
|
||||
</div>
|
||||
<span className="invite-friend-name">{friend.name}</span>
|
||||
<button className="invite-btn">Invite</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="invite-link-section">
|
||||
<label>OR, SEND A SERVER INVITE LINK TO A FRIEND</label>
|
||||
<div className="invite-link-row">
|
||||
<input
|
||||
type="text"
|
||||
value={inviteLink}
|
||||
readOnly
|
||||
className="invite-link-input"
|
||||
/>
|
||||
<button
|
||||
className={`copy-link-btn ${copied ? 'copied' : ''}`}
|
||||
onClick={copyLink}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="link-expires">
|
||||
Your invite link expires in 7 days.
|
||||
<button className="edit-link-btn" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
Edit invite link
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="invite-advanced">
|
||||
<div className="advanced-option">
|
||||
<label>EXPIRE AFTER</label>
|
||||
<select
|
||||
value={expiresIn}
|
||||
onChange={(e) => setExpiresIn(e.target.value)}
|
||||
className="advanced-select"
|
||||
>
|
||||
<option value="1800">30 minutes</option>
|
||||
<option value="3600">1 hour</option>
|
||||
<option value="21600">6 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
<option value="86400">1 day</option>
|
||||
<option value="7">7 days</option>
|
||||
<option value="0">Never</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="advanced-option">
|
||||
<label>MAX NUMBER OF USES</label>
|
||||
<select
|
||||
value={maxUses}
|
||||
onChange={(e) => setMaxUses(e.target.value)}
|
||||
className="advanced-select"
|
||||
>
|
||||
<option value="0">No limit</option>
|
||||
<option value="1">1 use</option>
|
||||
<option value="5">5 uses</option>
|
||||
<option value="10">10 uses</option>
|
||||
<option value="25">25 uses</option>
|
||||
<option value="50">50 uses</option>
|
||||
<option value="100">100 uses</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="temporary-toggle">
|
||||
<input type="checkbox" />
|
||||
<span>Grant temporary membership</span>
|
||||
</label>
|
||||
|
||||
<button className="generate-new-btn" onClick={generateNewLink}>
|
||||
Generate a New Link
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer">
|
||||
<div className="invite-tip">
|
||||
<span className="tip-icon">💡</span>
|
||||
<span>Customize your invite link with Server Boost</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,17 +1,199 @@
|
|||
import React from "react";
|
||||
import ServerList from "./ServerList";
|
||||
import ChannelSidebar from "./ChannelSidebar";
|
||||
import ChatArea from "./ChatArea";
|
||||
import MemberSidebar from "./MemberSidebar";
|
||||
import "./global.css";
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import ServerList from './ServerList';
|
||||
import ChannelSidebar from './ChannelSidebar';
|
||||
import ChatArea from './ChatArea';
|
||||
import MemberSidebar from './MemberSidebar';
|
||||
import SearchPanel from './SearchPanel';
|
||||
import ThreadPanel from './ThreadPanel';
|
||||
import ServerSettingsModal from './ServerSettingsModal';
|
||||
import UserProfileCard from './UserProfileCard';
|
||||
import DMsView from './DMsView';
|
||||
import UserSettingsModal from './UserSettingsModal';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import PinnedMessagesPanel from './PinnedMessagesPanel';
|
||||
import CreateChannelModal from './CreateChannelModal';
|
||||
import InviteModal from './InviteModal';
|
||||
import NotificationsPanel from './NotificationsPanel';
|
||||
import VideoCall from './VideoCall';
|
||||
import './mockup.css';
|
||||
|
||||
export default function MainLayout() {
|
||||
// UI State
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [showThread, setShowThread] = useState(false);
|
||||
const [threadParent, setThreadParent] = useState(null);
|
||||
const [showServerSettings, setShowServerSettings] = useState(false);
|
||||
const [showUserProfile, setShowUserProfile] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
|
||||
// New feature states
|
||||
const [showDMs, setShowDMs] = useState(false);
|
||||
const [showUserSettings, setShowUserSettings] = useState(false);
|
||||
const [showPinnedMessages, setShowPinnedMessages] = useState(false);
|
||||
const [showCreateChannel, setShowCreateChannel] = useState(false);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
const [showVideoCall, setShowVideoCall] = useState(false);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState({ visible: false, x: 0, y: 0, type: 'message', data: null });
|
||||
|
||||
// Current server/channel context for modals
|
||||
const [currentServer, setCurrentServer] = useState({
|
||||
id: '1',
|
||||
name: 'AeThex Gaming',
|
||||
icon: '🎮'
|
||||
});
|
||||
|
||||
const handleOpenSearch = () => {
|
||||
setShowSearch(true);
|
||||
setShowThread(false);
|
||||
};
|
||||
|
||||
const handleOpenThread = (message) => {
|
||||
setThreadParent(message);
|
||||
setShowThread(true);
|
||||
setShowSearch(false);
|
||||
};
|
||||
|
||||
const handleOpenUserProfile = (user) => {
|
||||
setSelectedUser(user);
|
||||
setShowUserProfile(true);
|
||||
};
|
||||
|
||||
// Context menu handlers
|
||||
const handleContextMenu = useCallback((e, type, data) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
type,
|
||||
data
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu(prev => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
const handleContextMenuAction = useCallback((action) => {
|
||||
console.log('Context menu action:', action, contextMenu.data);
|
||||
closeContextMenu();
|
||||
// Handle actions
|
||||
if (action === 'pin') setShowPinnedMessages(true);
|
||||
if (action === 'create_thread') handleOpenThread(contextMenu.data);
|
||||
}, [contextMenu.data, closeContextMenu]);
|
||||
|
||||
// Video call handlers
|
||||
const handleJoinVoice = useCallback(() => {
|
||||
setShowVideoCall(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="connect-container flex h-screen">
|
||||
<ServerList />
|
||||
<ChannelSidebar />
|
||||
<ChatArea />
|
||||
<MemberSidebar />
|
||||
<div className="connect-container" onClick={closeContextMenu}>
|
||||
{showDMs ? (
|
||||
<>
|
||||
<ServerList
|
||||
onOpenSettings={() => setShowServerSettings(true)}
|
||||
onDMsClick={() => setShowDMs(true)}
|
||||
selectedDMs={true}
|
||||
/>
|
||||
<DMsView onClose={() => setShowDMs(false)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ServerList
|
||||
onOpenSettings={() => setShowServerSettings(true)}
|
||||
onDMsClick={() => setShowDMs(true)}
|
||||
/>
|
||||
<ChannelSidebar
|
||||
onCreateChannel={() => setShowCreateChannel(true)}
|
||||
onSettingsClick={() => setShowUserSettings(true)}
|
||||
onJoinVoice={handleJoinVoice}
|
||||
/>
|
||||
<ChatArea
|
||||
onOpenSearch={handleOpenSearch}
|
||||
onOpenThread={handleOpenThread}
|
||||
onPinnedClick={() => setShowPinnedMessages(true)}
|
||||
onNotificationsClick={() => setShowNotifications(true)}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
<MemberSidebar onMemberClick={handleOpenUserProfile} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Overlays */}
|
||||
{showSearch && (
|
||||
<SearchPanel onClose={() => setShowSearch(false)} />
|
||||
)}
|
||||
|
||||
{showThread && threadParent && (
|
||||
<ThreadPanel
|
||||
parentMessage={threadParent}
|
||||
onClose={() => setShowThread(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showServerSettings && (
|
||||
<ServerSettingsModal onClose={() => setShowServerSettings(false)} />
|
||||
)}
|
||||
|
||||
{showUserProfile && selectedUser && (
|
||||
<UserProfileCard
|
||||
user={selectedUser}
|
||||
onClose={() => setShowUserProfile(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Modals */}
|
||||
{showUserSettings && (
|
||||
<UserSettingsModal onClose={() => setShowUserSettings(false)} />
|
||||
)}
|
||||
|
||||
{showPinnedMessages && (
|
||||
<PinnedMessagesPanel onClose={() => setShowPinnedMessages(false)} />
|
||||
)}
|
||||
|
||||
{showCreateChannel && (
|
||||
<CreateChannelModal
|
||||
onClose={() => setShowCreateChannel(false)}
|
||||
onSubmit={(data) => {
|
||||
console.log('Create channel:', data);
|
||||
setShowCreateChannel(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showInvite && (
|
||||
<InviteModal
|
||||
server={currentServer}
|
||||
onClose={() => setShowInvite(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNotifications && (
|
||||
<NotificationsPanel onClose={() => setShowNotifications(false)} />
|
||||
)}
|
||||
|
||||
{showVideoCall && (
|
||||
<VideoCall
|
||||
channel={{ name: 'General Voice' }}
|
||||
onLeave={() => setShowVideoCall(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenu.visible && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
type={contextMenu.type}
|
||||
data={contextMenu.data}
|
||||
onAction={handleContextMenuAction}
|
||||
onClose={closeContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
139
src/frontend/mockup/MarkdownText.jsx
Normal file
139
src/frontend/mockup/MarkdownText.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React from 'react';
|
||||
|
||||
// Simple markdown-like parsing for Discord-style formatting
|
||||
export function parseMarkdown(text) {
|
||||
if (!text) return null;
|
||||
|
||||
const elements = [];
|
||||
let remaining = text;
|
||||
let key = 0;
|
||||
|
||||
const patterns = [
|
||||
// Code blocks (```)
|
||||
{ regex: /```(\w+)?\n?([\s\S]*?)```/g, render: (match, lang, code) => (
|
||||
<pre key={key++} className="code-block" data-language={lang}>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)},
|
||||
// Inline code (`)
|
||||
{ regex: /`([^`]+)`/g, render: (match, code) => (
|
||||
<code key={key++} className="inline-code">{code}</code>
|
||||
)},
|
||||
// Spoiler (||text||)
|
||||
{ regex: /\|\|([^|]+)\|\|/g, render: (match, content) => (
|
||||
<span key={key++} className="spoiler" onClick={(e) => e.target.classList.toggle('revealed')}>
|
||||
{content}
|
||||
</span>
|
||||
)},
|
||||
// Bold (**text**)
|
||||
{ regex: /\*\*([^*]+)\*\*/g, render: (match, content) => (
|
||||
<strong key={key++}>{content}</strong>
|
||||
)},
|
||||
// Italic (*text* or _text_)
|
||||
{ regex: /(?:\*|_)([^*_]+)(?:\*|_)/g, render: (match, content) => (
|
||||
<em key={key++}>{content}</em>
|
||||
)},
|
||||
// Strikethrough (~~text~~)
|
||||
{ regex: /~~([^~]+)~~/g, render: (match, content) => (
|
||||
<del key={key++}>{content}</del>
|
||||
)},
|
||||
// Underline (__text__)
|
||||
{ regex: /__([^_]+)__/g, render: (match, content) => (
|
||||
<u key={key++}>{content}</u>
|
||||
)},
|
||||
// Links [text](url)
|
||||
{ regex: /\[([^\]]+)\]\(([^)]+)\)/g, render: (match, text, url) => (
|
||||
<a key={key++} href={url} target="_blank" rel="noopener noreferrer" className="message-link">
|
||||
{text}
|
||||
</a>
|
||||
)},
|
||||
// Auto-link URLs
|
||||
{ regex: /(https?:\/\/[^\s<]+)/g, render: (match, url) => (
|
||||
<a key={key++} href={url} target="_blank" rel="noopener noreferrer" className="message-link">
|
||||
{url}
|
||||
</a>
|
||||
)},
|
||||
// Mentions (@user)
|
||||
{ regex: /@(\w+)/g, render: (match, user) => (
|
||||
<span key={key++} className="mention">@{user}</span>
|
||||
)},
|
||||
// Channel mentions (#channel)
|
||||
{ regex: /#(\w+)/g, render: (match, channel) => (
|
||||
<span key={key++} className="channel-mention">#{channel}</span>
|
||||
)},
|
||||
// Role mentions (@&role)
|
||||
{ regex: /@&(\w+)/g, render: (match, role) => (
|
||||
<span key={key++} className="role-mention">@{role}</span>
|
||||
)},
|
||||
// Emoji :emoji_name:
|
||||
{ regex: /:(\w+):/g, render: (match, name) => {
|
||||
const emojiMap = {
|
||||
smile: '😊', laughing: '😆', heart: '❤️', fire: '🔥',
|
||||
rocket: '🚀', thumbsup: '👍', thumbsdown: '👎', check: '✅',
|
||||
x: '❌', star: '⭐', eyes: '👀', thinking: '🤔',
|
||||
aethex: '🔺', foundation: '🔴', corporation: '🔵', labs: '🟠',
|
||||
};
|
||||
const emoji = emojiMap[name.toLowerCase()];
|
||||
return emoji ? <span key={key++} className="emoji">{emoji}</span> : match;
|
||||
}},
|
||||
// Block quote (> text)
|
||||
{ regex: /^>\s(.+)$/gm, render: (match, content) => (
|
||||
<blockquote key={key++} className="block-quote">{content}</blockquote>
|
||||
)},
|
||||
];
|
||||
|
||||
// Process special patterns
|
||||
// For simplicity, we'll do a basic render with some patterns
|
||||
const processedText = text
|
||||
.replace(/```(\w+)?\n?([\s\S]*?)```/g, '⟦CODE_BLOCK⟧$1⟦SEP⟧$2⟦END_CODE⟧')
|
||||
.replace(/`([^`]+)`/g, '⟦INLINE_CODE⟧$1⟦END_INLINE⟧')
|
||||
.replace(/\|\|([^|]+)\|\|/g, '⟦SPOILER⟧$1⟦END_SPOILER⟧')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '⟦BOLD⟧$1⟦END_BOLD⟧')
|
||||
.replace(/~~([^~]+)~~/g, '⟦STRIKE⟧$1⟦END_STRIKE⟧')
|
||||
.replace(/__([^_]+)__/g, '⟦UNDERLINE⟧$1⟦END_UNDERLINE⟧')
|
||||
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '⟦ITALIC⟧$1⟦END_ITALIC⟧')
|
||||
.replace(/(?<!_)_([^_]+)_(?!_)/g, '⟦ITALIC⟧$1⟦END_ITALIC⟧');
|
||||
|
||||
return <span dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }} />;
|
||||
}
|
||||
|
||||
function renderMarkdown(text) {
|
||||
let html = text
|
||||
// Escape HTML
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// Code blocks
|
||||
.replace(/```(\w+)?\n?([\s\S]*?)```/g, '<pre class="code-block"><code>$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
|
||||
// Spoilers
|
||||
.replace(/\|\|([^|]+)\|\|/g, '<span class="spoiler">$1</span>')
|
||||
// Bold
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
// Strikethrough
|
||||
.replace(/~~([^~]+)~~/g, '<del>$1</del>')
|
||||
// Underline
|
||||
.replace(/__([^_]+)__/g, '<u>$1</u>')
|
||||
// Italic
|
||||
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
||||
// Mentions
|
||||
.replace(/@(\w+)/g, '<span class="mention">@$1</span>')
|
||||
// Channel mentions
|
||||
.replace(/#(\w[-\w]*)/g, '<span class="channel-mention">#$1</span>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="message-link" target="_blank">$1</a>')
|
||||
// Auto URLs
|
||||
.replace(/(https?:\/\/[^\s<]+)/g, '<a href="$1" class="message-link" target="_blank">$1</a>')
|
||||
// Block quotes
|
||||
.replace(/^>\s(.+)$/gm, '<blockquote class="block-quote">$1</blockquote>')
|
||||
// Newlines
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function MarkdownText({ children }) {
|
||||
if (typeof children !== 'string') return children;
|
||||
return parseMarkdown(children);
|
||||
}
|
||||
155
src/frontend/mockup/MemberListPanel.jsx
Normal file
155
src/frontend/mockup/MemberListPanel.jsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockMembers = [
|
||||
{ id: 1, name: 'Trevor', tag: '#0001', avatar: 'T', status: 'online', roles: ['founder', 'foundation'], joinedAt: '2024-01-15' },
|
||||
{ id: 2, name: 'Sarah', tag: '#1234', avatar: 'S', status: 'online', roles: ['foundation', 'corporation'], joinedAt: '2024-02-20' },
|
||||
{ id: 3, name: 'DevUser_2847', tag: '#9876', avatar: 'D', status: 'idle', roles: ['labs'], joinedAt: '2024-03-10' },
|
||||
{ id: 4, name: 'Alex', tag: '#5555', avatar: 'A', status: 'dnd', roles: ['corporation'], joinedAt: '2024-04-05' },
|
||||
{ id: 5, name: 'Jordan', tag: '#7777', avatar: 'J', status: 'offline', roles: [], joinedAt: '2024-05-12' },
|
||||
{ id: 6, name: 'Casey', tag: '#3333', avatar: 'C', status: 'online', roles: ['labs', 'corporation'], joinedAt: '2024-06-01' },
|
||||
{ id: 7, name: 'Morgan', tag: '#2222', avatar: 'M', status: 'online', roles: ['foundation'], joinedAt: '2024-06-15' },
|
||||
{ id: 8, name: 'Riley', tag: '#4444', avatar: 'R', status: 'offline', roles: [], joinedAt: '2024-07-20' },
|
||||
];
|
||||
|
||||
const allRoles = [
|
||||
{ id: 'founder', name: 'Founder', color: '#ff0000' },
|
||||
{ id: 'foundation', name: 'Foundation', color: '#ff0000' },
|
||||
{ id: 'corporation', name: 'Corporation', color: '#0066ff' },
|
||||
{ id: 'labs', name: 'Labs', color: '#ffa500' },
|
||||
];
|
||||
|
||||
export default function MemberListPanel({ onClose }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterRole, setFilterRole] = useState('all');
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
const [showRoleMenu, setShowRoleMenu] = useState(false);
|
||||
|
||||
const filteredMembers = mockMembers.filter(member => {
|
||||
const matchesSearch = member.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
member.tag.includes(searchQuery);
|
||||
const matchesRole = filterRole === 'all' || member.roles.includes(filterRole);
|
||||
return matchesSearch && matchesRole;
|
||||
});
|
||||
|
||||
const getMemberRoles = (member) => {
|
||||
return allRoles.filter(role => member.roles.includes(role.id));
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colors = { online: '#3ba55d', idle: '#faa61a', dnd: '#ed4245', offline: '#747f8d' };
|
||||
return colors[status] || colors.offline;
|
||||
};
|
||||
|
||||
const toggleMemberRole = (memberId, roleId) => {
|
||||
// Mock implementation - in real app would update backend
|
||||
console.log(`Toggle role ${roleId} for member ${memberId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="member-list-panel">
|
||||
<div className="member-list-header">
|
||||
<h2>Members — {mockMembers.length}</h2>
|
||||
<button className="member-list-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="member-list-filters">
|
||||
<input
|
||||
type="text"
|
||||
className="member-search"
|
||||
placeholder="Search members..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="role-filter"
|
||||
value={filterRole}
|
||||
onChange={(e) => setFilterRole(e.target.value)}
|
||||
>
|
||||
<option value="all">All Roles</option>
|
||||
{allRoles.map(role => (
|
||||
<option key={role.id} value={role.id}>{role.name}</option>
|
||||
))}
|
||||
<option value="">No Roles</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="member-list-content">
|
||||
{filteredMembers.length === 0 ? (
|
||||
<div className="no-members">No members found</div>
|
||||
) : (
|
||||
filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`member-list-item ${selectedMember === member.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedMember(member.id)}
|
||||
>
|
||||
<div className="member-avatar-wrapper">
|
||||
<div className="member-avatar">{member.avatar}</div>
|
||||
<div
|
||||
className="member-status-dot"
|
||||
style={{ background: getStatusColor(member.status) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="member-info">
|
||||
<div className="member-name-row">
|
||||
<span className="member-name">{member.name}</span>
|
||||
<span className="member-tag">{member.tag}</span>
|
||||
</div>
|
||||
<div className="member-roles">
|
||||
{getMemberRoles(member).map(role => (
|
||||
<span
|
||||
key={role.id}
|
||||
className="member-role-badge"
|
||||
style={{ borderColor: role.color, color: role.color }}
|
||||
>
|
||||
{role.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="member-actions">
|
||||
<button
|
||||
className="member-action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedMember(member.id);
|
||||
setShowRoleMenu(!showRoleMenu);
|
||||
}}
|
||||
title="Manage Roles"
|
||||
>
|
||||
🎭
|
||||
</button>
|
||||
<button className="member-action-btn" title="Message">💬</button>
|
||||
<button className="member-action-btn" title="More">⋯</button>
|
||||
</div>
|
||||
|
||||
{selectedMember === member.id && showRoleMenu && (
|
||||
<div className="role-assignment-menu" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="role-menu-header">Manage Roles</div>
|
||||
{allRoles.map(role => (
|
||||
<label key={role.id} className="role-menu-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={member.roles.includes(role.id)}
|
||||
onChange={() => toggleMemberRole(member.id, role.id)}
|
||||
/>
|
||||
<span className="role-dot" style={{ background: role.color }}></span>
|
||||
<span>{role.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="member-list-footer">
|
||||
<button className="prune-members-btn">Prune Members</button>
|
||||
<button className="invite-members-btn">Invite People</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +1,67 @@
|
|||
import React from "react";
|
||||
import React from 'react';
|
||||
|
||||
const members = [
|
||||
{ section: "Foundation Team — 8", users: [
|
||||
{ name: "Anderson", avatar: "A", status: "online", avatarBg: "from-red-600 to-red-800" },
|
||||
{ name: "Trevor", avatar: "T", status: "online", avatarBg: "from-red-600 to-red-800" },
|
||||
]},
|
||||
{ section: "Labs Team — 12", users: [
|
||||
{ name: "Sarah", avatar: "S", status: "labs", avatarBg: "from-orange-400 to-orange-700", activity: "Testing v2.0" },
|
||||
]},
|
||||
{ section: "Developers — 47", users: [
|
||||
{ name: "Marcus", avatar: "M", status: "in-game", avatarBg: "bg-[#1a1a1a]", activity: "Building" },
|
||||
{ name: "DevUser_2847", avatar: "D", status: "online", avatarBg: "bg-[#1a1a1a]" },
|
||||
]},
|
||||
{ section: "Community — 61", users: [
|
||||
{ name: "JohnDev", avatar: "J", status: "offline", avatarBg: "bg-[#1a1a1a]" },
|
||||
]},
|
||||
const memberSections = [
|
||||
{
|
||||
title: 'Foundation Team',
|
||||
count: 8,
|
||||
members: [
|
||||
{ id: 1, name: 'Anderson', tag: 'Anderson#0001', initial: 'A', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)', status: 'online', banner: '#ff0000', customStatus: '🔥 Building AeThex', badge: 'Founder', roles: [{ name: 'Founder', color: '#ff0000' }, { name: 'Foundation', color: '#cc0000' }], about: 'Creator of AeThex ecosystem. Building the future of developer tools.' },
|
||||
{ id: 2, name: 'Trevor', tag: 'Trevor#0042', initial: 'T', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)', status: 'online', banner: '#cc0000', badge: 'Foundation', roles: [{ name: 'Foundation', color: '#cc0000' }, { name: 'Developer', color: '#5865f2' }], about: 'Lead developer on authentication systems.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Labs Team',
|
||||
count: 12,
|
||||
members: [
|
||||
{ id: 3, name: 'Sarah', tag: 'Sarah#1337', initial: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)', status: 'labs', banner: '#ffa500', activity: 'Testing v2.0', badge: 'Labs', roles: [{ name: 'Labs', color: '#ffa500' }, { name: 'Tester', color: '#3ba55d' }], about: 'Experimental features lead. Breaking things so you don\'t have to.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Developers',
|
||||
count: 47,
|
||||
members: [
|
||||
{ id: 4, name: 'Marcus', tag: 'Marcus#2048', initial: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)', status: 'in-game', activity: 'Building', banner: '#0066ff', roles: [{ name: 'Developer', color: '#5865f2' }], about: 'Full-stack developer passionate about clean code.' },
|
||||
{ id: 5, name: 'DevUser_2847', tag: 'DevUser_2847#2847', initial: 'D', status: 'online', roles: [{ name: 'Member', color: '#99aab5' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
count: 61,
|
||||
members: [
|
||||
{ id: 6, name: 'JohnDev', tag: 'JohnDev#9999', initial: 'J', status: 'idle', roles: [{ name: 'Member', color: '#99aab5' }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function MemberSidebar() {
|
||||
export default function MemberSidebar({ onMemberClick }) {
|
||||
return (
|
||||
<div className="member-sidebar w-72 bg-[#0f0f0f] border-l border-[#1a1a1a] flex flex-col">
|
||||
<div className="member-header p-4 border-b border-[#1a1a1a] text-xs uppercase tracking-widest text-gray-500">Members — 128</div>
|
||||
<div className="member-list flex-1 overflow-y-auto py-3">
|
||||
{members.map((section, i) => (
|
||||
<div key={i} className="member-section mb-4">
|
||||
<div className="member-section-title px-4 py-2 text-xs uppercase tracking-wider text-gray-500 font-bold">{section.section}</div>
|
||||
{section.users.map((user, j) => (
|
||||
<div key={j} className="member-item flex items-center gap-3 px-4 py-1.5 cursor-pointer hover:bg-[#1a1a1a]">
|
||||
<div className={`member-avatar-small w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm relative bg-gradient-to-tr ${user.avatarBg}`}>
|
||||
{user.avatar}
|
||||
<div className={`online-indicator absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-[#0f0f0f] ${user.status === "online" ? "bg-green-400" : user.status === "in-game" ? "bg-blue-500" : user.status === "labs" ? "bg-orange-400" : "bg-gray-700"}`}></div>
|
||||
<div className="member-sidebar">
|
||||
<div className="member-header">Members — 128</div>
|
||||
|
||||
<div className="member-list">
|
||||
{memberSections.map((section) => (
|
||||
<div key={section.title} className="member-section">
|
||||
<div className="member-section-title">{section.title} — {section.count}</div>
|
||||
{section.members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="member-item"
|
||||
onClick={() => onMemberClick?.(member)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div
|
||||
className="member-avatar-small"
|
||||
style={member.gradient ? { background: member.gradient } : undefined}
|
||||
>
|
||||
{member.initial}
|
||||
{member.status && (
|
||||
<div className={`online-indicator ${member.status}`}></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 className="member-name">{member.name}</div>
|
||||
{member.activity && (
|
||||
<div className="member-activity">{member.activity}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
84
src/frontend/mockup/MessageActions.jsx
Normal file
84
src/frontend/mockup/MessageActions.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function MessageActions({ message, onReact, onReply, onEdit, onDelete, onPin, onThread }) {
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '😡'];
|
||||
|
||||
return (
|
||||
<div className="message-actions">
|
||||
<div className="quick-reactions">
|
||||
{quickReactions.map((emoji, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className="reaction-btn"
|
||||
onClick={() => onReact?.(emoji)}
|
||||
title={`React with ${emoji}`}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
<button className="reaction-btn add-reaction" title="Add Reaction">
|
||||
😀+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="action-buttons">
|
||||
<button className="action-btn" onClick={() => onReply?.()} title="Reply">
|
||||
↩️
|
||||
</button>
|
||||
<button className="action-btn" onClick={() => onThread?.()} title="Create Thread">
|
||||
🧵
|
||||
</button>
|
||||
{message?.isOwn && (
|
||||
<button className="action-btn" onClick={() => onEdit?.()} title="Edit">
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="action-btn more-btn"
|
||||
onClick={() => setShowMore(!showMore)}
|
||||
title="More"
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMore && (
|
||||
<div className="more-menu">
|
||||
<div className="menu-item" onClick={() => onPin?.()}>
|
||||
📌 {message?.isPinned ? 'Unpin' : 'Pin'} Message
|
||||
</div>
|
||||
<div className="menu-item">
|
||||
📋 Copy Text
|
||||
</div>
|
||||
<div className="menu-item">
|
||||
🔗 Copy Link
|
||||
</div>
|
||||
<div className="menu-item">
|
||||
🚩 Mark Unread
|
||||
</div>
|
||||
<div className="menu-divider"></div>
|
||||
<div className="menu-item">
|
||||
💬 Reply
|
||||
</div>
|
||||
<div className="menu-item">
|
||||
🧵 Create Thread
|
||||
</div>
|
||||
<div className="menu-divider"></div>
|
||||
<div className="menu-item">
|
||||
🚫 Report Message
|
||||
</div>
|
||||
{message?.isOwn && (
|
||||
<>
|
||||
<div className="menu-divider"></div>
|
||||
<div className="menu-item danger" onClick={() => onDelete?.()}>
|
||||
🗑️ Delete Message
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
src/frontend/mockup/ModerationModal.jsx
Normal file
273
src/frontend/mockup/ModerationModal.jsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
// Moderation action modal - for kicks, bans, timeouts
|
||||
export default function ModerationModal({ type, user, onSubmit, onClose }) {
|
||||
const [reason, setReason] = useState('');
|
||||
const [deleteMessages, setDeleteMessages] = useState('none');
|
||||
const [duration, setDuration] = useState('1h');
|
||||
|
||||
const typeConfig = {
|
||||
kick: {
|
||||
title: 'Kick Member',
|
||||
icon: '👢',
|
||||
action: 'Kick',
|
||||
color: '#faa61a',
|
||||
description: `Are you sure you want to kick ${user?.name}? They will be able to rejoin with a new invite.`,
|
||||
},
|
||||
ban: {
|
||||
title: 'Ban Member',
|
||||
icon: '🔨',
|
||||
action: 'Ban',
|
||||
color: '#ed4245',
|
||||
description: `Are you sure you want to ban ${user?.name}? They will not be able to rejoin unless unbanned.`,
|
||||
showDeleteMessages: true,
|
||||
},
|
||||
timeout: {
|
||||
title: 'Timeout Member',
|
||||
icon: '⏰',
|
||||
action: 'Timeout',
|
||||
color: '#faa61a',
|
||||
description: `Temporarily mute ${user?.name}. They won't be able to send messages, react, or join voice.`,
|
||||
showDuration: true,
|
||||
},
|
||||
warn: {
|
||||
title: 'Warn Member',
|
||||
icon: '⚠️',
|
||||
action: 'Warn',
|
||||
color: '#faa61a',
|
||||
description: `Send a warning to ${user?.name}. This will be logged in the audit log.`,
|
||||
},
|
||||
};
|
||||
|
||||
const config = typeConfig[type] || typeConfig.kick;
|
||||
|
||||
const durations = [
|
||||
{ value: '60s', label: '60 seconds' },
|
||||
{ value: '5m', label: '5 minutes' },
|
||||
{ value: '10m', label: '10 minutes' },
|
||||
{ value: '1h', label: '1 hour' },
|
||||
{ value: '1d', label: '1 day' },
|
||||
{ value: '1w', label: '1 week' },
|
||||
];
|
||||
|
||||
const deleteOptions = [
|
||||
{ value: 'none', label: "Don't delete any" },
|
||||
{ value: '1h', label: 'Previous hour' },
|
||||
{ value: '24h', label: 'Previous 24 hours' },
|
||||
{ value: '7d', label: 'Previous 7 days' },
|
||||
];
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit?.({
|
||||
type,
|
||||
userId: user?.id,
|
||||
reason,
|
||||
deleteMessages: config.showDeleteMessages ? deleteMessages : undefined,
|
||||
duration: config.showDuration ? duration : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="moderation-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mod-header" style={{ borderColor: config.color }}>
|
||||
<span className="mod-icon">{config.icon}</span>
|
||||
<h2>{config.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="mod-content">
|
||||
<div className="mod-user">
|
||||
<div
|
||||
className="mod-user-avatar"
|
||||
style={user?.color ? { background: user.color } : undefined}
|
||||
>
|
||||
{user?.avatar || user?.name?.[0]}
|
||||
</div>
|
||||
<div className="mod-user-info">
|
||||
<span className="mod-user-name">{user?.name}</span>
|
||||
{user?.tag && <span className="mod-user-tag">{user.tag}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mod-description">{config.description}</p>
|
||||
|
||||
{config.showDuration && (
|
||||
<div className="mod-field">
|
||||
<label>Duration</label>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="mod-select"
|
||||
>
|
||||
{durations.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.showDeleteMessages && (
|
||||
<div className="mod-field">
|
||||
<label>Delete message history</label>
|
||||
<select
|
||||
value={deleteMessages}
|
||||
onChange={(e) => setDeleteMessages(e.target.value)}
|
||||
className="mod-select"
|
||||
>
|
||||
{deleteOptions.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mod-field">
|
||||
<label>Reason (optional)</label>
|
||||
<textarea
|
||||
placeholder="Enter a reason..."
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<span className="field-hint">This will be recorded in the audit log</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mod-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="action-btn"
|
||||
style={{ background: config.color }}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{config.action}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// User notes modal
|
||||
export function UserNotesModal({ user, onSave, onClose }) {
|
||||
const [note, setNote] = useState(user?.note || '');
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="notes-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="notes-header">
|
||||
<h2>Note about {user?.name}</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="notes-content">
|
||||
<textarea
|
||||
placeholder="Click to add a note..."
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={5}
|
||||
/>
|
||||
<span className="notes-hint">Only you can see this note</span>
|
||||
</div>
|
||||
|
||||
<div className="notes-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="save-btn"
|
||||
onClick={() => onSave?.(note)}
|
||||
>
|
||||
Save Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Report user modal
|
||||
export function ReportModal({ user, message, onSubmit, onClose }) {
|
||||
const [category, setCategory] = useState('');
|
||||
const [details, setDetails] = useState('');
|
||||
|
||||
const categories = [
|
||||
{ value: 'harassment', label: 'Harassment or Bullying' },
|
||||
{ value: 'spam', label: 'Spam or Scam' },
|
||||
{ value: 'hate', label: 'Hate Speech or Discrimination' },
|
||||
{ value: 'nsfw', label: 'Inappropriate Content' },
|
||||
{ value: 'impersonation', label: 'Impersonation' },
|
||||
{ value: 'threats', label: 'Threats or Violence' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="report-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="report-header">
|
||||
<span className="report-icon">🚩</span>
|
||||
<h2>Report {message ? 'Message' : 'User'}</h2>
|
||||
</div>
|
||||
|
||||
<div className="report-content">
|
||||
{user && (
|
||||
<div className="report-target">
|
||||
<div
|
||||
className="target-avatar"
|
||||
style={user.color ? { background: user.color } : undefined}
|
||||
>
|
||||
{user.avatar || user.name?.[0]}
|
||||
</div>
|
||||
<span className="target-name">{user.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className="reported-message">
|
||||
<div className="reported-content">{message.text}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="report-field">
|
||||
<label>What's the issue?</label>
|
||||
<div className="category-options">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.value}
|
||||
className={`category-btn ${category === cat.value ? 'selected' : ''}`}
|
||||
onClick={() => setCategory(cat.value)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="report-field">
|
||||
<label>Additional details (optional)</label>
|
||||
<textarea
|
||||
placeholder="Provide more context about this issue..."
|
||||
value={details}
|
||||
onChange={(e) => setDetails(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="report-notice">
|
||||
<span className="notice-icon">ℹ️</span>
|
||||
<p>Reports are confidential. The reported user won't know who reported them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="report-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={() => onSubmit?.({ category, details })}
|
||||
disabled={!category}
|
||||
>
|
||||
Submit Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
src/frontend/mockup/NitroPanel.jsx
Normal file
233
src/frontend/mockup/NitroPanel.jsx
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const nitroFeatures = [
|
||||
{ id: 'animated-avatar', icon: '👤', title: 'Animated Avatar', description: 'Use GIFs as your profile picture' },
|
||||
{ id: 'animated-emoji', icon: '😄', title: 'Animated Emoji', description: 'Use animated emojis anywhere' },
|
||||
{ id: 'larger-uploads', icon: '📁', title: '500MB Uploads', description: 'Share large files up to 500MB' },
|
||||
{ id: 'hd-streaming', icon: '📺', title: 'HD Streaming', description: 'Stream in 4K at 60fps' },
|
||||
{ id: 'custom-tag', icon: '🏷️', title: 'Custom Tag', description: 'Choose your own 4-digit tag' },
|
||||
{ id: 'server-boost', icon: '🚀', title: '2 Free Boosts', description: 'Boost your favorite servers' },
|
||||
{ id: 'profile-banner', icon: '🖼️', title: 'Profile Banner', description: 'Add a custom banner to your profile' },
|
||||
{ id: 'custom-themes', icon: '🎨', title: 'Custom Themes', description: 'Personalize your client' },
|
||||
{ id: 'super-reactions', icon: '✨', title: 'Super Reactions', description: 'Send enhanced animated reactions' },
|
||||
{ id: 'soundboard', icon: '🔊', title: 'More Sounds', description: 'Upload more soundboard sounds' },
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{
|
||||
id: 'nitro',
|
||||
name: 'Nitro',
|
||||
price: '$9.99',
|
||||
period: '/month',
|
||||
yearlyPrice: '$99.99',
|
||||
features: nitroFeatures.map(f => f.id),
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: 'nitro-basic',
|
||||
name: 'Nitro Basic',
|
||||
price: '$2.99',
|
||||
period: '/month',
|
||||
yearlyPrice: '$29.99',
|
||||
features: ['animated-emoji', 'larger-uploads', 'custom-tag'],
|
||||
popular: false,
|
||||
},
|
||||
];
|
||||
|
||||
const boostPerks = [
|
||||
{ level: 1, boosts: 2, perks: ['50 extra emoji slots', '128kbps audio', 'Animated server icon', 'Custom invite background'] },
|
||||
{ level: 2, boosts: 7, perks: ['100 extra emoji slots', '256kbps audio', '50MB upload limit', 'Server banner'] },
|
||||
{ level: 3, boosts: 14, perks: ['250 extra emoji slots', '384kbps audio', '100MB upload limit', 'Vanity URL'] },
|
||||
];
|
||||
|
||||
export default function NitroPanel({ onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('subscribe');
|
||||
const [billingCycle, setBillingCycle] = useState('monthly');
|
||||
const [selectedPlan, setSelectedPlan] = useState('nitro');
|
||||
|
||||
return (
|
||||
<div className="nitro-panel">
|
||||
<div className="nitro-header">
|
||||
<div className="nitro-branding">
|
||||
<span className="nitro-logo">🚀</span>
|
||||
<h1>Nitro</h1>
|
||||
</div>
|
||||
<button className="nitro-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="nitro-tabs">
|
||||
<button
|
||||
className={`nitro-tab ${activeTab === 'subscribe' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('subscribe')}
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
<button
|
||||
className={`nitro-tab ${activeTab === 'boost' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('boost')}
|
||||
>
|
||||
Server Boost
|
||||
</button>
|
||||
<button
|
||||
className={`nitro-tab ${activeTab === 'gift' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('gift')}
|
||||
>
|
||||
Gift
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'subscribe' && (
|
||||
<div className="nitro-content">
|
||||
<div className="nitro-hero">
|
||||
<h2>Unleash more fun</h2>
|
||||
<p>Subscribe to Nitro to unlock features and perks</p>
|
||||
</div>
|
||||
|
||||
<div className="billing-toggle">
|
||||
<button
|
||||
className={`billing-option ${billingCycle === 'monthly' ? 'active' : ''}`}
|
||||
onClick={() => setBillingCycle('monthly')}
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
className={`billing-option ${billingCycle === 'yearly' ? 'active' : ''}`}
|
||||
onClick={() => setBillingCycle('yearly')}
|
||||
>
|
||||
Yearly <span className="save-badge">Save 16%</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="plans-grid">
|
||||
{plans.map(plan => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={`plan-card ${plan.popular ? 'popular' : ''} ${selectedPlan === plan.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedPlan(plan.id)}
|
||||
>
|
||||
{plan.popular && <div className="popular-badge">Most Popular</div>}
|
||||
<h3>{plan.name}</h3>
|
||||
<div className="plan-price">
|
||||
<span className="price">{billingCycle === 'yearly' ? plan.yearlyPrice : plan.price}</span>
|
||||
<span className="period">{billingCycle === 'yearly' ? '/year' : plan.period}</span>
|
||||
</div>
|
||||
<ul className="plan-features">
|
||||
{nitroFeatures.filter(f => plan.features.includes(f.id)).map(feature => (
|
||||
<li key={feature.id}>
|
||||
<span className="check">✓</span>
|
||||
{feature.title}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button className="select-plan-btn">
|
||||
{selectedPlan === plan.id ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="subscribe-btn">
|
||||
Subscribe to {plans.find(p => p.id === selectedPlan)?.name}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'boost' && (
|
||||
<div className="nitro-content">
|
||||
<div className="boost-hero">
|
||||
<span className="boost-icon">🚀</span>
|
||||
<h2>Server Boost</h2>
|
||||
<p>Unlock perks for your favorite communities</p>
|
||||
</div>
|
||||
|
||||
<div className="boost-levels">
|
||||
{boostPerks.map(level => (
|
||||
<div key={level.level} className="boost-level-card">
|
||||
<div className="level-header">
|
||||
<span className="level-badge">Level {level.level}</span>
|
||||
<span className="boost-requirement">{level.boosts} Boosts</span>
|
||||
</div>
|
||||
<ul className="level-perks">
|
||||
{level.perks.map((perk, idx) => (
|
||||
<li key={idx}>
|
||||
<span className="perk-check">✓</span>
|
||||
{perk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="boost-cta">
|
||||
<button className="boost-btn">
|
||||
🚀 Boost a Server
|
||||
</button>
|
||||
<p className="boost-price">$4.99/month or included with Nitro</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'gift' && (
|
||||
<div className="nitro-content">
|
||||
<div className="gift-hero">
|
||||
<span className="gift-icon">🎁</span>
|
||||
<h2>Gift Nitro</h2>
|
||||
<p>Send the gift of Nitro to a friend</p>
|
||||
</div>
|
||||
|
||||
<div className="gift-options">
|
||||
<div className="gift-card">
|
||||
<h3>Nitro</h3>
|
||||
<div className="gift-durations">
|
||||
<button className="duration-btn active">1 Month - $9.99</button>
|
||||
<button className="duration-btn">1 Year - $99.99</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gift-card">
|
||||
<h3>Nitro Basic</h3>
|
||||
<div className="gift-durations">
|
||||
<button className="duration-btn active">1 Month - $2.99</button>
|
||||
<button className="duration-btn">1 Year - $29.99</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gift-recipient">
|
||||
<label>Send to</label>
|
||||
<input type="text" placeholder="Enter a username" />
|
||||
</div>
|
||||
|
||||
<div className="gift-message">
|
||||
<label>Add a message (optional)</label>
|
||||
<textarea placeholder="Write a message to include with your gift..." rows={3} />
|
||||
</div>
|
||||
|
||||
<button className="purchase-gift-btn">
|
||||
🎁 Purchase Gift
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="nitro-footer">
|
||||
<a href="#" className="nitro-link">Nitro Terms</a>
|
||||
<span>•</span>
|
||||
<a href="#" className="nitro-link">Paid Services Terms</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Nitro badge component
|
||||
export function NitroBadge({ type = 'full' }) {
|
||||
if (type === 'icon') {
|
||||
return <span className="nitro-badge-icon">🚀</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nitro-badge">
|
||||
<span className="badge-icon">🚀</span>
|
||||
<span className="badge-text">Nitro</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
src/frontend/mockup/NotificationSettings.jsx
Normal file
158
src/frontend/mockup/NotificationSettings.jsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function NotificationSettings({ server, channel, onClose, onSave }) {
|
||||
const isChannelSettings = !!channel;
|
||||
const name = channel?.name || server?.name || 'AeThex Foundation';
|
||||
|
||||
const [notifSetting, setNotifSetting] = useState('all');
|
||||
const [suppressEveryone, setSuppressEveryone] = useState(false);
|
||||
const [suppressRoles, setSuppressRoles] = useState(false);
|
||||
const [muteUntil, setMuteUntil] = useState('none');
|
||||
const [mobilePush, setMobilePush] = useState(true);
|
||||
|
||||
const notifOptions = [
|
||||
{ id: 'all', label: 'All Messages', desc: 'Get notified for all messages' },
|
||||
{ id: 'mentions', label: 'Only @mentions', desc: 'Only notify when mentioned' },
|
||||
{ id: 'nothing', label: 'Nothing', desc: 'Mute all notifications' },
|
||||
];
|
||||
|
||||
if (isChannelSettings) {
|
||||
notifOptions.unshift({ id: 'default', label: 'Default', desc: 'Use server default setting' });
|
||||
}
|
||||
|
||||
const muteOptions = [
|
||||
{ value: 'none', label: 'Not muted' },
|
||||
{ value: '15min', label: 'For 15 minutes' },
|
||||
{ value: '1hour', label: 'For 1 hour' },
|
||||
{ value: '8hours', label: 'For 8 hours' },
|
||||
{ value: '24hours', label: 'For 24 hours' },
|
||||
{ value: 'forever', label: 'Until I turn it back on' },
|
||||
];
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({
|
||||
notifSetting,
|
||||
suppressEveryone,
|
||||
suppressRoles,
|
||||
muteUntil,
|
||||
mobilePush,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="notification-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="notif-header">
|
||||
<h2>🔔 Notification Settings</h2>
|
||||
<button className="notif-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="notif-target">
|
||||
<span className="target-icon">{isChannelSettings ? '#' : '🏠'}</span>
|
||||
<span className="target-name">{name}</span>
|
||||
</div>
|
||||
|
||||
<div className="notif-content">
|
||||
<div className="notif-section">
|
||||
<h3>Notification Frequency</h3>
|
||||
<div className="notif-options">
|
||||
{notifOptions.map(opt => (
|
||||
<label
|
||||
key={opt.id}
|
||||
className={`notif-option ${notifSetting === opt.id ? 'selected' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="notifSetting"
|
||||
value={opt.id}
|
||||
checked={notifSetting === opt.id}
|
||||
onChange={() => setNotifSetting(opt.id)}
|
||||
/>
|
||||
<div className="option-content">
|
||||
<span className="option-label">{opt.label}</span>
|
||||
<span className="option-desc">{opt.desc}</span>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notif-section">
|
||||
<h3>Mute {isChannelSettings ? 'Channel' : 'Server'}</h3>
|
||||
<select
|
||||
className="mute-select"
|
||||
value={muteUntil}
|
||||
onChange={(e) => setMuteUntil(e.target.value)}
|
||||
>
|
||||
{muteOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="notif-section">
|
||||
<h3>Suppress</h3>
|
||||
<div className="suppress-options">
|
||||
<label className="suppress-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={suppressEveryone}
|
||||
onChange={(e) => setSuppressEveryone(e.target.checked)}
|
||||
/>
|
||||
<span>Suppress @everyone and @here</span>
|
||||
</label>
|
||||
<label className="suppress-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={suppressRoles}
|
||||
onChange={(e) => setSuppressRoles(e.target.checked)}
|
||||
/>
|
||||
<span>Suppress All Role @mentions</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notif-section">
|
||||
<div className="toggle-row">
|
||||
<div className="toggle-info">
|
||||
<h3>Mobile Push Notifications</h3>
|
||||
<p>Receive push notifications on mobile</p>
|
||||
</div>
|
||||
<label className="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mobilePush}
|
||||
onChange={(e) => setMobilePush(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isChannelSettings && (
|
||||
<div className="notif-section channel-overrides">
|
||||
<h3>Channel Overrides</h3>
|
||||
<p className="override-hint">You can customize notifications for individual channels</p>
|
||||
<div className="override-list">
|
||||
<div className="override-item">
|
||||
<span>#announcements</span>
|
||||
<span className="override-value">All Messages</span>
|
||||
</div>
|
||||
<div className="override-item">
|
||||
<span>#spam</span>
|
||||
<span className="override-value">Muted</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="add-override-btn">+ Add Override</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="notif-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button className="save-btn" onClick={handleSave}>Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/frontend/mockup/NotificationsPanel.jsx
Normal file
189
src/frontend/mockup/NotificationsPanel.jsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const allNotifications = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'mention',
|
||||
server: 'AeThex Foundation',
|
||||
serverIcon: 'F',
|
||||
serverGradient: 'linear-gradient(135deg, #ff0000, #990000)',
|
||||
channel: 'general',
|
||||
author: 'Trevor',
|
||||
authorAvatar: 'T',
|
||||
message: '@Anderson check out the new auth flow!',
|
||||
time: '2 minutes ago',
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
type: 'reply',
|
||||
server: 'AeThex Labs',
|
||||
serverIcon: 'L',
|
||||
serverGradient: 'linear-gradient(135deg, #ffa500, #ff8c00)',
|
||||
channel: 'testing',
|
||||
author: 'Sarah',
|
||||
authorAvatar: 'S',
|
||||
message: 'Great point! I\'ll add that to the test suite.',
|
||||
time: '15 minutes ago',
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
type: 'dm',
|
||||
author: 'Marcus',
|
||||
authorAvatar: 'M',
|
||||
authorGradient: 'linear-gradient(135deg, #0066ff, #003380)',
|
||||
message: 'The API integration is ready for review!',
|
||||
time: '1 hour ago',
|
||||
unread: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
type: 'friend-request',
|
||||
author: 'NewDev123',
|
||||
authorAvatar: 'N',
|
||||
message: 'sent you a friend request.',
|
||||
time: '2 hours ago',
|
||||
unread: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'server-invite',
|
||||
server: 'Dev Community',
|
||||
serverIcon: 'DC',
|
||||
author: 'JohnDev',
|
||||
message: 'invited you to join the server.',
|
||||
time: '3 hours ago',
|
||||
unread: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function NotificationsPanel({ onClose, onNotificationClick }) {
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [notifications, setNotifications] = useState(allNotifications);
|
||||
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'mentions', label: 'Mentions' },
|
||||
{ id: 'unreads', label: 'Unreads' },
|
||||
];
|
||||
|
||||
const filteredNotifications = notifications.filter((n) => {
|
||||
if (filter === 'mentions') return n.type === 'mention';
|
||||
if (filter === 'unreads') return n.unread;
|
||||
return true;
|
||||
});
|
||||
|
||||
const markAllRead = () => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })));
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setNotifications([]);
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type) => {
|
||||
switch (type) {
|
||||
case 'mention': return '@';
|
||||
case 'reply': return '↩️';
|
||||
case 'dm': return '💬';
|
||||
case 'friend-request': return '👋';
|
||||
case 'server-invite': return '✉️';
|
||||
default: return '🔔';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notifications-panel">
|
||||
<div className="notifications-header">
|
||||
<h3>Inbox</h3>
|
||||
<div className="notifications-actions">
|
||||
<button className="mark-read-btn" onClick={markAllRead}>Mark All Read</button>
|
||||
<button className="clear-btn" onClick={clearAll}>Clear All</button>
|
||||
</div>
|
||||
<button className="notifications-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="notifications-filters">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`filter-btn ${filter === f.id ? 'active' : ''}`}
|
||||
onClick={() => setFilter(f.id)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="notifications-list">
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<div className="notifications-empty">
|
||||
<span className="empty-icon">📭</span>
|
||||
<p>You're all caught up!</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredNotifications.map((notification) => (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`notification-item ${notification.unread ? 'unread' : ''}`}
|
||||
onClick={() => onNotificationClick?.(notification)}
|
||||
>
|
||||
<div className="notification-icon-container">
|
||||
{notification.type === 'dm' || notification.type === 'friend-request' ? (
|
||||
<div
|
||||
className="notification-avatar"
|
||||
style={{ background: notification.authorGradient || '#36393f' }}
|
||||
>
|
||||
{notification.authorAvatar}
|
||||
</div>
|
||||
) : notification.serverIcon ? (
|
||||
<div
|
||||
className="notification-server-icon"
|
||||
style={{ background: notification.serverGradient || '#36393f' }}
|
||||
>
|
||||
{notification.serverIcon}
|
||||
</div>
|
||||
) : (
|
||||
<div className="notification-type-icon">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
)}
|
||||
<div className="notification-type-badge">
|
||||
{getNotificationIcon(notification.type)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notification-content">
|
||||
<div className="notification-header-row">
|
||||
{notification.server && (
|
||||
<>
|
||||
<span className="notification-server">{notification.server}</span>
|
||||
<span className="notification-separator">•</span>
|
||||
<span className="notification-channel">#{notification.channel}</span>
|
||||
</>
|
||||
)}
|
||||
{notification.type === 'dm' && (
|
||||
<span className="notification-author">{notification.author}</span>
|
||||
)}
|
||||
{notification.type === 'friend-request' && (
|
||||
<span className="notification-author">{notification.author}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="notification-message">
|
||||
{notification.author && notification.type !== 'dm' && notification.type !== 'friend-request' && (
|
||||
<strong>{notification.author}: </strong>
|
||||
)}
|
||||
{notification.message}
|
||||
</div>
|
||||
<div className="notification-time">{notification.time}</div>
|
||||
</div>
|
||||
|
||||
{notification.unread && <div className="unread-dot" />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
src/frontend/mockup/PinnedMessagesPanel.jsx
Normal file
92
src/frontend/mockup/PinnedMessagesPanel.jsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import React from 'react';
|
||||
|
||||
const pinnedMessages = [
|
||||
{
|
||||
id: 1,
|
||||
author: 'Anderson',
|
||||
avatar: 'A',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)',
|
||||
badge: 'Founder',
|
||||
time: 'Jan 15, 2026',
|
||||
text: 'Welcome to AeThex Connect! Please read the rules in #rules and introduce yourself in #introductions. The Trinity awaits! 🔥',
|
||||
pinnedBy: 'Anderson',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: 'Trevor',
|
||||
avatar: 'T',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #cc0000)',
|
||||
badge: 'Foundation',
|
||||
time: 'Jan 20, 2026',
|
||||
text: 'Important: Authentication v2.1.0 is now live! All users should update their Passport credentials. Check #updates for migration guide.',
|
||||
pinnedBy: 'Trevor',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: 'Sarah',
|
||||
avatar: 'S',
|
||||
gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)',
|
||||
badge: 'Labs',
|
||||
time: 'Feb 1, 2026',
|
||||
text: 'Nexus Engine v2.0 is officially out of beta! Huge thanks to everyone who helped test. Read the changelog in #changelog 🚀',
|
||||
pinnedBy: 'Sarah',
|
||||
},
|
||||
];
|
||||
|
||||
export default function PinnedMessagesPanel({ channelName, onClose, onJumpToMessage }) {
|
||||
return (
|
||||
<div className="pinned-panel">
|
||||
<div className="pinned-header">
|
||||
<span className="pinned-icon">📌</span>
|
||||
<span className="pinned-title">Pinned Messages</span>
|
||||
<span className="pinned-channel">#{channelName || 'general'}</span>
|
||||
<button className="pinned-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="pinned-content">
|
||||
{pinnedMessages.length === 0 ? (
|
||||
<div className="pinned-empty">
|
||||
<span className="empty-icon">📌</span>
|
||||
<p>This channel doesn't have any pinned messages... yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pinned-list">
|
||||
{pinnedMessages.map((msg) => (
|
||||
<div key={msg.id} className="pinned-message">
|
||||
<div className="pinned-message-header">
|
||||
<div
|
||||
className="pinned-avatar"
|
||||
style={{ background: msg.gradient || '#36393f' }}
|
||||
>
|
||||
{msg.avatar}
|
||||
</div>
|
||||
<div className="pinned-author-info">
|
||||
<span className="pinned-author">{msg.author}</span>
|
||||
{msg.badge && <span className={`pinned-badge ${msg.badge.toLowerCase()}`}>{msg.badge}</span>}
|
||||
<span className="pinned-time">{msg.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pinned-message-text">{msg.text}</div>
|
||||
<div className="pinned-message-footer">
|
||||
<span className="pinned-by">Pinned by {msg.pinnedBy}</span>
|
||||
<button
|
||||
className="jump-to-btn"
|
||||
onClick={() => onJumpToMessage?.(msg)}
|
||||
>
|
||||
Jump
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pinned-footer">
|
||||
<span className="protip">
|
||||
<strong>PROTIP:</strong> You can pin up to 50 messages in a channel.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/frontend/mockup/PollCreator.jsx
Normal file
234
src/frontend/mockup/PollCreator.jsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function PollCreator({ onSubmit, onClose }) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState(['', '']);
|
||||
const [duration, setDuration] = useState('24h');
|
||||
const [multiSelect, setMultiSelect] = useState(false);
|
||||
|
||||
const durations = [
|
||||
{ value: '1h', label: '1 Hour' },
|
||||
{ value: '4h', label: '4 Hours' },
|
||||
{ value: '8h', label: '8 Hours' },
|
||||
{ value: '24h', label: '24 Hours' },
|
||||
{ value: '3d', label: '3 Days' },
|
||||
{ value: '7d', label: '1 Week' },
|
||||
];
|
||||
|
||||
const addOption = () => {
|
||||
if (options.length < 10) {
|
||||
setOptions([...options, '']);
|
||||
}
|
||||
};
|
||||
|
||||
const removeOption = (index) => {
|
||||
if (options.length > 2) {
|
||||
setOptions(options.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = (index, value) => {
|
||||
setOptions(options.map((opt, i) => i === index ? value : opt));
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!question.trim() || options.filter(o => o.trim()).length < 2) return;
|
||||
|
||||
onSubmit?.({
|
||||
question,
|
||||
options: options.filter(o => o.trim()),
|
||||
duration,
|
||||
multiSelect,
|
||||
});
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const validOptions = options.filter(o => o.trim()).length;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="poll-creator" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="poll-header">
|
||||
<h2>Create Poll</h2>
|
||||
<button className="poll-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="poll-content">
|
||||
<div className="poll-field">
|
||||
<label>Question</label>
|
||||
<textarea
|
||||
placeholder="Ask something..."
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
maxLength={300}
|
||||
rows={2}
|
||||
/>
|
||||
<span className="char-count">{question.length}/300</span>
|
||||
</div>
|
||||
|
||||
<div className="poll-field">
|
||||
<label>Answers</label>
|
||||
<div className="poll-options">
|
||||
{options.map((opt, idx) => (
|
||||
<div key={idx} className="poll-option">
|
||||
<span className="option-number">{idx + 1}</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={`Option ${idx + 1}`}
|
||||
value={opt}
|
||||
onChange={(e) => updateOption(idx, e.target.value)}
|
||||
maxLength={55}
|
||||
/>
|
||||
{options.length > 2 && (
|
||||
<button
|
||||
className="remove-option"
|
||||
onClick={() => removeOption(idx)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{options.length < 10 && (
|
||||
<button className="add-option" onClick={addOption}>
|
||||
+ Add another answer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="poll-field">
|
||||
<label>Duration</label>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="poll-select"
|
||||
>
|
||||
{durations.map(d => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="poll-toggle">
|
||||
<label className="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiSelect}
|
||||
onChange={(e) => setMultiSelect(e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-switch"></span>
|
||||
<span>Allow multiple answers</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="poll-preview">
|
||||
<h4>Preview</h4>
|
||||
<div className="preview-card">
|
||||
<div className="preview-question">{question || 'Your question here'}</div>
|
||||
<div className="preview-options">
|
||||
{options.filter(o => o.trim()).map((opt, idx) => (
|
||||
<div key={idx} className="preview-option">
|
||||
<div className="option-circle" />
|
||||
<span>{opt}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="preview-footer">
|
||||
<span>0 votes</span>
|
||||
<span>•</span>
|
||||
<span>{durations.find(d => d.value === duration)?.label} remaining</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="poll-footer">
|
||||
<button className="poll-cancel" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="poll-submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={!question.trim() || validOptions < 2}
|
||||
>
|
||||
Create Poll
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Poll display component for messages
|
||||
export function Poll({ poll, onVote }) {
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
|
||||
const totalVotes = poll.options.reduce((sum, opt) => sum + (opt.votes || 0), 0);
|
||||
|
||||
const handleVote = (optionIndex) => {
|
||||
if (hasVoted) return;
|
||||
|
||||
if (poll.multiSelect) {
|
||||
setSelected(prev =>
|
||||
prev.includes(optionIndex)
|
||||
? prev.filter(i => i !== optionIndex)
|
||||
: [...prev, optionIndex]
|
||||
);
|
||||
} else {
|
||||
setSelected([optionIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const submitVote = () => {
|
||||
if (selected.length === 0) return;
|
||||
onVote?.(selected);
|
||||
setHasVoted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="poll-message">
|
||||
<div className="poll-question">{poll.question}</div>
|
||||
<div className="poll-options-list">
|
||||
{poll.options.map((opt, idx) => {
|
||||
const percentage = hasVoted && totalVotes > 0
|
||||
? Math.round((opt.votes / totalVotes) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
className={`poll-vote-option ${selected.includes(idx) ? 'selected' : ''} ${hasVoted ? 'voted' : ''}`}
|
||||
onClick={() => handleVote(idx)}
|
||||
disabled={hasVoted}
|
||||
>
|
||||
{hasVoted && (
|
||||
<div className="vote-bar" style={{ width: `${percentage}%` }} />
|
||||
)}
|
||||
<div className="vote-content">
|
||||
{!hasVoted && (
|
||||
<div className={`vote-checkbox ${poll.multiSelect ? 'multi' : ''}`}>
|
||||
{selected.includes(idx) && '✓'}
|
||||
</div>
|
||||
)}
|
||||
<span className="vote-text">{opt.text}</span>
|
||||
{hasVoted && (
|
||||
<span className="vote-percentage">{percentage}%</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!hasVoted && selected.length > 0 && (
|
||||
<button className="submit-vote" onClick={submitVote}>
|
||||
Vote
|
||||
</button>
|
||||
)}
|
||||
<div className="poll-info">
|
||||
<span>{totalVotes} vote{totalVotes !== 1 ? 's' : ''}</span>
|
||||
<span>•</span>
|
||||
<span>{poll.timeLeft}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
src/frontend/mockup/QuickSwitcher.jsx
Normal file
216
src/frontend/mockup/QuickSwitcher.jsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const recentItems = [
|
||||
{ type: 'channel', id: 'general', name: 'general', server: 'AeThex Foundation', icon: '#' },
|
||||
{ type: 'dm', id: 'dm-1', name: 'Trevor', avatar: 'T', color: '#ff0000' },
|
||||
{ type: 'channel', id: 'api-discussion', name: 'api-discussion', server: 'AeThex Foundation', icon: '#' },
|
||||
];
|
||||
|
||||
const allItems = [
|
||||
// Channels
|
||||
{ type: 'channel', id: 'general', name: 'general', server: 'AeThex Foundation', icon: '#' },
|
||||
{ type: 'channel', id: 'updates', name: 'updates', server: 'AeThex Foundation', icon: '📢' },
|
||||
{ type: 'channel', id: 'api-discussion', name: 'api-discussion', server: 'AeThex Foundation', icon: '#' },
|
||||
{ type: 'channel', id: 'bug-reports', name: 'bug-reports', server: 'AeThex Foundation', icon: '🐛' },
|
||||
{ type: 'channel', id: 'nexus-lounge', name: 'Nexus Lounge', server: 'AeThex Foundation', icon: '🔊', isVoice: true },
|
||||
{ type: 'channel', id: 'labs-general', name: 'general', server: 'AeThex Labs', icon: '#' },
|
||||
{ type: 'channel', id: 'experiments', name: 'experiments', server: 'AeThex Labs', icon: '🧪' },
|
||||
// Users/DMs
|
||||
{ type: 'user', id: 'u-1', name: 'Trevor', tag: '#0001', avatar: 'T', color: '#ff0000' },
|
||||
{ type: 'user', id: 'u-2', name: 'Sarah', tag: '#0042', avatar: 'S', color: '#ffa500' },
|
||||
{ type: 'user', id: 'u-3', name: 'Marcus', tag: '#1234', avatar: 'M', color: '#0066ff' },
|
||||
{ type: 'user', id: 'u-4', name: 'Anderson', tag: '#0001', avatar: 'A', color: '#5865f2' },
|
||||
// Servers
|
||||
{ type: 'server', id: 's-1', name: 'AeThex Foundation', icon: 'F', color: '#ff0000' },
|
||||
{ type: 'server', id: 's-2', name: 'AeThex Labs', icon: 'L', color: '#ffa500' },
|
||||
{ type: 'server', id: 's-3', name: 'AeThex Corporation', icon: 'C', color: '#0066ff' },
|
||||
];
|
||||
|
||||
export default function QuickSwitcher({ onSelect, onClose }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [filter, setFilter] = useState('all'); // all, channels, users, servers
|
||||
|
||||
const getFilteredItems = useCallback(() => {
|
||||
let items = query ? allItems : recentItems;
|
||||
|
||||
if (query) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
items = items.filter(item =>
|
||||
item.name.toLowerCase().includes(lowerQuery) ||
|
||||
(item.server && item.server.toLowerCase().includes(lowerQuery))
|
||||
);
|
||||
}
|
||||
|
||||
if (filter !== 'all') {
|
||||
const typeMap = {
|
||||
channels: 'channel',
|
||||
users: ['user', 'dm'],
|
||||
servers: 'server',
|
||||
};
|
||||
const types = typeMap[filter];
|
||||
items = items.filter(item =>
|
||||
Array.isArray(types) ? types.includes(item.type) : item.type === types
|
||||
);
|
||||
}
|
||||
|
||||
return items.slice(0, 10);
|
||||
}, [query, filter]);
|
||||
|
||||
const filteredItems = getFilteredItems();
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [query, filter]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
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]) {
|
||||
onSelect?.(filteredItems[selectedIndex]);
|
||||
onClose?.();
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose?.();
|
||||
break;
|
||||
case 'Tab':
|
||||
e.preventDefault();
|
||||
// Cycle through filters
|
||||
const filters = ['all', 'channels', 'users', 'servers'];
|
||||
const currentIdx = filters.indexOf(filter);
|
||||
setFilter(filters[(currentIdx + 1) % filters.length]);
|
||||
break;
|
||||
}
|
||||
}, [filteredItems, selectedIndex, filter, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const getItemIcon = (item) => {
|
||||
if (item.type === 'channel') {
|
||||
return item.isVoice ? '🔊' : item.icon;
|
||||
}
|
||||
if (item.type === 'user' || item.type === 'dm') {
|
||||
return (
|
||||
<div
|
||||
className="qs-avatar"
|
||||
style={item.color ? { background: item.color } : undefined}
|
||||
>
|
||||
{item.avatar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (item.type === 'server') {
|
||||
return (
|
||||
<div
|
||||
className="qs-server-icon"
|
||||
style={item.color ? { background: item.color } : undefined}
|
||||
>
|
||||
{item.icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay dark" onClick={onClose}>
|
||||
<div className="quick-switcher" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="qs-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Where would you like to go?"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="qs-filters">
|
||||
<button
|
||||
className={`qs-filter ${filter === 'all' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
className={`qs-filter ${filter === 'channels' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('channels')}
|
||||
>
|
||||
# Channels
|
||||
</button>
|
||||
<button
|
||||
className={`qs-filter ${filter === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('users')}
|
||||
>
|
||||
@ Users
|
||||
</button>
|
||||
<button
|
||||
className={`qs-filter ${filter === 'servers' ? 'active' : ''}`}
|
||||
onClick={() => setFilter('servers')}
|
||||
>
|
||||
🏠 Servers
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="qs-results">
|
||||
{!query && (
|
||||
<div className="qs-section-header">Recent</div>
|
||||
)}
|
||||
{filteredItems.map((item, idx) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`qs-item ${idx === selectedIndex ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect?.(item);
|
||||
onClose?.();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<span className="qs-icon">{getItemIcon(item)}</span>
|
||||
<div className="qs-item-info">
|
||||
<span className="qs-item-name">{item.name}</span>
|
||||
{item.tag && <span className="qs-item-tag">{item.tag}</span>}
|
||||
{item.server && <span className="qs-item-server">{item.server}</span>}
|
||||
</div>
|
||||
{item.type === 'channel' && <span className="qs-type">#</span>}
|
||||
{item.type === 'user' && <span className="qs-type">@</span>}
|
||||
</div>
|
||||
))}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="qs-empty">
|
||||
<p>No results found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="qs-footer">
|
||||
<span className="qs-hint">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> to navigate
|
||||
</span>
|
||||
<span className="qs-hint">
|
||||
<kbd>Enter</kbd> to select
|
||||
</span>
|
||||
<span className="qs-hint">
|
||||
<kbd>Tab</kbd> to filter
|
||||
</span>
|
||||
<span className="qs-hint">
|
||||
<kbd>Esc</kbd> to close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/frontend/mockup/ReplyPreview.jsx
Normal file
54
src/frontend/mockup/ReplyPreview.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ReplyPreview({ message, onCancel, onClose }) {
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div className="reply-preview">
|
||||
<div className="reply-line" />
|
||||
<div className="reply-content">
|
||||
<span className="replying-to">Replying to</span>
|
||||
<span
|
||||
className="reply-author"
|
||||
style={{ color: message.badge === 'foundation' ? '#ff0000' :
|
||||
message.badge === 'labs' ? '#ffa500' :
|
||||
message.badge === 'corporation' ? '#0066ff' : '#b9bbbe' }}
|
||||
>
|
||||
{message.author}
|
||||
</span>
|
||||
<span className="reply-text">{message.text?.substring(0, 100)}{message.text?.length > 100 ? '...' : ''}</span>
|
||||
</div>
|
||||
<button className="reply-cancel" onClick={onCancel || onClose}>✕</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageWithReply({ message, replyTo }) {
|
||||
if (!replyTo) return null;
|
||||
|
||||
return (
|
||||
<div className="message-reply-context">
|
||||
<div className="reply-connector">
|
||||
<div className="reply-line-vertical" />
|
||||
<div className="reply-line-horizontal" />
|
||||
</div>
|
||||
<div
|
||||
className="reply-avatar-mini"
|
||||
style={{ background: replyTo.avatar?.gradient || '#36393f' }}
|
||||
>
|
||||
{replyTo.avatar?.initial || replyTo.author?.charAt(0)}
|
||||
</div>
|
||||
<span
|
||||
className="reply-author-mini"
|
||||
style={{ color: replyTo.badge === 'foundation' ? '#ff0000' :
|
||||
replyTo.badge === 'labs' ? '#ffa500' :
|
||||
replyTo.badge === 'corporation' ? '#0066ff' : '#b9bbbe' }}
|
||||
>
|
||||
{replyTo.author}
|
||||
</span>
|
||||
<span className="reply-text-mini">
|
||||
{replyTo.text?.substring(0, 50)}{replyTo.text?.length > 50 ? '...' : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
src/frontend/mockup/RoleEditor.jsx
Normal file
272
src/frontend/mockup/RoleEditor.jsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const permissions = [
|
||||
{ category: 'General', items: [
|
||||
{ id: 'view_channels', label: 'View Channels', description: 'Members can view channels by default' },
|
||||
{ id: 'manage_channels', label: 'Manage Channels', description: 'Create, edit, and delete channels', dangerous: true },
|
||||
{ id: 'manage_roles', label: 'Manage Roles', description: 'Create and edit roles below this one', dangerous: true },
|
||||
{ id: 'manage_emoji', label: 'Manage Emoji & Stickers', description: 'Add, remove, and manage custom emojis' },
|
||||
{ id: 'view_audit', label: 'View Audit Log', description: 'View a log of all moderation actions' },
|
||||
{ id: 'manage_webhooks', label: 'Manage Webhooks', description: 'Create, edit, and delete webhooks' },
|
||||
{ id: 'manage_server', label: 'Manage Server', description: 'Change server name, region, etc.', dangerous: true },
|
||||
]},
|
||||
{ category: 'Membership', items: [
|
||||
{ id: 'create_invite', label: 'Create Invite', description: 'Invite new people to this server' },
|
||||
{ id: 'change_nickname', label: 'Change Nickname', description: 'Change their own nickname' },
|
||||
{ id: 'manage_nicknames', label: 'Manage Nicknames', description: 'Change nicknames of other members' },
|
||||
{ id: 'kick_members', label: 'Kick Members', description: 'Remove members from the server', dangerous: true },
|
||||
{ id: 'ban_members', label: 'Ban Members', description: 'Permanently ban members', dangerous: true },
|
||||
{ id: 'timeout_members', label: 'Timeout Members', description: 'Temporarily mute members' },
|
||||
]},
|
||||
{ category: 'Text', items: [
|
||||
{ id: 'send_messages', label: 'Send Messages', description: 'Send messages in text channels' },
|
||||
{ id: 'send_tts', label: 'Send TTS Messages', description: 'Send text-to-speech messages' },
|
||||
{ id: 'manage_messages', label: 'Manage Messages', description: 'Delete and pin messages from others', dangerous: true },
|
||||
{ id: 'embed_links', label: 'Embed Links', description: 'Links will show a preview' },
|
||||
{ id: 'attach_files', label: 'Attach Files', description: 'Upload images and files' },
|
||||
{ id: 'add_reactions', label: 'Add Reactions', description: 'React to messages with emoji' },
|
||||
{ id: 'use_external_emoji', label: 'Use External Emoji', description: 'Use emoji from other servers' },
|
||||
{ id: 'mention_everyone', label: 'Mention @everyone', description: 'Can use @everyone and @here', dangerous: true },
|
||||
]},
|
||||
{ category: 'Voice', items: [
|
||||
{ id: 'connect', label: 'Connect', description: 'Join voice channels' },
|
||||
{ id: 'speak', label: 'Speak', description: 'Talk in voice channels' },
|
||||
{ id: 'video', label: 'Video', description: 'Share camera in voice channels' },
|
||||
{ id: 'mute_members', label: 'Mute Members', description: 'Mute other members' },
|
||||
{ id: 'deafen_members', label: 'Deafen Members', description: 'Deafen other members' },
|
||||
{ id: 'move_members', label: 'Move Members', description: 'Move members between voice channels' },
|
||||
{ id: 'use_vad', label: 'Use Voice Activity', description: 'Use voice activity detection' },
|
||||
{ id: 'priority_speaker', label: 'Priority Speaker', description: 'Be more easily heard' },
|
||||
]},
|
||||
];
|
||||
|
||||
export default function RoleEditor({ role, onSave, onClose }) {
|
||||
const [roleName, setRoleName] = useState(role?.name || 'New Role');
|
||||
const [roleColor, setRoleColor] = useState(role?.color || '#99aab5');
|
||||
const [rolePermissions, setRolePermissions] = useState(role?.permissions || {});
|
||||
const [displaySeparately, setDisplaySeparately] = useState(role?.displaySeparately || false);
|
||||
const [mentionable, setMentionable] = useState(role?.mentionable || false);
|
||||
const [activeTab, setActiveTab] = useState('display');
|
||||
|
||||
const colors = [
|
||||
'#99aab5', '#1abc9c', '#2ecc71', '#3498db', '#9b59b6',
|
||||
'#e91e63', '#f1c40f', '#e67e22', '#e74c3c', '#95a5a6',
|
||||
'#607d8b', '#11806a', '#1f8b4c', '#206694', '#71368a',
|
||||
'#ad1457', '#c27c0e', '#a84300', '#992d22', '#979c9f',
|
||||
];
|
||||
|
||||
const togglePermission = (permId) => {
|
||||
setRolePermissions(prev => ({
|
||||
...prev,
|
||||
[permId]: prev[permId] === true ? false : prev[permId] === false ? null : true
|
||||
}));
|
||||
};
|
||||
|
||||
const getPermState = (permId) => {
|
||||
const state = rolePermissions[permId];
|
||||
if (state === true) return 'granted';
|
||||
if (state === false) return 'denied';
|
||||
return 'inherit';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="role-editor">
|
||||
<div className="role-editor-sidebar">
|
||||
<div className="role-preview">
|
||||
<div className="role-color-preview" style={{ background: roleColor }} />
|
||||
<input
|
||||
type="text"
|
||||
value={roleName}
|
||||
onChange={(e) => setRoleName(e.target.value)}
|
||||
className="role-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="editor-tabs">
|
||||
<button
|
||||
className={`editor-tab ${activeTab === 'display' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('display')}
|
||||
>
|
||||
Display
|
||||
</button>
|
||||
<button
|
||||
className={`editor-tab ${activeTab === 'permissions' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('permissions')}
|
||||
>
|
||||
Permissions
|
||||
</button>
|
||||
<button
|
||||
className={`editor-tab ${activeTab === 'members' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('members')}
|
||||
>
|
||||
Members
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="role-editor-content">
|
||||
<div className="editor-header">
|
||||
<h2>Edit Role — {roleName}</h2>
|
||||
<button className="editor-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'display' && (
|
||||
<div className="editor-section">
|
||||
<div className="setting-group">
|
||||
<label>Role Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={roleName}
|
||||
onChange={(e) => setRoleName(e.target.value)}
|
||||
className="text-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Role Color</label>
|
||||
<div className="color-grid">
|
||||
{colors.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
className={`color-btn ${roleColor === color ? 'selected' : ''}`}
|
||||
style={{ background: color }}
|
||||
onClick={() => setRoleColor(color)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="custom-color">
|
||||
<input
|
||||
type="color"
|
||||
value={roleColor}
|
||||
onChange={(e) => setRoleColor(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={roleColor}
|
||||
onChange={(e) => setRoleColor(e.target.value)}
|
||||
className="color-text-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="toggle-setting">
|
||||
<div className="setting-info">
|
||||
<span>Display role members separately</span>
|
||||
<span className="setting-desc">Show members with this role in a separate category</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displaySeparately}
|
||||
onChange={(e) => setDisplaySeparately(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="toggle-setting">
|
||||
<div className="setting-info">
|
||||
<span>Allow anyone to @mention this role</span>
|
||||
<span className="setting-desc">Members can mention this role in chat</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mentionable}
|
||||
onChange={(e) => setMentionable(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="role-icon-section">
|
||||
<label>Role Icon</label>
|
||||
<div className="role-icon-upload">
|
||||
<div className="icon-placeholder">🎭</div>
|
||||
<button className="upload-icon-btn">Upload Image</button>
|
||||
<span className="icon-hint">Recommended 64x64</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'permissions' && (
|
||||
<div className="editor-section permissions-section">
|
||||
<div className="permissions-warning">
|
||||
⚠️ Be careful! Granting dangerous permissions can allow members to harm your server.
|
||||
</div>
|
||||
|
||||
{permissions.map(category => (
|
||||
<div key={category.category} className="permission-category">
|
||||
<h3>{category.category}</h3>
|
||||
{category.items.map(perm => (
|
||||
<div
|
||||
key={perm.id}
|
||||
className={`permission-item ${perm.dangerous ? 'dangerous' : ''}`}
|
||||
>
|
||||
<div className="perm-info">
|
||||
<span className="perm-label">{perm.label}</span>
|
||||
<span className="perm-description">{perm.description}</span>
|
||||
</div>
|
||||
<div className="perm-toggle">
|
||||
<button
|
||||
className={`toggle-btn ${getPermState(perm.id) === 'denied' ? 'active' : ''}`}
|
||||
onClick={() => setRolePermissions(prev => ({ ...prev, [perm.id]: false }))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn neutral ${getPermState(perm.id) === 'inherit' ? 'active' : ''}`}
|
||||
onClick={() => setRolePermissions(prev => ({ ...prev, [perm.id]: null }))}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
className={`toggle-btn ${getPermState(perm.id) === 'granted' ? 'active' : ''}`}
|
||||
onClick={() => setRolePermissions(prev => ({ ...prev, [perm.id]: true }))}
|
||||
>
|
||||
✓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'members' && (
|
||||
<div className="editor-section">
|
||||
<div className="members-header">
|
||||
<input type="text" placeholder="Search members" className="members-search" />
|
||||
<button className="add-members-btn">+ Add Members</button>
|
||||
</div>
|
||||
<div className="role-members-list">
|
||||
<div className="member-item">
|
||||
<div className="member-avatar" style={{ background: '#ff0000' }}>T</div>
|
||||
<span className="member-name">Trevor</span>
|
||||
<button className="remove-member-btn">✕</button>
|
||||
</div>
|
||||
<div className="member-item">
|
||||
<div className="member-avatar" style={{ background: '#ffa500' }}>S</div>
|
||||
<span className="member-name">Sarah</span>
|
||||
<button className="remove-member-btn">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="editor-footer">
|
||||
<button className="delete-role-btn">Delete Role</button>
|
||||
<div className="footer-actions">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="save-btn"
|
||||
onClick={() => onSave?.({ name: roleName, color: roleColor, permissions: rolePermissions, displaySeparately, mentionable })}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
src/frontend/mockup/SearchPanel.jsx
Normal file
176
src/frontend/mockup/SearchPanel.jsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function SearchPanel({ onClose, onSearch }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [results, setResults] = useState([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All', icon: '🔍' },
|
||||
{ id: 'messages', label: 'Messages', icon: '💬' },
|
||||
{ id: 'files', label: 'Files', icon: '📎' },
|
||||
{ id: 'links', label: 'Links', icon: '🔗' },
|
||||
{ id: 'images', label: 'Images', icon: '🖼️' },
|
||||
{ id: 'mentions', label: 'Mentions', icon: '@' },
|
||||
{ id: 'pinned', label: 'Pinned', icon: '📌' },
|
||||
];
|
||||
|
||||
const recentSearches = [
|
||||
'authentication update',
|
||||
'from: Anderson',
|
||||
'has: link',
|
||||
'in: general',
|
||||
'before: 2026-01-15',
|
||||
];
|
||||
|
||||
const mockResults = [
|
||||
{
|
||||
type: 'message',
|
||||
channel: '#general',
|
||||
author: 'Trevor',
|
||||
avatar: 'T',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #cc0000)',
|
||||
content: 'Just pushed the authentication updates...',
|
||||
timestamp: '10:34 AM',
|
||||
highlight: 'authentication',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
channel: '#api-discussion',
|
||||
author: 'Marcus',
|
||||
avatar: 'M',
|
||||
gradient: 'linear-gradient(135deg, #0066ff, #003380)',
|
||||
content: 'The new auth flow is working perfectly now',
|
||||
timestamp: 'Yesterday',
|
||||
highlight: 'auth',
|
||||
},
|
||||
{
|
||||
type: 'file',
|
||||
channel: '#development',
|
||||
author: 'Sarah',
|
||||
filename: 'auth-documentation.pdf',
|
||||
size: '2.4 MB',
|
||||
timestamp: 'Jan 15',
|
||||
},
|
||||
];
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
setIsSearching(true);
|
||||
// Simulate search
|
||||
setTimeout(() => {
|
||||
setResults(mockResults);
|
||||
setIsSearching(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="search-panel">
|
||||
<div className="search-header">
|
||||
<form className="search-form" onSubmit={handleSearch}>
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search messages, files, and more..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{query && (
|
||||
<button type="button" className="clear-btn" onClick={() => setQuery('')}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<button className="close-search" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="search-filters">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
className={`filter-btn ${filter === f.id ? 'active' : ''}`}
|
||||
onClick={() => setFilter(f.id)}
|
||||
>
|
||||
<span>{f.icon}</span>
|
||||
<span>{f.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="search-content">
|
||||
{!query && (
|
||||
<div className="search-suggestions">
|
||||
<h4>Recent Searches</h4>
|
||||
{recentSearches.map((search, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="recent-search"
|
||||
onClick={() => setQuery(search)}
|
||||
>
|
||||
<span className="history-icon">🕐</span>
|
||||
<span>{search}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h4>Search Options</h4>
|
||||
<div className="search-options">
|
||||
<div className="option"><code>from:</code> user</div>
|
||||
<div className="option"><code>mentions:</code> user</div>
|
||||
<div className="option"><code>has:</code> link, embed, file</div>
|
||||
<div className="option"><code>before:</code> date</div>
|
||||
<div className="option"><code>after:</code> date</div>
|
||||
<div className="option"><code>in:</code> channel</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSearching && (
|
||||
<div className="search-loading">
|
||||
<div className="spinner"></div>
|
||||
<span>Searching...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && !isSearching && (
|
||||
<div className="search-results">
|
||||
<h4>{results.length} results found</h4>
|
||||
{results.map((result, idx) => (
|
||||
<div key={idx} className="search-result">
|
||||
{result.type === 'message' && (
|
||||
<>
|
||||
<div className="result-avatar" style={{ background: result.gradient }}>
|
||||
{result.avatar}
|
||||
</div>
|
||||
<div className="result-content">
|
||||
<div className="result-header">
|
||||
<span className="result-author">{result.author}</span>
|
||||
<span className="result-channel">{result.channel}</span>
|
||||
<span className="result-time">{result.timestamp}</span>
|
||||
</div>
|
||||
<div className="result-text">{result.content}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{result.type === 'file' && (
|
||||
<>
|
||||
<div className="result-icon">📄</div>
|
||||
<div className="result-content">
|
||||
<div className="result-filename">{result.filename}</div>
|
||||
<div className="result-meta">
|
||||
{result.size} • {result.channel} • {result.timestamp}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/frontend/mockup/ServerBanner.jsx
Normal file
133
src/frontend/mockup/ServerBanner.jsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function ServerBanner({ server, isCollapsed }) {
|
||||
const serverData = server || {
|
||||
name: 'AeThex Foundation',
|
||||
icon: 'F',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #990000)',
|
||||
banner: 'linear-gradient(180deg, #ff0000 0%, transparent 100%)',
|
||||
description: 'Official AeThex Foundation server',
|
||||
boostLevel: 3,
|
||||
boostCount: 14,
|
||||
memberCount: 128,
|
||||
onlineCount: 47,
|
||||
features: ['ANIMATED_BANNER', 'ANIMATED_ICON', 'BANNER', 'INVITE_SPLASH', 'VANITY_URL', 'VIP_REGIONS'],
|
||||
vanityUrl: 'aethex',
|
||||
};
|
||||
|
||||
const boostFeatures = [
|
||||
{ level: 1, features: ['50 Emoji Slots', '128 Kbps Audio', 'Custom Invite Background', 'Animated Server Icon'] },
|
||||
{ level: 2, features: ['100 Emoji Slots', '256 Kbps Audio', 'Server Banner', '50 MB Uploads', 'Custom Role Icons'] },
|
||||
{ level: 3, features: ['250 Emoji Slots', '384 Kbps Audio', 'Vanity URL', '100 MB Uploads', 'Animated Banner'] },
|
||||
];
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="server-banner-mini">
|
||||
<div className="banner-gradient" style={{ background: serverData.banner }}></div>
|
||||
<span className="server-name-mini">{serverData.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="server-banner">
|
||||
<div className="banner-image" style={{ background: serverData.banner }}>
|
||||
<div className="banner-overlay">
|
||||
<div className="banner-content">
|
||||
<h2 className="banner-title">{serverData.name}</h2>
|
||||
<p className="banner-description">{serverData.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="server-info-bar">
|
||||
<div className="server-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-icon">👥</span>
|
||||
<span className="stat-value">{serverData.memberCount}</span>
|
||||
<span className="stat-label">Members</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-indicator online"></span>
|
||||
<span className="stat-value">{serverData.onlineCount}</span>
|
||||
<span className="stat-label">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="boost-status">
|
||||
<span className="boost-icon">🚀</span>
|
||||
<span className="boost-level">Level {serverData.boostLevel}</span>
|
||||
<span className="boost-count">{serverData.boostCount} Boosts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{serverData.boostLevel > 0 && (
|
||||
<div className="boost-perks">
|
||||
<div className="perks-header">
|
||||
<span className="perks-icon">✨</span>
|
||||
<span>Server Boost Perks</span>
|
||||
</div>
|
||||
<div className="perks-list">
|
||||
{boostFeatures.slice(0, serverData.boostLevel).map((tier) => (
|
||||
tier.features.map((feature, idx) => (
|
||||
<div key={`${tier.level}-${idx}`} className="perk-item">
|
||||
<span className="perk-check">✓</span>
|
||||
<span className="perk-name">{feature}</span>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverData.vanityUrl && (
|
||||
<div className="vanity-url">
|
||||
<span className="vanity-label">Vanity URL</span>
|
||||
<span className="vanity-link">aethex.gg/{serverData.vanityUrl}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Progress bar for boost goals
|
||||
export function BoostProgress({ currentBoosts, currentLevel }) {
|
||||
const levels = [
|
||||
{ level: 1, required: 2 },
|
||||
{ level: 2, required: 7 },
|
||||
{ level: 3, required: 14 },
|
||||
];
|
||||
|
||||
const nextLevel = levels.find((l) => l.level > currentLevel);
|
||||
const prevLevel = levels.find((l) => l.level === currentLevel) || { required: 0 };
|
||||
|
||||
if (!nextLevel) {
|
||||
return (
|
||||
<div className="boost-progress max-level">
|
||||
<span className="boost-label">Maximum Level Reached!</span>
|
||||
<div className="boost-bar">
|
||||
<div className="boost-fill" style={{ width: '100%' }}></div>
|
||||
</div>
|
||||
<span className="boost-count">{currentBoosts} Boosts</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const progress = ((currentBoosts - prevLevel.required) / (nextLevel.required - prevLevel.required)) * 100;
|
||||
|
||||
return (
|
||||
<div className="boost-progress">
|
||||
<div className="boost-header">
|
||||
<span className="current-level">Level {currentLevel}</span>
|
||||
<span className="next-level">Level {nextLevel.level}</span>
|
||||
</div>
|
||||
<div className="boost-bar">
|
||||
<div className="boost-fill" style={{ width: `${Math.min(progress, 100)}%` }}></div>
|
||||
</div>
|
||||
<span className="boost-count">
|
||||
{currentBoosts} / {nextLevel.required} Boosts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/frontend/mockup/ServerDiscovery.jsx
Normal file
199
src/frontend/mockup/ServerDiscovery.jsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const categories = [
|
||||
{ id: 'home', label: 'Home', icon: '🏠' },
|
||||
{ id: 'gaming', label: 'Gaming', icon: '🎮' },
|
||||
{ id: 'music', label: 'Music', icon: '🎵' },
|
||||
{ id: 'entertainment', label: 'Entertainment', icon: '🎬' },
|
||||
{ id: 'education', label: 'Education', icon: '📚' },
|
||||
{ id: 'science', label: 'Science & Tech', icon: '🔬' },
|
||||
{ id: 'crypto', label: 'Crypto', icon: '💰' },
|
||||
];
|
||||
|
||||
const featuredServers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'AeThex Foundation',
|
||||
description: 'Official home of AeThex. Security, authentication, and core infrastructure discussions.',
|
||||
icon: 'F',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #990000)',
|
||||
members: '128.5K',
|
||||
online: '24.2K',
|
||||
verified: true,
|
||||
partnered: true,
|
||||
banner: 'linear-gradient(135deg, #1a0000, #ff0000)',
|
||||
tags: ['Security', 'Development', 'Official'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'AeThex Labs',
|
||||
description: 'Experimental features, beta testing, and bleeding-edge technology development.',
|
||||
icon: 'L',
|
||||
gradient: 'linear-gradient(135deg, #ffa500, #ff6b00)',
|
||||
members: '45.2K',
|
||||
online: '8.1K',
|
||||
verified: true,
|
||||
partnered: false,
|
||||
banner: 'linear-gradient(135deg, #1a0f00, #ffa500)',
|
||||
tags: ['Beta', 'Innovation', 'Research'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'GameForge Community',
|
||||
description: 'The ultimate gaming community. LFG, tournaments, and game discussions.',
|
||||
icon: '🎮',
|
||||
members: '892.1K',
|
||||
online: '156.3K',
|
||||
verified: true,
|
||||
partnered: true,
|
||||
banner: 'linear-gradient(135deg, #2d1b69, #5865f2)',
|
||||
tags: ['Gaming', 'LFG', 'Tournaments'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Lofi Beats',
|
||||
description: '24/7 lofi hip hop radio - beats to relax/study to',
|
||||
icon: '🎧',
|
||||
members: '1.2M',
|
||||
online: '89.4K',
|
||||
verified: true,
|
||||
partnered: true,
|
||||
banner: 'linear-gradient(135deg, #1a1a2e, #16213e)',
|
||||
tags: ['Music', 'Chill', 'Study'],
|
||||
},
|
||||
];
|
||||
|
||||
const popularServers = [
|
||||
{ id: 5, name: 'Python', icon: '🐍', members: '456K', tags: ['Programming'] },
|
||||
{ id: 6, name: 'Anime Hub', icon: '🎌', members: '1.8M', tags: ['Anime'] },
|
||||
{ id: 7, name: 'Art & Design', icon: '🎨', members: '234K', tags: ['Creative'] },
|
||||
{ id: 8, name: 'Crypto Trading', icon: '📈', members: '567K', tags: ['Crypto'] },
|
||||
{ id: 9, name: 'Movie Night', icon: '🎬', members: '123K', tags: ['Movies'] },
|
||||
{ id: 10, name: 'Book Club', icon: '📚', members: '89K', tags: ['Books'] },
|
||||
];
|
||||
|
||||
export default function ServerDiscovery({ onJoin, onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('home');
|
||||
|
||||
return (
|
||||
<div className="server-discovery">
|
||||
<div className="discovery-sidebar">
|
||||
<div className="discovery-header">
|
||||
<h2>Discover</h2>
|
||||
</div>
|
||||
|
||||
<div className="discovery-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Explore servers"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="discovery-nav">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`discovery-nav-item ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
<span className="nav-icon">{cat.icon}</span>
|
||||
<span>{cat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="discovery-content">
|
||||
<button className="discovery-close" onClick={onClose}>✕</button>
|
||||
|
||||
<section className="discovery-section">
|
||||
<h3>Featured Communities</h3>
|
||||
<div className="featured-grid">
|
||||
{featuredServers.map(server => (
|
||||
<div key={server.id} className="featured-card">
|
||||
<div
|
||||
className="card-banner"
|
||||
style={{ background: server.banner }}
|
||||
>
|
||||
{server.verified && <span className="verified-badge">✓</span>}
|
||||
{server.partnered && <span className="partner-badge">🤝</span>}
|
||||
</div>
|
||||
<div className="card-content">
|
||||
<div
|
||||
className="card-icon"
|
||||
style={server.gradient ? { background: server.gradient } : undefined}
|
||||
>
|
||||
{server.icon}
|
||||
</div>
|
||||
<h4 className="card-name">{server.name}</h4>
|
||||
<p className="card-description">{server.description}</p>
|
||||
<div className="card-tags">
|
||||
{server.tags.map(tag => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="card-stats">
|
||||
<span className="stat">
|
||||
<span className="online-dot" />
|
||||
{server.online} Online
|
||||
</span>
|
||||
<span className="stat">
|
||||
👥 {server.members} Members
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="join-btn"
|
||||
onClick={() => onJoin?.(server)}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="discovery-section">
|
||||
<h3>Popular Right Now</h3>
|
||||
<div className="popular-grid">
|
||||
{popularServers.map(server => (
|
||||
<div key={server.id} className="popular-card">
|
||||
<div className="popular-icon">{server.icon}</div>
|
||||
<div className="popular-info">
|
||||
<h4>{server.name}</h4>
|
||||
<span className="popular-members">{server.members} members</span>
|
||||
</div>
|
||||
<button
|
||||
className="join-btn small"
|
||||
onClick={() => onJoin?.(server)}
|
||||
>
|
||||
Join
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="discovery-section">
|
||||
<h3>Browse by Category</h3>
|
||||
<div className="category-grid">
|
||||
{categories.filter(c => c.id !== 'home').map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className="category-card"
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
<span className="category-icon">{cat.icon}</span>
|
||||
<span className="category-label">{cat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
src/frontend/mockup/ServerInsights.jsx
Normal file
256
src/frontend/mockup/ServerInsights.jsx
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const mockData = {
|
||||
overview: {
|
||||
totalMembers: 12847,
|
||||
membersChange: '+342',
|
||||
activeMembers: 3241,
|
||||
activeChange: '+12%',
|
||||
messagesPerDay: 8432,
|
||||
messagesChange: '-5%',
|
||||
joinRate: 89,
|
||||
retentionRate: 76,
|
||||
},
|
||||
growth: [
|
||||
{ date: 'Jan 29', joins: 45, leaves: 12 },
|
||||
{ date: 'Jan 30', joins: 52, leaves: 8 },
|
||||
{ date: 'Jan 31', joins: 38, leaves: 15 },
|
||||
{ date: 'Feb 1', joins: 67, leaves: 10 },
|
||||
{ date: 'Feb 2', joins: 89, leaves: 7 },
|
||||
{ date: 'Feb 3', joins: 72, leaves: 11 },
|
||||
{ date: 'Feb 4', joins: 85, leaves: 9 },
|
||||
],
|
||||
topChannels: [
|
||||
{ name: 'general', messages: 2341, percentage: 28 },
|
||||
{ name: 'gaming', messages: 1892, percentage: 22 },
|
||||
{ name: 'off-topic', messages: 1456, percentage: 17 },
|
||||
{ name: 'development', messages: 1234, percentage: 15 },
|
||||
{ name: 'announcements', messages: 892, percentage: 11 },
|
||||
],
|
||||
peakHours: [
|
||||
{ hour: '12AM', activity: 15 },
|
||||
{ hour: '4AM', activity: 8 },
|
||||
{ hour: '8AM', activity: 25 },
|
||||
{ hour: '12PM', activity: 65 },
|
||||
{ hour: '4PM', activity: 85 },
|
||||
{ hour: '8PM', activity: 100 },
|
||||
],
|
||||
demographics: {
|
||||
regions: [
|
||||
{ name: 'North America', percentage: 42 },
|
||||
{ name: 'Europe', percentage: 31 },
|
||||
{ name: 'Asia', percentage: 18 },
|
||||
{ name: 'Other', percentage: 9 },
|
||||
],
|
||||
platforms: [
|
||||
{ name: 'Desktop', percentage: 58 },
|
||||
{ name: 'Mobile', percentage: 35 },
|
||||
{ name: 'Web', percentage: 7 },
|
||||
],
|
||||
},
|
||||
invites: [
|
||||
{ code: 'abc123', uses: 234, creator: 'Admin' },
|
||||
{ code: 'def456', uses: 156, creator: 'Mod' },
|
||||
{ code: 'ghi789', uses: 89, creator: 'Member' },
|
||||
],
|
||||
};
|
||||
|
||||
export default function ServerInsights({ onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [timeRange, setTimeRange] = useState('7d');
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: '📊' },
|
||||
{ id: 'growth', label: 'Growth', icon: '📈' },
|
||||
{ id: 'engagement', label: 'Engagement', icon: '💬' },
|
||||
{ id: 'audience', label: 'Audience', icon: '👥' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="server-insights">
|
||||
<div className="insights-header">
|
||||
<h2>📊 Server Insights</h2>
|
||||
<div className="insights-controls">
|
||||
<select
|
||||
className="time-range-select"
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
>
|
||||
<option value="24h">Last 24 hours</option>
|
||||
<option value="7d">Last 7 days</option>
|
||||
<option value="30d">Last 30 days</option>
|
||||
<option value="90d">Last 90 days</option>
|
||||
</select>
|
||||
<button className="insights-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="insights-tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`insights-tab ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span>{tab.icon}</span> {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="insights-content">
|
||||
{activeTab === 'overview' && (
|
||||
<div className="insights-overview">
|
||||
<div className="stat-cards">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">👥</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{mockData.overview.totalMembers.toLocaleString()}</span>
|
||||
<span className="stat-label">Total Members</span>
|
||||
<span className="stat-change positive">{mockData.overview.membersChange}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">🟢</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{mockData.overview.activeMembers.toLocaleString()}</span>
|
||||
<span className="stat-label">Active Members</span>
|
||||
<span className="stat-change positive">{mockData.overview.activeChange}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">💬</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{mockData.overview.messagesPerDay.toLocaleString()}</span>
|
||||
<span className="stat-label">Messages/Day</span>
|
||||
<span className="stat-change negative">{mockData.overview.messagesChange}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon">📥</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-value">{mockData.overview.joinRate}%</span>
|
||||
<span className="stat-label">Join Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="insights-chart">
|
||||
<h3>Member Growth</h3>
|
||||
<div className="simple-chart">
|
||||
{mockData.growth.map((day, idx) => (
|
||||
<div key={idx} className="chart-bar-container">
|
||||
<div className="chart-bar joins" style={{ height: `${day.joins}%` }} title={`+${day.joins} joins`} />
|
||||
<div className="chart-bar leaves" style={{ height: `${day.leaves}%` }} title={`-${day.leaves} leaves`} />
|
||||
<span className="chart-label">{day.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'growth' && (
|
||||
<div className="insights-growth">
|
||||
<div className="growth-stats">
|
||||
<div className="growth-stat">
|
||||
<span className="growth-number positive">+{mockData.growth.reduce((a, d) => a + d.joins, 0)}</span>
|
||||
<span className="growth-label">New Members</span>
|
||||
</div>
|
||||
<div className="growth-stat">
|
||||
<span className="growth-number negative">-{mockData.growth.reduce((a, d) => a + d.leaves, 0)}</span>
|
||||
<span className="growth-label">Members Left</span>
|
||||
</div>
|
||||
<div className="growth-stat">
|
||||
<span className="growth-number">{mockData.overview.retentionRate}%</span>
|
||||
<span className="growth-label">Retention Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-invites">
|
||||
<h3>Top Invite Links</h3>
|
||||
{mockData.invites.map((invite, idx) => (
|
||||
<div key={idx} className="invite-row">
|
||||
<span className="invite-code">discord.gg/{invite.code}</span>
|
||||
<span className="invite-uses">{invite.uses} uses</span>
|
||||
<span className="invite-creator">by {invite.creator}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'engagement' && (
|
||||
<div className="insights-engagement">
|
||||
<div className="top-channels">
|
||||
<h3>Most Active Channels</h3>
|
||||
{mockData.topChannels.map((channel, idx) => (
|
||||
<div key={idx} className="channel-stat">
|
||||
<span className="channel-rank">#{idx + 1}</span>
|
||||
<span className="channel-name">#{channel.name}</span>
|
||||
<div className="channel-bar">
|
||||
<div
|
||||
className="channel-bar-fill"
|
||||
style={{ width: `${channel.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="channel-messages">{channel.messages.toLocaleString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="peak-hours">
|
||||
<h3>Peak Activity Hours</h3>
|
||||
<div className="hours-chart">
|
||||
{mockData.peakHours.map((hour, idx) => (
|
||||
<div key={idx} className="hour-bar-container">
|
||||
<div
|
||||
className="hour-bar"
|
||||
style={{ height: `${hour.activity}%` }}
|
||||
/>
|
||||
<span className="hour-label">{hour.hour}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'audience' && (
|
||||
<div className="insights-audience">
|
||||
<div className="audience-section">
|
||||
<h3>Top Regions</h3>
|
||||
{mockData.demographics.regions.map((region, idx) => (
|
||||
<div key={idx} className="demographic-row">
|
||||
<span className="demo-name">{region.name}</span>
|
||||
<div className="demo-bar">
|
||||
<div
|
||||
className="demo-bar-fill"
|
||||
style={{ width: `${region.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="demo-percentage">{region.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="audience-section">
|
||||
<h3>Platforms</h3>
|
||||
{mockData.demographics.platforms.map((platform, idx) => (
|
||||
<div key={idx} className="demographic-row">
|
||||
<span className="demo-name">{platform.name}</span>
|
||||
<div className="demo-bar">
|
||||
<div
|
||||
className="demo-bar-fill"
|
||||
style={{ width: `${platform.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="demo-percentage">{platform.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,30 +1,79 @@
|
|||
import React from "react";
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const servers = [
|
||||
{ id: "foundation", label: "F", active: true, className: "foundation" },
|
||||
{ id: "corporation", label: "C", active: false, className: "corporation" },
|
||||
{ id: "labs", label: "L", active: false, className: "labs" },
|
||||
{ id: "divider" },
|
||||
{ id: "community1", label: "AG", active: false, className: "community" },
|
||||
{ id: "community2", label: "RD", active: false, className: "community" },
|
||||
{ id: "add", label: "+", active: false, className: "community" },
|
||||
{ id: 'home', name: 'Direct Messages', initial: '🏠', type: 'home', notifications: 4 },
|
||||
{ divider: true },
|
||||
{ id: 'foundation', name: 'AeThex Foundation', initial: 'F', type: 'foundation', notifications: 0 },
|
||||
{ id: 'corporation', name: 'AeThex Corporation', initial: 'C', type: 'corporation', notifications: 12 },
|
||||
{ id: 'labs', name: 'AeThex Labs', initial: 'L', type: 'labs', notifications: 0, hasUpdate: true },
|
||||
{ divider: true },
|
||||
{ id: 'gaming', name: 'AeThex Gaming', initial: 'AG', type: 'community', notifications: 0 },
|
||||
{ id: 'dev', name: 'Dev Community', initial: 'DC', type: 'community', notifications: 3 },
|
||||
{ id: 'music', name: 'Music Lounge', initial: '🎵', type: 'community', notifications: 0 },
|
||||
{ divider: true },
|
||||
{ id: 'add', name: 'Add a Server', initial: '+', type: 'add' },
|
||||
{ id: 'explore', name: 'Explore Servers', initial: '🧭', type: 'explore' },
|
||||
];
|
||||
|
||||
export default function ServerList() {
|
||||
export default function ServerList({ activeServer, setActiveServer, onOpenDMs, onDMsClick, selectedDMs, onOpenSettings }) {
|
||||
const [hoveredServer, setHoveredServer] = useState(null);
|
||||
|
||||
return (
|
||||
<div className="server-list">
|
||||
{servers.map((server, idx) => {
|
||||
if (server.divider) {
|
||||
return <div key={`divider-${idx}`} className="server-divider" />;
|
||||
}
|
||||
|
||||
const isActive = activeServer === server.id || (server.id === 'home' && selectedDMs);
|
||||
const isHovered = hoveredServer === server.id;
|
||||
let className = 'server-icon';
|
||||
if (server.type !== 'home' && server.type !== 'add' && server.type !== 'explore') {
|
||||
className += ` ${server.type}`;
|
||||
}
|
||||
if (isActive) className += ' active';
|
||||
|
||||
return (
|
||||
<div className="server-list flex flex-col items-center py-3 gap-3 w-20 bg-[#0d0d0d] border-r border-[#1a1a1a]">
|
||||
{servers.map((srv, i) =>
|
||||
srv.id === "divider" ? (
|
||||
<div key={i} className="server-divider w-10 h-0.5 bg-[#1a1a1a] my-1" />
|
||||
) : (
|
||||
<div
|
||||
key={srv.id}
|
||||
className={`server-icon ${srv.className} ${srv.active ? "active" : ""} w-14 h-14 rounded-xl flex items-center justify-center font-bold text-lg cursor-pointer transition-all relative`}
|
||||
key={server.id}
|
||||
className={className}
|
||||
onClick={() => {
|
||||
if (server.id === 'home') {
|
||||
onOpenDMs?.();
|
||||
onDMsClick?.();
|
||||
}
|
||||
setActiveServer?.(server.id);
|
||||
}}
|
||||
onMouseEnter={() => setHoveredServer(server.id)}
|
||||
onMouseLeave={() => setHoveredServer(null)}
|
||||
title={server.name}
|
||||
style={server.type === 'home' ? { background: isActive ? '#5865f2' : '#36393f' } :
|
||||
server.type === 'add' ? { background: '#36393f', color: '#3ba55d' } :
|
||||
server.type === 'explore' ? { background: '#36393f', color: '#3ba55d' } : undefined}
|
||||
>
|
||||
{srv.label}
|
||||
{server.initial}
|
||||
|
||||
{/* Notification Badge */}
|
||||
{server.notifications > 0 && (
|
||||
<div className="notification-badge">
|
||||
{server.notifications > 99 ? '99+' : server.notifications}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update dot */}
|
||||
{server.hasUpdate && !server.notifications && (
|
||||
<div className="update-dot"></div>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{isHovered && (
|
||||
<div className="server-tooltip">
|
||||
{server.name}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
159
src/frontend/mockup/ServerSettingsModal.jsx
Normal file
159
src/frontend/mockup/ServerSettingsModal.jsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function ServerSettingsModal({ server, onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
|
||||
const serverData = server || {
|
||||
name: 'AeThex Foundation',
|
||||
icon: 'F',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #990000)',
|
||||
description: 'Official AeThex Foundation server. Security, authentication, and core infrastructure discussions.',
|
||||
region: 'US East',
|
||||
memberCount: 128,
|
||||
boostLevel: 3,
|
||||
boostCount: 14,
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: '📋' },
|
||||
{ id: 'roles', label: 'Roles', icon: '🎭' },
|
||||
{ id: 'emoji', label: 'Emoji', icon: '😀' },
|
||||
{ id: 'stickers', label: 'Stickers', icon: '🎨' },
|
||||
{ id: 'moderation', label: 'Moderation', icon: '🛡️' },
|
||||
{ id: 'audit-log', label: 'Audit Log', icon: '📜' },
|
||||
{ id: 'integrations', label: 'Integrations', icon: '🔗' },
|
||||
{ id: 'members', label: 'Members', icon: '👥' },
|
||||
{ id: 'invites', label: 'Invites', icon: '✉️' },
|
||||
{ id: 'bans', label: 'Bans', icon: '🚫' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="server-settings-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="settings-sidebar">
|
||||
<div className="settings-server-info">
|
||||
<div className="settings-server-icon" style={{ background: serverData.gradient }}>
|
||||
{serverData.icon}
|
||||
</div>
|
||||
<div className="settings-server-name">{serverData.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-nav">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`settings-nav-item ${activeTab === tab.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className="nav-icon">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
<div className="settings-nav-item danger">
|
||||
<span className="nav-icon">🗑️</span>
|
||||
<span>Delete Server</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="settings-header">
|
||||
<h2>{tabs.find(t => t.id === activeTab)?.label}</h2>
|
||||
<button className="settings-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<div className="settings-overview">
|
||||
<div className="setting-group">
|
||||
<label>Server Name</label>
|
||||
<input type="text" className="setting-input" defaultValue={serverData.name} />
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Server Icon</label>
|
||||
<div className="icon-upload">
|
||||
<div className="current-icon" style={{ background: serverData.gradient }}>
|
||||
{serverData.icon}
|
||||
</div>
|
||||
<button className="upload-btn">Upload Image</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
className="setting-textarea"
|
||||
defaultValue={serverData.description}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Server Region</label>
|
||||
<select className="setting-select" defaultValue={serverData.region}>
|
||||
<option>US East</option>
|
||||
<option>US West</option>
|
||||
<option>Europe</option>
|
||||
<option>Asia</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="boost-info">
|
||||
<div className="boost-level">
|
||||
<span className="boost-icon">🚀</span>
|
||||
<span>Level {serverData.boostLevel}</span>
|
||||
</div>
|
||||
<div className="boost-count">{serverData.boostCount} Boosts</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'roles' && (
|
||||
<div className="settings-roles">
|
||||
<div className="roles-header">
|
||||
<button className="create-role-btn">+ Create Role</button>
|
||||
</div>
|
||||
<div className="roles-list">
|
||||
<div className="role-item">
|
||||
<span className="role-color" style={{ background: '#ff0000' }}></span>
|
||||
<span className="role-name">Founder</span>
|
||||
<span className="role-members">1 member</span>
|
||||
</div>
|
||||
<div className="role-item">
|
||||
<span className="role-color" style={{ background: '#ff0000' }}></span>
|
||||
<span className="role-name">Foundation</span>
|
||||
<span className="role-members">8 members</span>
|
||||
</div>
|
||||
<div className="role-item">
|
||||
<span className="role-color" style={{ background: '#0066ff' }}></span>
|
||||
<span className="role-name">Corporation</span>
|
||||
<span className="role-members">24 members</span>
|
||||
</div>
|
||||
<div className="role-item">
|
||||
<span className="role-color" style={{ background: '#ffa500' }}></span>
|
||||
<span className="role-name">Labs</span>
|
||||
<span className="role-members">12 members</span>
|
||||
</div>
|
||||
<div className="role-item">
|
||||
<span className="role-color" style={{ background: '#99aab5' }}></span>
|
||||
<span className="role-name">@everyone</span>
|
||||
<span className="role-members">128 members</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab !== 'overview' && activeTab !== 'roles' && (
|
||||
<div className="settings-placeholder">
|
||||
<span className="placeholder-icon">🔧</span>
|
||||
<p>Settings for {tabs.find(t => t.id === activeTab)?.label} coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
src/frontend/mockup/ServerTemplate.jsx
Normal file
247
src/frontend/mockup/ServerTemplate.jsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const builtInTemplates = [
|
||||
{ id: 'gaming', name: 'Gaming', icon: '🎮', description: 'Perfect for gaming communities', channels: ['general', 'voice-chat', 'lfg', 'clips', 'memes'], roles: ['Admin', 'Moderator', 'Member'] },
|
||||
{ id: 'school', name: 'School Club', icon: '📚', description: 'For study groups and school clubs', channels: ['announcements', 'homework-help', 'resources', 'off-topic', 'voice-study'], roles: ['Teacher', 'TA', 'Student'] },
|
||||
{ id: 'creator', name: 'Content Creator', icon: '🎬', description: 'For streamers and creators', channels: ['announcements', 'stream-chat', 'clips', 'fan-art', 'suggestions'], roles: ['Creator', 'Mod', 'Subscriber', 'Fan'] },
|
||||
{ id: 'friends', name: 'Friends', icon: '👥', description: 'Casual hangout space', channels: ['general', 'memes', 'music', 'gaming'], roles: ['Admin', 'Member'] },
|
||||
{ id: 'local', name: 'Local Community', icon: '🏘️', description: 'For local groups and neighborhoods', channels: ['announcements', 'events', 'marketplace', 'discussion'], roles: ['Organizer', 'Member'] },
|
||||
];
|
||||
|
||||
const myTemplates = [
|
||||
{ id: 'my-dev', name: 'Dev Team Setup', icon: '💻', description: 'My development team template', channels: ['general', 'backend', 'frontend', 'devops', 'standup'], roles: ['Lead', 'Senior', 'Junior', 'Intern'], createdAt: '2025-01-15' },
|
||||
];
|
||||
|
||||
export default function ServerTemplate({ mode = 'apply', currentServer, onClose, onApply, onSave }) {
|
||||
const [activeTab, setActiveTab] = useState('browse');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [templateDesc, setTemplateDesc] = useState('');
|
||||
const [includeChannels, setIncludeChannels] = useState(true);
|
||||
const [includeRoles, setIncludeRoles] = useState(true);
|
||||
const [includeSettings, setIncludeSettings] = useState(true);
|
||||
|
||||
const handleApplyTemplate = () => {
|
||||
if (selectedTemplate) {
|
||||
onApply?.(selectedTemplate);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTemplate = () => {
|
||||
onSave?.({
|
||||
name: templateName,
|
||||
description: templateDesc,
|
||||
includeChannels,
|
||||
includeRoles,
|
||||
includeSettings,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="server-template-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="template-header">
|
||||
<h2>📋 Server Templates</h2>
|
||||
<button className="template-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="template-tabs">
|
||||
<button
|
||||
className={`template-tab ${activeTab === 'browse' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('browse')}
|
||||
>
|
||||
Browse Templates
|
||||
</button>
|
||||
<button
|
||||
className={`template-tab ${activeTab === 'my-templates' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('my-templates')}
|
||||
>
|
||||
My Templates
|
||||
</button>
|
||||
<button
|
||||
className={`template-tab ${activeTab === 'create' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('create')}
|
||||
>
|
||||
Create Template
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="template-content">
|
||||
{activeTab === 'browse' && (
|
||||
<div className="template-browse">
|
||||
<p className="template-intro">
|
||||
Start your server with a template to get channels, roles, and settings pre-configured.
|
||||
</p>
|
||||
<div className="template-grid">
|
||||
{builtInTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`template-card ${selectedTemplate?.id === template.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-info">
|
||||
<h3>{template.name}</h3>
|
||||
<p>{template.description}</p>
|
||||
</div>
|
||||
<div className="template-preview">
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">Channels:</span>
|
||||
<span className="preview-count">{template.channels.length}</span>
|
||||
</div>
|
||||
<div className="preview-section">
|
||||
<span className="preview-label">Roles:</span>
|
||||
<span className="preview-count">{template.roles.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedTemplate && (
|
||||
<div className="template-detail">
|
||||
<h3>{selectedTemplate.icon} {selectedTemplate.name}</h3>
|
||||
<div className="detail-section">
|
||||
<h4>Channels</h4>
|
||||
<div className="detail-items">
|
||||
{selectedTemplate.channels.map(ch => (
|
||||
<span key={ch} className="detail-item">#{ch}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-section">
|
||||
<h4>Roles</h4>
|
||||
<div className="detail-items">
|
||||
{selectedTemplate.roles.map(role => (
|
||||
<span key={role} className="detail-item role">@{role}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'my-templates' && (
|
||||
<div className="my-templates">
|
||||
{myTemplates.length === 0 ? (
|
||||
<div className="no-templates">
|
||||
<span className="no-templates-icon">📁</span>
|
||||
<p>No saved templates</p>
|
||||
<span>Create a template from your server to save it here</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="template-list">
|
||||
{myTemplates.map(template => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`template-list-item ${selectedTemplate?.id === template.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-info">
|
||||
<h3>{template.name}</h3>
|
||||
<p>{template.description}</p>
|
||||
<span className="template-date">Created {template.createdAt}</span>
|
||||
</div>
|
||||
<div className="template-actions">
|
||||
<button className="template-action-btn" title="Edit">✏️</button>
|
||||
<button className="template-action-btn" title="Delete">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'create' && (
|
||||
<div className="create-template">
|
||||
<p className="create-intro">
|
||||
Create a template from your current server configuration.
|
||||
</p>
|
||||
|
||||
<div className="create-form">
|
||||
<div className="form-group">
|
||||
<label>Template Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="My Server Template"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Description</label>
|
||||
<textarea
|
||||
value={templateDesc}
|
||||
onChange={(e) => setTemplateDesc(e.target.value)}
|
||||
placeholder="Describe what this template is for..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Include in Template</label>
|
||||
<div className="include-options">
|
||||
<label className="include-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeChannels}
|
||||
onChange={(e) => setIncludeChannels(e.target.checked)}
|
||||
/>
|
||||
<span>Channels & Categories</span>
|
||||
</label>
|
||||
<label className="include-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeRoles}
|
||||
onChange={(e) => setIncludeRoles(e.target.checked)}
|
||||
/>
|
||||
<span>Roles & Permissions</span>
|
||||
</label>
|
||||
<label className="include-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeSettings}
|
||||
onChange={(e) => setIncludeSettings(e.target.checked)}
|
||||
/>
|
||||
<span>Server Settings</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-warning">
|
||||
<span>⚠️</span>
|
||||
<span>Templates do not include messages, members, or server-specific content.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="template-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
{activeTab === 'create' ? (
|
||||
<button
|
||||
className="save-btn"
|
||||
onClick={handleSaveTemplate}
|
||||
disabled={!templateName.trim()}
|
||||
>
|
||||
Create Template
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="apply-btn"
|
||||
onClick={handleApplyTemplate}
|
||||
disabled={!selectedTemplate}
|
||||
>
|
||||
Use Template
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
src/frontend/mockup/SlowModeSettings.jsx
Normal file
186
src/frontend/mockup/SlowModeSettings.jsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const slowModePresets = [
|
||||
{ value: 0, label: 'Off' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
{ value: 15, label: '15s' },
|
||||
{ value: 30, label: '30s' },
|
||||
{ value: 60, label: '1m' },
|
||||
{ value: 120, label: '2m' },
|
||||
{ value: 300, label: '5m' },
|
||||
{ value: 600, label: '10m' },
|
||||
{ value: 900, label: '15m' },
|
||||
{ value: 1800, label: '30m' },
|
||||
{ value: 3600, label: '1h' },
|
||||
{ value: 7200, label: '2h' },
|
||||
{ value: 21600, label: '6h' },
|
||||
];
|
||||
|
||||
export default function SlowModeSettings({ channel, onSave, onClose }) {
|
||||
const [slowMode, setSlowMode] = useState(channel?.slowMode || 0);
|
||||
const [customMode, setCustomMode] = useState(false);
|
||||
const [customSeconds, setCustomSeconds] = useState('');
|
||||
const [exemptRoles, setExemptRoles] = useState(channel?.exemptRoles || []);
|
||||
const [showExemptSettings, setShowExemptSettings] = useState(false);
|
||||
|
||||
const roles = [
|
||||
{ id: 'admin', name: 'Admin', color: '#ff0000' },
|
||||
{ id: 'mod', name: 'Moderator', color: '#5865f2' },
|
||||
{ id: 'helper', name: 'Helper', color: '#3ba55d' },
|
||||
{ id: 'vip', name: 'VIP', color: '#faa61a' },
|
||||
{ id: 'booster', name: 'Server Booster', color: '#ff73fa' },
|
||||
];
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (seconds === 0) return 'Off';
|
||||
if (seconds < 60) return `${seconds} second${seconds !== 1 ? 's' : ''}`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)} minute${seconds >= 120 ? 's' : ''}`;
|
||||
return `${Math.floor(seconds / 3600)} hour${seconds >= 7200 ? 's' : ''}`;
|
||||
};
|
||||
|
||||
const handlePresetClick = (value) => {
|
||||
setSlowMode(value);
|
||||
setCustomMode(false);
|
||||
};
|
||||
|
||||
const handleCustomApply = () => {
|
||||
const value = parseInt(customSeconds);
|
||||
if (!isNaN(value) && value >= 0 && value <= 21600) {
|
||||
setSlowMode(value);
|
||||
setCustomMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleExemptRole = (roleId) => {
|
||||
setExemptRoles(prev =>
|
||||
prev.includes(roleId)
|
||||
? prev.filter(id => id !== roleId)
|
||||
: [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave?.({
|
||||
slowMode,
|
||||
exemptRoles,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="slowmode-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="slowmode-header">
|
||||
<h2>⏱️ Slowmode Settings</h2>
|
||||
<button className="slowmode-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="slowmode-content">
|
||||
<div className="slowmode-channel">
|
||||
<span className="channel-icon">#</span>
|
||||
<span className="channel-name">{channel?.name || 'general'}</span>
|
||||
</div>
|
||||
|
||||
<div className="slowmode-section">
|
||||
<h3>Slowmode Duration</h3>
|
||||
<p className="slowmode-description">
|
||||
Members can only send one message per {formatDuration(slowMode)}.
|
||||
</p>
|
||||
|
||||
<div className="slowmode-presets">
|
||||
{slowModePresets.map(preset => (
|
||||
<button
|
||||
key={preset.value}
|
||||
className={`preset-btn ${slowMode === preset.value && !customMode ? 'active' : ''}`}
|
||||
onClick={() => handlePresetClick(preset.value)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className={`preset-btn ${customMode ? 'active' : ''}`}
|
||||
onClick={() => setCustomMode(true)}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{customMode && (
|
||||
<div className="custom-input">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="21600"
|
||||
value={customSeconds}
|
||||
onChange={(e) => setCustomSeconds(e.target.value)}
|
||||
placeholder="Seconds (0-21600)"
|
||||
/>
|
||||
<button className="apply-custom" onClick={handleCustomApply}>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="slowmode-slider">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="21600"
|
||||
step="5"
|
||||
value={slowMode}
|
||||
onChange={(e) => setSlowMode(parseInt(e.target.value))}
|
||||
/>
|
||||
<span className="slider-value">{formatDuration(slowMode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="slowmode-section">
|
||||
<div
|
||||
className="section-header-toggle"
|
||||
onClick={() => setShowExemptSettings(!showExemptSettings)}
|
||||
>
|
||||
<h3>Exempt Roles</h3>
|
||||
<span className="toggle-icon">{showExemptSettings ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{showExemptSettings && (
|
||||
<div className="exempt-roles">
|
||||
<p className="exempt-description">
|
||||
These roles won't be affected by slowmode.
|
||||
</p>
|
||||
{roles.map(role => (
|
||||
<label key={role.id} className="exempt-role">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={exemptRoles.includes(role.id)}
|
||||
onChange={() => toggleExemptRole(role.id)}
|
||||
/>
|
||||
<span
|
||||
className="role-name"
|
||||
style={{ color: role.color }}
|
||||
>
|
||||
@{role.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="slowmode-info">
|
||||
<span className="info-icon">ℹ️</span>
|
||||
<span>
|
||||
Slowmode helps prevent spam by limiting how often members can send messages.
|
||||
Members with "Manage Channel" permission are always exempt.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="slowmode-footer">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button className="save-btn" onClick={handleSave}>Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/frontend/mockup/Soundboard.jsx
Normal file
129
src/frontend/mockup/Soundboard.jsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const sounds = [
|
||||
{ id: 1, name: 'Airhorn', emoji: '📯', duration: '1.2s', category: 'effects' },
|
||||
{ id: 2, name: 'Cricket', emoji: '🦗', duration: '2.0s', category: 'effects' },
|
||||
{ id: 3, name: 'Sad Trombone', emoji: '📯', duration: '1.8s', category: 'effects' },
|
||||
{ id: 4, name: 'Clap', emoji: '👏', duration: '0.5s', category: 'effects' },
|
||||
{ id: 5, name: 'Dun Dun Dun', emoji: '🎵', duration: '1.5s', category: 'effects' },
|
||||
{ id: 6, name: 'Wow', emoji: '😮', duration: '0.8s', category: 'voice' },
|
||||
{ id: 7, name: 'Bruh', emoji: '😐', duration: '0.5s', category: 'voice' },
|
||||
{ id: 8, name: "Let's Go", emoji: '🔥', duration: '0.7s', category: 'voice' },
|
||||
{ id: 9, name: 'GG', emoji: '🎮', duration: '0.6s', category: 'voice' },
|
||||
{ id: 10, name: 'Oof', emoji: '💀', duration: '0.4s', category: 'voice' },
|
||||
{ id: 11, name: 'Windows Error', emoji: '💻', duration: '0.8s', category: 'memes' },
|
||||
{ id: 12, name: 'Vine Boom', emoji: '💥', duration: '0.6s', category: 'memes' },
|
||||
];
|
||||
|
||||
export default function Soundboard({ onPlay, onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState('all');
|
||||
const [volume, setVolume] = useState(50);
|
||||
const [playing, setPlaying] = useState(null);
|
||||
const [favorites, setFavorites] = useState([1, 8, 12]);
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'favorites', label: '⭐ Favorites' },
|
||||
{ id: 'effects', label: 'Effects' },
|
||||
{ id: 'voice', label: 'Voice' },
|
||||
{ id: 'memes', label: 'Memes' },
|
||||
];
|
||||
|
||||
const filteredSounds = sounds.filter(s => {
|
||||
const matchesSearch = !search || s.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = activeCategory === 'all' ||
|
||||
(activeCategory === 'favorites' ? favorites.includes(s.id) : s.category === activeCategory);
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const handlePlay = (sound) => {
|
||||
setPlaying(sound.id);
|
||||
onPlay?.(sound);
|
||||
// Simulate sound duration
|
||||
setTimeout(() => setPlaying(null), parseFloat(sound.duration) * 1000);
|
||||
};
|
||||
|
||||
const toggleFavorite = (e, soundId) => {
|
||||
e.stopPropagation();
|
||||
setFavorites(prev =>
|
||||
prev.includes(soundId)
|
||||
? prev.filter(id => id !== soundId)
|
||||
: [...prev, soundId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="soundboard">
|
||||
<div className="soundboard-header">
|
||||
<h3>Soundboard</h3>
|
||||
<button className="soundboard-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="soundboard-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sounds"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="soundboard-categories">
|
||||
{categories.map(cat => (
|
||||
<button
|
||||
key={cat.id}
|
||||
className={`sb-category ${activeCategory === cat.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="soundboard-grid">
|
||||
{filteredSounds.map(sound => (
|
||||
<button
|
||||
key={sound.id}
|
||||
className={`sound-btn ${playing === sound.id ? 'playing' : ''}`}
|
||||
onClick={() => handlePlay(sound)}
|
||||
>
|
||||
<span className="sound-emoji">{sound.emoji}</span>
|
||||
<span className="sound-name">{sound.name}</span>
|
||||
<span className="sound-duration">{sound.duration}</span>
|
||||
<button
|
||||
className={`sound-favorite ${favorites.includes(sound.id) ? 'active' : ''}`}
|
||||
onClick={(e) => toggleFavorite(e, sound.id)}
|
||||
>
|
||||
{favorites.includes(sound.id) ? '⭐' : '☆'}
|
||||
</button>
|
||||
{playing === sound.id && <div className="sound-playing-indicator" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredSounds.length === 0 && (
|
||||
<div className="soundboard-empty">
|
||||
<span>No sounds found</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="soundboard-footer">
|
||||
<div className="volume-control">
|
||||
<span className="volume-icon">🔊</span>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="volume-slider"
|
||||
/>
|
||||
<span className="volume-value">{volume}%</span>
|
||||
</div>
|
||||
<button className="upload-sound-btn">+ Upload Sound</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/frontend/mockup/StageChannel.jsx
Normal file
152
src/frontend/mockup/StageChannel.jsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function StageChannel({ channel, onClose, onLeave }) {
|
||||
const [isLive, setIsLive] = useState(true);
|
||||
const [isSpeaker, setIsSpeaker] = useState(false);
|
||||
const [handRaised, setHandRaised] = useState(false);
|
||||
const [muted, setMuted] = useState(true);
|
||||
|
||||
const stageData = channel || {
|
||||
name: 'Community Townhall',
|
||||
topic: 'Q&A with the AeThex Team - Monthly Update',
|
||||
startedAt: '2 hours ago',
|
||||
listeners: 234,
|
||||
};
|
||||
|
||||
const speakers = [
|
||||
{ id: 1, name: 'Anderson', avatar: 'A', color: '#ff0000', speaking: true, role: 'Host' },
|
||||
{ id: 2, name: 'Trevor', avatar: 'T', color: '#0066ff', speaking: false, role: 'Speaker' },
|
||||
{ id: 3, name: 'Sarah', avatar: 'S', color: '#ffa500', speaking: false, role: 'Speaker' },
|
||||
];
|
||||
|
||||
const audience = [
|
||||
{ id: 4, name: 'Marcus', avatar: 'M', handRaised: true },
|
||||
{ id: 5, name: 'DevUser_123', avatar: 'D', handRaised: false },
|
||||
{ id: 6, name: 'Player_456', avatar: 'P', handRaised: true },
|
||||
{ id: 7, name: 'CoolGamer', avatar: 'C', handRaised: false },
|
||||
{ id: 8, name: 'TechFan', avatar: 'T', handRaised: false },
|
||||
];
|
||||
|
||||
const handleRequestSpeak = () => {
|
||||
setHandRaised(!handRaised);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stage-channel">
|
||||
<div className="stage-header">
|
||||
<div className="stage-info">
|
||||
<div className="stage-live-badge">
|
||||
<span className="live-dot" />
|
||||
LIVE
|
||||
</div>
|
||||
<h2>{stageData.name}</h2>
|
||||
<p className="stage-topic">{stageData.topic}</p>
|
||||
</div>
|
||||
<button className="stage-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="stage-content">
|
||||
<div className="stage-section">
|
||||
<div className="section-header">
|
||||
<span className="speaker-icon">🎙️</span>
|
||||
<span>Speakers — {speakers.length}</span>
|
||||
</div>
|
||||
<div className="speakers-grid">
|
||||
{speakers.map(speaker => (
|
||||
<div
|
||||
key={speaker.id}
|
||||
className={`speaker-card ${speaker.speaking ? 'speaking' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="speaker-avatar"
|
||||
style={{ background: speaker.color }}
|
||||
>
|
||||
{speaker.avatar}
|
||||
{speaker.speaking && <div className="speaking-ring" />}
|
||||
</div>
|
||||
<div className="speaker-name">{speaker.name}</div>
|
||||
<div className="speaker-role">{speaker.role}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-section">
|
||||
<div className="section-header">
|
||||
<span className="audience-icon">👥</span>
|
||||
<span>Audience — {audience.length}</span>
|
||||
</div>
|
||||
<div className="audience-grid">
|
||||
{audience.map(user => (
|
||||
<div key={user.id} className="audience-member">
|
||||
<div className="audience-avatar">{user.avatar}</div>
|
||||
<span className="audience-name">{user.name}</span>
|
||||
{user.handRaised && <span className="hand-raised">✋</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stage-stats">
|
||||
<span>👁️ {stageData.listeners} listening</span>
|
||||
<span>•</span>
|
||||
<span>Started {stageData.startedAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="stage-controls">
|
||||
{isSpeaker ? (
|
||||
<>
|
||||
<button
|
||||
className={`stage-btn ${muted ? 'muted' : ''}`}
|
||||
onClick={() => setMuted(!muted)}
|
||||
>
|
||||
{muted ? '🔇' : '🎤'}
|
||||
<span>{muted ? 'Unmute' : 'Mute'}</span>
|
||||
</button>
|
||||
<button className="stage-btn" onClick={() => setIsSpeaker(false)}>
|
||||
👥
|
||||
<span>Move to Audience</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className={`stage-btn request ${handRaised ? 'active' : ''}`}
|
||||
onClick={handleRequestSpeak}
|
||||
>
|
||||
✋
|
||||
<span>{handRaised ? 'Lower Hand' : 'Request to Speak'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="stage-btn leave" onClick={onLeave}>
|
||||
📞
|
||||
<span>Leave Stage</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stage channel in sidebar
|
||||
export function StageChannelPreview({ channel, onJoin }) {
|
||||
return (
|
||||
<div className="stage-preview">
|
||||
<div className="stage-preview-header">
|
||||
<span className="stage-icon">📢</span>
|
||||
<span className="stage-name">{channel.name}</span>
|
||||
<div className="stage-live-dot" />
|
||||
</div>
|
||||
{channel.topic && (
|
||||
<div className="stage-preview-topic">{channel.topic}</div>
|
||||
)}
|
||||
<div className="stage-preview-info">
|
||||
<span className="speaker-count">🎙️ {channel.speakers} speaking</span>
|
||||
<span className="listener-count">👁️ {channel.listeners}</span>
|
||||
</div>
|
||||
<button className="stage-join-btn" onClick={onJoin}>
|
||||
Join Stage
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/frontend/mockup/StickerPicker.jsx
Normal file
142
src/frontend/mockup/StickerPicker.jsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
const stickerPacks = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Wumpus',
|
||||
icon: '🐙',
|
||||
stickers: [
|
||||
{ id: 1, emoji: '👋', name: 'Wave' },
|
||||
{ id: 2, emoji: '❤️', name: 'Love' },
|
||||
{ id: 3, emoji: '😭', name: 'Cry' },
|
||||
{ id: 4, emoji: '🎉', name: 'Party' },
|
||||
{ id: 5, emoji: '😎', name: 'Cool' },
|
||||
{ id: 6, emoji: '🤔', name: 'Think' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'aethex',
|
||||
name: 'AeThex',
|
||||
icon: '🔺',
|
||||
stickers: [
|
||||
{ id: 7, emoji: '🔥', name: 'Fire' },
|
||||
{ id: 8, emoji: '⚡', name: 'Lightning' },
|
||||
{ id: 9, emoji: '🚀', name: 'Launch' },
|
||||
{ id: 10, emoji: '💎', name: 'Gem' },
|
||||
{ id: 11, emoji: '🔮', name: 'Crystal' },
|
||||
{ id: 12, emoji: '⭐', name: 'Star' },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'gaming',
|
||||
name: 'Gaming',
|
||||
icon: '🎮',
|
||||
stickers: [
|
||||
{ id: 13, emoji: '🏆', name: 'Trophy' },
|
||||
{ id: 14, emoji: '💪', name: 'Strong' },
|
||||
{ id: 15, emoji: '🎯', name: 'Target' },
|
||||
{ id: 16, emoji: '⚔️', name: 'Swords' },
|
||||
{ id: 17, emoji: '🛡️', name: 'Shield' },
|
||||
{ id: 18, emoji: '👑', name: 'Crown' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export default function StickerPicker({ onSelect, onClose }) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [activePack, setActivePack] = useState('default');
|
||||
const [recentStickers, setRecentStickers] = useState([1, 7, 13]);
|
||||
|
||||
const currentPack = stickerPacks.find(p => p.id === activePack);
|
||||
|
||||
const allStickers = stickerPacks.flatMap(p => p.stickers);
|
||||
const filteredStickers = search
|
||||
? allStickers.filter(s => s.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: currentPack?.stickers || [];
|
||||
|
||||
const handleSelect = (sticker) => {
|
||||
setRecentStickers(prev => [sticker.id, ...prev.filter(id => id !== sticker.id)].slice(0, 6));
|
||||
onSelect?.(sticker);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sticker-picker">
|
||||
<div className="sticker-header">
|
||||
<div className="sticker-search">
|
||||
<span className="search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search stickers"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!search && recentStickers.length > 0 && (
|
||||
<div className="sticker-section">
|
||||
<div className="sticker-section-header">
|
||||
<span>Recently Used</span>
|
||||
</div>
|
||||
<div className="sticker-grid small">
|
||||
{recentStickers.map(id => {
|
||||
const sticker = allStickers.find(s => s.id === id);
|
||||
if (!sticker) return null;
|
||||
return (
|
||||
<div
|
||||
key={`recent-${id}`}
|
||||
className="sticker-item small"
|
||||
onClick={() => handleSelect(sticker)}
|
||||
title={sticker.name}
|
||||
>
|
||||
{sticker.emoji}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="sticker-packs-nav">
|
||||
{stickerPacks.map(pack => (
|
||||
<button
|
||||
key={pack.id}
|
||||
className={`pack-btn ${activePack === pack.id ? 'active' : ''}`}
|
||||
onClick={() => setActivePack(pack.id)}
|
||||
title={pack.name}
|
||||
>
|
||||
{pack.icon}
|
||||
</button>
|
||||
))}
|
||||
<div className="pack-divider" />
|
||||
<button className="pack-btn add" title="Get more stickers">+</button>
|
||||
</div>
|
||||
|
||||
<div className="sticker-section">
|
||||
{!search && (
|
||||
<div className="sticker-section-header">
|
||||
<span>{currentPack?.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="sticker-grid">
|
||||
{filteredStickers.map(sticker => (
|
||||
<div
|
||||
key={sticker.id}
|
||||
className="sticker-item"
|
||||
onClick={() => handleSelect(sticker)}
|
||||
title={sticker.name}
|
||||
>
|
||||
<div className="sticker-preview">{sticker.emoji}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{search && filteredStickers.length === 0 && (
|
||||
<div className="sticker-empty">
|
||||
<span>No stickers found</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
src/frontend/mockup/StreamOverlay.jsx
Normal file
238
src/frontend/mockup/StreamOverlay.jsx
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function StreamOverlay({ onStart, onClose }) {
|
||||
const [streamType, setStreamType] = useState('screen');
|
||||
const [source, setSource] = useState(null);
|
||||
const [quality, setQuality] = useState('720p');
|
||||
const [fps, setFps] = useState('30');
|
||||
|
||||
const streamSources = {
|
||||
screen: [
|
||||
{ id: 'screen-1', name: 'Screen 1', type: 'Screen', preview: '🖥️' },
|
||||
{ id: 'screen-2', name: 'Screen 2', type: 'Screen', preview: '🖥️' },
|
||||
],
|
||||
window: [
|
||||
{ id: 'vscode', name: 'Visual Studio Code', type: 'Window', preview: '💻' },
|
||||
{ id: 'chrome', name: 'Google Chrome', type: 'Window', preview: '🌐' },
|
||||
{ id: 'terminal', name: 'Terminal', type: 'Window', preview: '⌨️' },
|
||||
{ id: 'game', name: 'Game.exe', type: 'Game', preview: '🎮' },
|
||||
],
|
||||
};
|
||||
|
||||
const qualities = [
|
||||
{ value: '480p', label: '480p' },
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '1080p', label: '1080p', nitro: true },
|
||||
{ value: '1440p', label: '1440p', nitro: true },
|
||||
{ value: '4k', label: '4K (Source)', nitro: true },
|
||||
];
|
||||
|
||||
const framerates = [
|
||||
{ value: '15', label: '15 FPS' },
|
||||
{ value: '30', label: '30 FPS' },
|
||||
{ value: '60', label: '60 FPS', nitro: true },
|
||||
];
|
||||
|
||||
const handleStart = () => {
|
||||
if (!source) return;
|
||||
onStart?.({
|
||||
type: streamType,
|
||||
source,
|
||||
quality,
|
||||
fps,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="stream-overlay" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="stream-header">
|
||||
<h2>📺 Go Live</h2>
|
||||
<button className="stream-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="stream-content">
|
||||
<div className="stream-tabs">
|
||||
<button
|
||||
className={`stream-tab ${streamType === 'screen' ? 'active' : ''}`}
|
||||
onClick={() => { setStreamType('screen'); setSource(null); }}
|
||||
>
|
||||
🖥️ Screen
|
||||
</button>
|
||||
<button
|
||||
className={`stream-tab ${streamType === 'window' ? 'active' : ''}`}
|
||||
onClick={() => { setStreamType('window'); setSource(null); }}
|
||||
>
|
||||
🪟 Window
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="source-grid">
|
||||
{streamSources[streamType]?.map(src => (
|
||||
<button
|
||||
key={src.id}
|
||||
className={`source-card ${source?.id === src.id ? 'selected' : ''}`}
|
||||
onClick={() => setSource(src)}
|
||||
>
|
||||
<div className="source-preview">{src.preview}</div>
|
||||
<div className="source-info">
|
||||
<span className="source-name">{src.name}</span>
|
||||
<span className="source-type">{src.type}</span>
|
||||
</div>
|
||||
{source?.id === src.id && (
|
||||
<span className="source-check">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stream-settings">
|
||||
<h3>Stream Quality</h3>
|
||||
|
||||
<div className="setting-row">
|
||||
<label>Resolution</label>
|
||||
<div className="quality-options">
|
||||
{qualities.map(q => (
|
||||
<button
|
||||
key={q.value}
|
||||
className={`quality-btn ${quality === q.value ? 'active' : ''} ${q.nitro ? 'nitro' : ''}`}
|
||||
onClick={() => setQuality(q.value)}
|
||||
disabled={q.nitro}
|
||||
>
|
||||
{q.label}
|
||||
{q.nitro && <span className="nitro-badge">🚀</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<label>Frame Rate</label>
|
||||
<div className="quality-options">
|
||||
{framerates.map(f => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={`quality-btn ${fps === f.value ? 'active' : ''} ${f.nitro ? 'nitro' : ''}`}
|
||||
onClick={() => setFps(f.value)}
|
||||
disabled={f.nitro}
|
||||
>
|
||||
{f.label}
|
||||
{f.nitro && <span className="nitro-badge">🚀</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nitro-upsell">
|
||||
<span className="nitro-icon">🚀</span>
|
||||
<div className="upsell-text">
|
||||
<span>Stream in higher quality with Nitro</span>
|
||||
<a href="#">Learn more</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stream-preview-section">
|
||||
<h3>Preview</h3>
|
||||
<div className="stream-preview-box">
|
||||
{source ? (
|
||||
<div className="preview-content">
|
||||
<span className="preview-icon">{source.preview}</span>
|
||||
<span className="preview-label">{source.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="preview-placeholder">
|
||||
Select a source to preview
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stream-footer">
|
||||
<div className="stream-info">
|
||||
{source && (
|
||||
<span>Streaming {source.name} at {quality} {fps}fps</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stream-actions">
|
||||
<button className="cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="go-live-btn"
|
||||
onClick={handleStart}
|
||||
disabled={!source}
|
||||
>
|
||||
Go Live
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stream viewer component (when watching someone's stream)
|
||||
export function StreamViewer({ stream, onClose }) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
|
||||
return (
|
||||
<div className={`stream-viewer ${isFullscreen ? 'fullscreen' : ''}`}>
|
||||
<div className="stream-video">
|
||||
<div className="stream-placeholder">
|
||||
<span className="stream-icon">📺</span>
|
||||
<span className="stream-title">{stream?.source?.name || 'Stream'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stream-controls">
|
||||
<div className="stream-info-bar">
|
||||
<div className="streamer-info">
|
||||
<div
|
||||
className="streamer-avatar"
|
||||
style={stream?.user?.color ? { background: stream.user.color } : undefined}
|
||||
>
|
||||
{stream?.user?.avatar || 'S'}
|
||||
</div>
|
||||
<div className="streamer-details">
|
||||
<span className="streamer-name">{stream?.user?.name || 'Streamer'}</span>
|
||||
<span className="viewer-count">👁️ {stream?.viewers || 0} viewers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="viewer-controls">
|
||||
<div className="volume-control">
|
||||
<button className="control-btn" onClick={() => setVolume(v => v > 0 ? 0 : 100)}>
|
||||
{volume === 0 ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Number(e.target.value))}
|
||||
className="volume-slider"
|
||||
/>
|
||||
</div>
|
||||
<button className="control-btn" onClick={() => setIsFullscreen(!isFullscreen)}>
|
||||
{isFullscreen ? '⛶' : '⛶'}
|
||||
</button>
|
||||
<button className="control-btn leave" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stream badge for voice channel
|
||||
export function StreamBadge({ stream }) {
|
||||
return (
|
||||
<div className="stream-badge">
|
||||
<span className="badge-icon">📺</span>
|
||||
<span className="badge-label">LIVE</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/frontend/mockup/ThreadPanel.jsx
Normal file
114
src/frontend/mockup/ThreadPanel.jsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function ThreadPanel({ thread, onClose }) {
|
||||
const [replyText, setReplyText] = useState('');
|
||||
|
||||
const threadData = thread || {
|
||||
name: 'Authentication Discussion',
|
||||
parentMessage: {
|
||||
author: 'Trevor',
|
||||
avatar: 'T',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #cc0000)',
|
||||
content: 'Just pushed the authentication updates. All services should automatically migrate to the new protocols within 24 hours.',
|
||||
timestamp: '10:34 AM',
|
||||
},
|
||||
replies: [
|
||||
{
|
||||
id: 1,
|
||||
author: 'Marcus',
|
||||
avatar: 'M',
|
||||
gradient: 'linear-gradient(135deg, #0066ff, #003380)',
|
||||
content: 'Great work! How does this affect the API rate limiting?',
|
||||
timestamp: '10:36 AM',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
author: 'Trevor',
|
||||
avatar: 'T',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #cc0000)',
|
||||
content: 'Rate limiting remains the same. The auth tokens now include refresh capabilities though.',
|
||||
timestamp: '10:38 AM',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
author: 'Sarah',
|
||||
avatar: 'S',
|
||||
gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)',
|
||||
content: 'Testing this on Labs now. Will report back in a few hours.',
|
||||
timestamp: '10:45 AM',
|
||||
},
|
||||
],
|
||||
memberCount: 4,
|
||||
messageCount: 3,
|
||||
};
|
||||
|
||||
const handleSendReply = () => {
|
||||
if (!replyText.trim()) return;
|
||||
// Handle sending reply
|
||||
setReplyText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="thread-panel">
|
||||
<div className="thread-header">
|
||||
<div className="thread-title">
|
||||
<span className="thread-icon">🧵</span>
|
||||
<span>{threadData.name}</span>
|
||||
</div>
|
||||
<div className="thread-meta">
|
||||
<span>{threadData.memberCount} members</span>
|
||||
<span>•</span>
|
||||
<span>{threadData.messageCount} messages</span>
|
||||
</div>
|
||||
<button className="thread-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="thread-parent">
|
||||
<div className="parent-message">
|
||||
<div className="message-avatar" style={{ background: threadData.parentMessage.gradient }}>
|
||||
{threadData.parentMessage.avatar}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<span className="message-author">{threadData.parentMessage.author}</span>
|
||||
<span className="message-time">{threadData.parentMessage.timestamp}</span>
|
||||
</div>
|
||||
<div className="message-text">{threadData.parentMessage.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="thread-line"></div>
|
||||
</div>
|
||||
|
||||
<div className="thread-replies">
|
||||
{threadData.replies.map(reply => (
|
||||
<div key={reply.id} className="thread-reply">
|
||||
<div className="message-avatar small" style={{ background: reply.gradient }}>
|
||||
{reply.avatar}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-header">
|
||||
<span className="message-author">{reply.author}</span>
|
||||
<span className="message-time">{reply.timestamp}</span>
|
||||
</div>
|
||||
<div className="message-text">{reply.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="thread-input-container">
|
||||
<input
|
||||
type="text"
|
||||
className="thread-input"
|
||||
placeholder="Reply to thread..."
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSendReply()}
|
||||
/>
|
||||
<button className="thread-send" onClick={handleSendReply}>
|
||||
➤
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/frontend/mockup/TypingIndicator.jsx
Normal file
28
src/frontend/mockup/TypingIndicator.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function TypingIndicator({ users }) {
|
||||
if (!users || users.length === 0) return null;
|
||||
|
||||
const getTypingText = () => {
|
||||
if (users.length === 1) {
|
||||
return <><strong>{users[0].name}</strong> is typing</>;
|
||||
} else if (users.length === 2) {
|
||||
return <><strong>{users[0].name}</strong> and <strong>{users[1].name}</strong> are typing</>;
|
||||
} else if (users.length === 3) {
|
||||
return <><strong>{users[0].name}</strong>, <strong>{users[1].name}</strong>, and <strong>{users[2].name}</strong> are typing</>;
|
||||
} else {
|
||||
return <><strong>Several people</strong> are typing</>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="typing-indicator">
|
||||
<div className="typing-dots">
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
<span className="dot"></span>
|
||||
</div>
|
||||
<span className="typing-text">{getTypingText()}...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/frontend/mockup/UserProfileCard.jsx
Normal file
165
src/frontend/mockup/UserProfileCard.jsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import React from 'react';
|
||||
|
||||
export default function UserProfileCard({ user, onClose, onMessage, onCall, onAddFriend }) {
|
||||
const defaultUser = {
|
||||
id: 1,
|
||||
name: 'Anderson',
|
||||
tag: 'Anderson#0001',
|
||||
initial: 'A',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)',
|
||||
banner: 'linear-gradient(135deg, #ff0000 0%, #0066ff 50%, #ffa500 100%)',
|
||||
status: 'online',
|
||||
customStatus: 'Building AeThex',
|
||||
about: 'Founder of AeThex. Building the future of metaverse communication and decentralized identity.',
|
||||
memberSince: 'Jan 2024',
|
||||
roles: [
|
||||
{ name: 'Founder', color: '#ff0000' },
|
||||
{ name: 'Foundation', color: '#ff0000' },
|
||||
],
|
||||
activity: {
|
||||
type: 'developing',
|
||||
name: 'AeThex Connect',
|
||||
details: 'Working on messaging system',
|
||||
elapsed: '2h 34m',
|
||||
},
|
||||
};
|
||||
|
||||
// Merge user prop with defaults
|
||||
const userData = { ...defaultUser, ...user };
|
||||
|
||||
const statusColors = {
|
||||
online: '#3ba55d',
|
||||
idle: '#faa61a',
|
||||
dnd: '#ed4245',
|
||||
offline: '#747f8d',
|
||||
'in-game': '#5865f2',
|
||||
labs: '#ffa500',
|
||||
};
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className="user-profile-card">
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div className="profile-banner" style={{ background: userData.banner || '#2f3136' }}></div>
|
||||
|
||||
<div className="profile-avatar-section">
|
||||
<div className="profile-avatar-wrapper">
|
||||
<div className="profile-avatar" style={{ background: userData.gradient || '#36393f' }}>
|
||||
{userData.initial || userData.name?.charAt(0) || '?'}
|
||||
</div>
|
||||
<div
|
||||
className="profile-status-indicator"
|
||||
style={{ background: statusColors[userData.status] || statusColors.offline }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="profile-badges">
|
||||
{(userData.roles || []).slice(0, 3).map((role, idx) => (
|
||||
<span key={idx} className="profile-badge" style={{ background: role.color }}>
|
||||
{role.name.charAt(0)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-info">
|
||||
<h2 className="profile-name">{userData.name}</h2>
|
||||
<span className="profile-tag">{userData.tag || userData.name}</span>
|
||||
|
||||
{userData.customStatus && (
|
||||
<div className="profile-custom-status">
|
||||
<span className="status-emoji">💻</span>
|
||||
{userData.customStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="profile-divider"></div>
|
||||
|
||||
{userData.activity && (
|
||||
<div className="profile-section">
|
||||
<h3 className="section-title">ACTIVITY</h3>
|
||||
<div className="activity-card">
|
||||
<div className="activity-icon">🔨</div>
|
||||
<div className="activity-info">
|
||||
<div className="activity-name">{userData.activity.name || userData.activity}</div>
|
||||
<div className="activity-details">{userData.activity.details || ''}</div>
|
||||
<div className="activity-elapsed">{userData.activity.elapsed || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userData.about && (
|
||||
<div className="profile-section">
|
||||
<h3 className="section-title">ABOUT ME</h3>
|
||||
<p className="about-text">{userData.about}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-section">
|
||||
<h3 className="section-title">MEMBER SINCE</h3>
|
||||
<p className="member-since">{userData.memberSince || 'Jan 2024'}</p>
|
||||
</div>
|
||||
|
||||
{userData.roles && userData.roles.length > 0 && (
|
||||
<div className="profile-section">
|
||||
<h3 className="section-title">ROLES — {userData.roles.length}</h3>
|
||||
<div className="roles-list">
|
||||
{userData.roles.map((role, idx) => (
|
||||
<span key={idx} className="role-tag" style={{ borderColor: role.color }}>
|
||||
<span className="role-dot" style={{ background: role.color }}></span>
|
||||
{role.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="profile-section">
|
||||
<h3 className="section-title">NOTE</h3>
|
||||
<textarea
|
||||
className="note-input"
|
||||
placeholder="Click to add a note"
|
||||
defaultValue=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="profile-actions">
|
||||
<button className="profile-btn primary" onClick={() => onMessage?.()}>
|
||||
💬 Message
|
||||
</button>
|
||||
<button className="profile-btn" onClick={() => onCall?.()}>
|
||||
📞 Call
|
||||
</button>
|
||||
<button className="profile-btn" onClick={() => onAddFriend?.()}>
|
||||
👋 Add Friend
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/frontend/mockup/UserSettingsBar.jsx
Normal file
105
src/frontend/mockup/UserSettingsBar.jsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function UserSettingsBar({ user, onSettingsClick }) {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
const [showStatusMenu, setShowStatusMenu] = useState(false);
|
||||
const [status, setStatus] = useState('online');
|
||||
|
||||
const currentUser = user || {
|
||||
name: 'Anderson',
|
||||
tag: '#0001',
|
||||
avatar: 'A',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)',
|
||||
};
|
||||
|
||||
const statuses = [
|
||||
{ id: 'online', label: 'Online', color: '#3ba55d' },
|
||||
{ id: 'idle', label: 'Idle', color: '#faa61a' },
|
||||
{ id: 'dnd', label: 'Do Not Disturb', color: '#ed4245' },
|
||||
{ id: 'invisible', label: 'Invisible', color: '#747f8d' },
|
||||
];
|
||||
|
||||
const toggleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
if (isDeafened && !isMuted) setIsDeafened(false);
|
||||
};
|
||||
|
||||
const toggleDeafen = () => {
|
||||
setIsDeafened(!isDeafened);
|
||||
if (!isDeafened) setIsMuted(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="user-settings-bar">
|
||||
<div className="user-info-section" onClick={() => setShowStatusMenu(!showStatusMenu)}>
|
||||
<div className="user-avatar-container">
|
||||
<div
|
||||
className="user-avatar-small"
|
||||
style={{ background: currentUser.gradient }}
|
||||
>
|
||||
{currentUser.avatar}
|
||||
</div>
|
||||
<div
|
||||
className="status-indicator"
|
||||
style={{ background: statuses.find(s => s.id === status)?.color }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<div className="user-display-name">{currentUser.name}</div>
|
||||
<div className="user-tag">{currentUser.tag}</div>
|
||||
</div>
|
||||
|
||||
{showStatusMenu && (
|
||||
<div className="status-menu">
|
||||
{statuses.map(s => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="status-option"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setStatus(s.id);
|
||||
setShowStatusMenu(false);
|
||||
}}
|
||||
>
|
||||
<div className="status-dot" style={{ background: s.color }}></div>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="status-divider"></div>
|
||||
<div className="status-option">
|
||||
<span>📝</span>
|
||||
<span>Set Custom Status</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="user-controls">
|
||||
<button
|
||||
className={`control-button ${isMuted ? 'active' : ''}`}
|
||||
onClick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? '🔇' : '🎤'}
|
||||
{isMuted && <div className="slash-overlay"></div>}
|
||||
</button>
|
||||
<button
|
||||
className={`control-button ${isDeafened ? 'active' : ''}`}
|
||||
onClick={toggleDeafen}
|
||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
{isDeafened ? '🔇' : '🎧'}
|
||||
{isDeafened && <div className="slash-overlay"></div>}
|
||||
</button>
|
||||
<button
|
||||
className="control-button"
|
||||
title="User Settings"
|
||||
onClick={onSettingsClick}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
src/frontend/mockup/UserSettingsModal.jsx
Normal file
375
src/frontend/mockup/UserSettingsModal.jsx
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function UserSettingsModal({ onClose }) {
|
||||
const [activeTab, setActiveTab] = useState('my-account');
|
||||
|
||||
const settingsSections = [
|
||||
{
|
||||
title: 'USER SETTINGS',
|
||||
items: [
|
||||
{ id: 'my-account', label: 'My Account', icon: '👤' },
|
||||
{ id: 'profiles', label: 'Profiles', icon: '🎨' },
|
||||
{ id: 'privacy', label: 'Privacy & Safety', icon: '🔒' },
|
||||
{ id: 'family', label: 'Family Center', icon: '👨👩👧' },
|
||||
{ id: 'authorized-apps', label: 'Authorized Apps', icon: '🔌' },
|
||||
{ id: 'devices', label: 'Devices', icon: '📱' },
|
||||
{ id: 'connections', label: 'Connections', icon: '🔗' },
|
||||
{ id: 'clips', label: 'Clips', icon: '🎬' },
|
||||
{ id: 'friend-requests', label: 'Friend Requests', icon: '👋' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'BILLING SETTINGS',
|
||||
items: [
|
||||
{ id: 'nitro', label: 'Nitro', icon: '🚀' },
|
||||
{ id: 'server-boost', label: 'Server Boost', icon: '💎' },
|
||||
{ id: 'subscriptions', label: 'Subscriptions', icon: '📋' },
|
||||
{ id: 'gift-inventory', label: 'Gift Inventory', icon: '🎁' },
|
||||
{ id: 'billing', label: 'Billing', icon: '💳' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'APP SETTINGS',
|
||||
items: [
|
||||
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
|
||||
{ id: 'accessibility', label: 'Accessibility', icon: '♿' },
|
||||
{ id: 'voice-video', label: 'Voice & Video', icon: '🎙️' },
|
||||
{ id: 'text-images', label: 'Text & Images', icon: '📝' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: '🔔' },
|
||||
{ id: 'keybinds', label: 'Keybinds', icon: '⌨️' },
|
||||
{ id: 'language', label: 'Language', icon: '🌐' },
|
||||
{ id: 'streamer-mode', label: 'Streamer Mode', icon: '📺' },
|
||||
{ id: 'advanced', label: 'Advanced', icon: '⚙️' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'ACTIVITY SETTINGS',
|
||||
items: [
|
||||
{ id: 'activity-privacy', label: 'Activity Privacy', icon: '👁️' },
|
||||
{ id: 'registered-games', label: 'Registered Games', icon: '🎮' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick}>
|
||||
<div className="user-settings-modal">
|
||||
<div className="settings-sidebar">
|
||||
<div className="settings-nav">
|
||||
{settingsSections.map((section) => (
|
||||
<div key={section.title}>
|
||||
<div className="settings-section-title">{section.title}</div>
|
||||
{section.items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`settings-nav-item ${activeTab === item.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(item.id)}
|
||||
>
|
||||
<span className="nav-icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="settings-divider"></div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="settings-nav-item danger">
|
||||
<span className="nav-icon">🚪</span>
|
||||
<span>Log Out</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
<div className="settings-header">
|
||||
<h2>{settingsSections.flatMap((s) => s.items).find((i) => i.id === activeTab)?.label}</h2>
|
||||
<button className="settings-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'my-account' && (
|
||||
<div className="settings-overview">
|
||||
<div className="account-card">
|
||||
<div className="account-banner" style={{ background: 'linear-gradient(135deg, #ff0000, #0066ff)' }}></div>
|
||||
<div className="account-info">
|
||||
<div className="account-avatar" style={{ background: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)' }}>A</div>
|
||||
<div className="account-details">
|
||||
<h3>Anderson</h3>
|
||||
<span className="account-tag">Anderson#0001</span>
|
||||
</div>
|
||||
<button className="edit-profile-btn">Edit User Profile</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="account-fields">
|
||||
<div className="account-field">
|
||||
<label>DISPLAY NAME</label>
|
||||
<div className="field-value">
|
||||
<span>Anderson</span>
|
||||
<button className="field-edit">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-field">
|
||||
<label>USERNAME</label>
|
||||
<div className="field-value">
|
||||
<span>anderson</span>
|
||||
<button className="field-edit">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-field">
|
||||
<label>EMAIL</label>
|
||||
<div className="field-value">
|
||||
<span>a*****@aethex.dev</span>
|
||||
<button className="field-edit">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="account-field">
|
||||
<label>PHONE NUMBER</label>
|
||||
<div className="field-value">
|
||||
<span>*******4567</span>
|
||||
<button className="field-edit">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
<div className="setting-group">
|
||||
<h4>Password and Authentication</h4>
|
||||
<button className="settings-btn">Change Password</button>
|
||||
<div className="two-factor">
|
||||
<div className="two-factor-info">
|
||||
<h5>Two-Factor Authentication</h5>
|
||||
<p>Protect your AeThex account with an extra layer of security.</p>
|
||||
</div>
|
||||
<button className="settings-btn primary">Enable 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-divider"></div>
|
||||
|
||||
<div className="setting-group danger-zone">
|
||||
<h4>Account Removal</h4>
|
||||
<p>Disabling your account means you can recover it at any time after taking this action.</p>
|
||||
<div className="danger-buttons">
|
||||
<button className="settings-btn danger-outline">Disable Account</button>
|
||||
<button className="settings-btn danger">Delete Account</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'appearance' && (
|
||||
<div className="settings-overview">
|
||||
<div className="setting-group">
|
||||
<h4>Theme</h4>
|
||||
<div className="theme-options">
|
||||
<label className="theme-option">
|
||||
<input type="radio" name="theme" defaultChecked />
|
||||
<span className="theme-preview dark">Dark</span>
|
||||
</label>
|
||||
<label className="theme-option">
|
||||
<input type="radio" name="theme" />
|
||||
<span className="theme-preview light">Light</span>
|
||||
</label>
|
||||
<label className="theme-option">
|
||||
<input type="radio" name="theme" />
|
||||
<span className="theme-preview sync">Sync with computer</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<h4>Message Display</h4>
|
||||
<div className="display-options">
|
||||
<label className="display-option">
|
||||
<input type="radio" name="display" defaultChecked />
|
||||
<div className="display-preview cozy">
|
||||
<span>Cozy</span>
|
||||
<p>Discord text as it was meant to be.</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="display-option">
|
||||
<input type="radio" name="display" />
|
||||
<div className="display-preview compact">
|
||||
<span>Compact</span>
|
||||
<p>Fit more messages on screen at once.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Chat Font Scaling — 16px</label>
|
||||
<input type="range" min="12" max="24" defaultValue="16" className="font-slider" />
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Space Between Message Groups — 16px</label>
|
||||
<input type="range" min="0" max="24" defaultValue="16" className="font-slider" />
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>Zoom Level — 100%</label>
|
||||
<input type="range" min="50" max="200" defaultValue="100" className="font-slider" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'voice-video' && (
|
||||
<div className="settings-overview">
|
||||
<div className="setting-group">
|
||||
<h4>Voice Settings</h4>
|
||||
<label>INPUT DEVICE</label>
|
||||
<select className="setting-select">
|
||||
<option>Default — Built-in Microphone</option>
|
||||
<option>External Microphone</option>
|
||||
</select>
|
||||
|
||||
<label>OUTPUT DEVICE</label>
|
||||
<select className="setting-select">
|
||||
<option>Default — Built-in Speakers</option>
|
||||
<option>External Speakers</option>
|
||||
<option>Headphones</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>INPUT VOLUME</label>
|
||||
<input type="range" min="0" max="100" defaultValue="80" className="volume-slider" />
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label>OUTPUT VOLUME</label>
|
||||
<input type="range" min="0" max="100" defaultValue="100" className="volume-slider" />
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<h4>Input Mode</h4>
|
||||
<div className="input-mode-options">
|
||||
<label className="input-mode">
|
||||
<input type="radio" name="inputMode" defaultChecked />
|
||||
<span>Voice Activity</span>
|
||||
</label>
|
||||
<label className="input-mode">
|
||||
<input type="radio" name="inputMode" />
|
||||
<span>Push to Talk</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="toggle-setting">
|
||||
<span>Echo Cancellation</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>Noise Suppression</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>Automatic Gain Control</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<h4>Video Settings</h4>
|
||||
<label>CAMERA</label>
|
||||
<select className="setting-select">
|
||||
<option>FaceTime HD Camera</option>
|
||||
<option>External Webcam</option>
|
||||
</select>
|
||||
<button className="settings-btn">Test Video</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="settings-overview">
|
||||
<div className="setting-group">
|
||||
<label className="toggle-setting">
|
||||
<span>Enable Desktop Notifications</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>Enable Unread Message Badge</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>Enable Taskbar Flashing</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<h4>Sounds</h4>
|
||||
<label className="toggle-setting">
|
||||
<span>Message</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>Deafen</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>Mute</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>User Join</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
<label className="toggle-setting">
|
||||
<span>User Leave</span>
|
||||
<input type="checkbox" defaultChecked />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'keybinds' && (
|
||||
<div className="settings-overview">
|
||||
<div className="keybinds-header">
|
||||
<button className="settings-btn primary">Add a Keybind</button>
|
||||
</div>
|
||||
|
||||
<div className="keybind-list">
|
||||
<div className="keybind-item">
|
||||
<div className="keybind-action">Push to Talk</div>
|
||||
<div className="keybind-key">` (backtick)</div>
|
||||
<button className="keybind-edit">✏️</button>
|
||||
<button className="keybind-delete">🗑️</button>
|
||||
</div>
|
||||
<div className="keybind-item">
|
||||
<div className="keybind-action">Toggle Mute</div>
|
||||
<div className="keybind-key">Ctrl + Shift + M</div>
|
||||
<button className="keybind-edit">✏️</button>
|
||||
<button className="keybind-delete">🗑️</button>
|
||||
</div>
|
||||
<div className="keybind-item">
|
||||
<div className="keybind-action">Toggle Deafen</div>
|
||||
<div className="keybind-key">Ctrl + Shift + D</div>
|
||||
<button className="keybind-edit">✏️</button>
|
||||
<button className="keybind-delete">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!['my-account', 'appearance', 'voice-video', 'notifications', 'keybinds'].includes(activeTab) && (
|
||||
<div className="settings-placeholder">
|
||||
<span className="placeholder-icon">🔧</span>
|
||||
<p>{settingsSections.flatMap((s) => s.items).find((i) => i.id === activeTab)?.label} settings coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
src/frontend/mockup/VideoCall.jsx
Normal file
206
src/frontend/mockup/VideoCall.jsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function VideoCall({ users, onLeave, onToggleMute, onToggleDeafen, onToggleVideo, onToggleScreenShare }) {
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isDeafened, setIsDeafened] = useState(false);
|
||||
const [isVideoOn, setIsVideoOn] = useState(true);
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [focusedUser, setFocusedUser] = useState(null);
|
||||
|
||||
const participants = users || [
|
||||
{ id: 1, name: 'Anderson', avatar: 'A', gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)', speaking: true, muted: false, video: true },
|
||||
{ id: 2, name: 'Trevor', avatar: 'T', gradient: 'linear-gradient(135deg, #ff0000, #cc0000)', speaking: false, muted: true, video: true },
|
||||
{ id: 3, name: 'Sarah', avatar: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)', speaking: false, muted: false, video: false, streaming: true },
|
||||
{ id: 4, name: 'Marcus', avatar: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)', speaking: false, muted: false, video: false },
|
||||
];
|
||||
|
||||
const handleMute = () => {
|
||||
setIsMuted(!isMuted);
|
||||
onToggleMute?.(!isMuted);
|
||||
};
|
||||
|
||||
const handleDeafen = () => {
|
||||
setIsDeafened(!isDeafened);
|
||||
onToggleDeafen?.(!isDeafened);
|
||||
};
|
||||
|
||||
const handleVideo = () => {
|
||||
setIsVideoOn(!isVideoOn);
|
||||
onToggleVideo?.(!isVideoOn);
|
||||
};
|
||||
|
||||
const handleScreenShare = () => {
|
||||
setIsScreenSharing(!isScreenSharing);
|
||||
onToggleScreenShare?.(!isScreenSharing);
|
||||
};
|
||||
|
||||
const focusedParticipant = focusedUser ? participants.find((p) => p.id === focusedUser) : participants.find((p) => p.streaming) || participants[0];
|
||||
const gridParticipants = participants.filter((p) => p.id !== focusedParticipant?.id);
|
||||
|
||||
return (
|
||||
<div className={`video-call-container ${isFullscreen ? 'fullscreen' : ''}`}>
|
||||
{/* Main/Focused Video Area */}
|
||||
<div className="video-main">
|
||||
<div className="video-focused">
|
||||
{focusedParticipant?.streaming ? (
|
||||
<div className="screen-share-view">
|
||||
<div className="screen-content">
|
||||
<div className="screen-placeholder">
|
||||
<span className="screen-icon">🖥️</span>
|
||||
<span>{focusedParticipant.name}'s screen</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="streamer-pip">
|
||||
<div
|
||||
className="pip-avatar"
|
||||
style={{ background: focusedParticipant.gradient }}
|
||||
>
|
||||
{focusedParticipant.avatar}
|
||||
</div>
|
||||
<span className="pip-name">{focusedParticipant.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : focusedParticipant?.video ? (
|
||||
<div className="video-feed">
|
||||
<div className="video-placeholder">
|
||||
<div
|
||||
className="video-avatar-large"
|
||||
style={{ background: focusedParticipant.gradient }}
|
||||
>
|
||||
{focusedParticipant.avatar}
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-name-overlay">{focusedParticipant.name}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="video-audio-only">
|
||||
<div
|
||||
className={`audio-avatar ${focusedParticipant?.speaking ? 'speaking' : ''}`}
|
||||
style={{ background: focusedParticipant?.gradient }}
|
||||
>
|
||||
{focusedParticipant?.avatar}
|
||||
</div>
|
||||
<div className="audio-name">{focusedParticipant?.name}</div>
|
||||
{focusedParticipant?.muted && <div className="audio-muted">🔇</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Participant Grid */}
|
||||
<div className="video-grid">
|
||||
{gridParticipants.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className={`video-tile ${participant.speaking ? 'speaking' : ''}`}
|
||||
onClick={() => setFocusedUser(participant.id)}
|
||||
>
|
||||
{participant.video ? (
|
||||
<div className="tile-video">
|
||||
<div
|
||||
className="tile-avatar"
|
||||
style={{ background: participant.gradient }}
|
||||
>
|
||||
{participant.avatar}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tile-audio">
|
||||
<div
|
||||
className="tile-avatar"
|
||||
style={{ background: participant.gradient }}
|
||||
>
|
||||
{participant.avatar}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="tile-info">
|
||||
<span className="tile-name">{participant.name}</span>
|
||||
<div className="tile-icons">
|
||||
{participant.muted && <span>🔇</span>}
|
||||
{participant.streaming && <span>🖥️</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Bar */}
|
||||
<div className="video-controls">
|
||||
<div className="controls-left">
|
||||
<div className="call-info">
|
||||
<span className="call-channel">🔊 Nexus Lounge</span>
|
||||
<span className="call-duration">1:23:45</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="controls-center">
|
||||
<button
|
||||
className={`control-btn ${isMuted ? 'active' : ''}`}
|
||||
onClick={handleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
>
|
||||
{isMuted ? '🔇' : '🎙️'}
|
||||
</button>
|
||||
<button
|
||||
className={`control-btn ${isDeafened ? 'active' : ''}`}
|
||||
onClick={handleDeafen}
|
||||
title={isDeafened ? 'Undeafen' : 'Deafen'}
|
||||
>
|
||||
{isDeafened ? '🔈' : '🔊'}
|
||||
</button>
|
||||
<button
|
||||
className={`control-btn ${isVideoOn ? 'active' : ''}`}
|
||||
onClick={handleVideo}
|
||||
title={isVideoOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
||||
>
|
||||
{isVideoOn ? '📹' : '📷'}
|
||||
</button>
|
||||
<button
|
||||
className={`control-btn ${isScreenSharing ? 'active sharing' : ''}`}
|
||||
onClick={handleScreenShare}
|
||||
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
|
||||
>
|
||||
🖥️
|
||||
</button>
|
||||
<button
|
||||
className="control-btn activities"
|
||||
title="Activities"
|
||||
>
|
||||
🎮
|
||||
</button>
|
||||
<button
|
||||
className="control-btn leave"
|
||||
onClick={onLeave}
|
||||
title="Leave Call"
|
||||
>
|
||||
📞
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="controls-right">
|
||||
<button
|
||||
className="control-btn"
|
||||
title="Show Participants"
|
||||
>
|
||||
👥 {participants.length}
|
||||
</button>
|
||||
<button
|
||||
className="control-btn"
|
||||
title="Chat"
|
||||
>
|
||||
💬
|
||||
</button>
|
||||
<button
|
||||
className={`control-btn ${isFullscreen ? 'active' : ''}`}
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? '⛶' : '⛶'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
src/frontend/mockup/VoiceChannel.jsx
Normal file
73
src/frontend/mockup/VoiceChannel.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function VoiceChannel({ channel, onJoin, onLeave }) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const voiceChannel = channel || {
|
||||
name: 'Nexus Lounge',
|
||||
users: [
|
||||
{ id: 1, name: 'Anderson', avatar: 'A', gradient: 'linear-gradient(135deg, #ff0000, #0066ff, #ffa500)', speaking: true, muted: false, deafened: false },
|
||||
{ id: 2, name: 'Sarah', avatar: 'S', gradient: 'linear-gradient(135deg, #ffa500, #ff8c00)', speaking: false, muted: true, deafened: false },
|
||||
{ id: 3, name: 'Marcus', avatar: 'M', gradient: 'linear-gradient(135deg, #0066ff, #003380)', speaking: false, muted: false, deafened: false, streaming: true },
|
||||
],
|
||||
limit: 10,
|
||||
bitrate: 64,
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
setIsConnected(!isConnected);
|
||||
if (!isConnected && onJoin) onJoin(voiceChannel);
|
||||
if (isConnected && onLeave) onLeave();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="voice-channel-container">
|
||||
<div className="voice-channel-header" onClick={() => setIsExpanded(!isExpanded)}>
|
||||
<span className="voice-icon">🔊</span>
|
||||
<span className="voice-channel-name">{voiceChannel.name}</span>
|
||||
<span className="voice-user-count">{voiceChannel.users.length}/{voiceChannel.limit}</span>
|
||||
<span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="voice-users-list">
|
||||
{voiceChannel.users.map(user => (
|
||||
<div key={user.id} className={`voice-user ${user.speaking ? 'speaking' : ''}`}>
|
||||
<div className="voice-user-avatar" style={{ background: user.gradient }}>
|
||||
{user.avatar}
|
||||
{user.speaking && <div className="speaking-ring"></div>}
|
||||
</div>
|
||||
<span className="voice-user-name">{user.name}</span>
|
||||
<div className="voice-user-icons">
|
||||
{user.streaming && <span className="stream-icon" title="Streaming">📺</span>}
|
||||
{user.muted && <span className="muted-icon" title="Muted">🔇</span>}
|
||||
{user.deafened && <span className="deafened-icon" title="Deafened">🔈</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnected && (
|
||||
<div className="voice-connected-panel">
|
||||
<div className="voice-status">
|
||||
<span className="connected-text">Voice Connected</span>
|
||||
<span className="voice-ping">23ms</span>
|
||||
</div>
|
||||
<div className="voice-controls">
|
||||
<button className="voice-control-btn" title="Disconnect" onClick={handleConnect}>
|
||||
📞
|
||||
</button>
|
||||
<button className="voice-control-btn" title="Start Video">
|
||||
📹
|
||||
</button>
|
||||
<button className="voice-control-btn" title="Share Screen">
|
||||
🖥️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
193
src/frontend/mockup/WelcomeScreen.jsx
Normal file
193
src/frontend/mockup/WelcomeScreen.jsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
export default function WelcomeScreen({ server, onComplete, onClose }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const [selectedRoles, setSelectedRoles] = useState([]);
|
||||
const [selectedChannels, setSelectedChannels] = useState([]);
|
||||
|
||||
const serverData = server || {
|
||||
name: 'AeThex Foundation',
|
||||
icon: 'F',
|
||||
gradient: 'linear-gradient(135deg, #ff0000, #990000)',
|
||||
description: 'Official home of AeThex. Security, authentication, and core infrastructure.',
|
||||
memberCount: '128.5K',
|
||||
rules: [
|
||||
{ id: 1, title: 'Be Respectful', description: 'Treat everyone with respect. No harassment, bullying, or hate speech.' },
|
||||
{ id: 2, title: 'No Spam', description: 'Avoid excessive messages, self-promotion, or unsolicited advertisements.' },
|
||||
{ id: 3, title: 'Stay On Topic', description: 'Keep discussions relevant to the channel topic.' },
|
||||
{ id: 4, title: 'No NSFW Content', description: 'This is a SFW community. Keep content appropriate.' },
|
||||
{ id: 5, title: 'Follow Discord TOS', description: 'Adhere to Discord\'s Terms of Service and Community Guidelines.' },
|
||||
],
|
||||
};
|
||||
|
||||
const roleOptions = [
|
||||
{ id: 'developer', label: '💻 Developer', description: 'I build things with AeThex tools' },
|
||||
{ id: 'gamer', label: '🎮 Gamer', description: 'I\'m here for gaming content' },
|
||||
{ id: 'security', label: '🔒 Security', description: 'Interested in security topics' },
|
||||
{ id: 'creator', label: '🎨 Creator', description: 'I create content' },
|
||||
{ id: 'announcements', label: '📢 Announcements Only', description: 'Just here for updates' },
|
||||
];
|
||||
|
||||
const channelOptions = [
|
||||
{ id: 'general', label: '# general', description: 'Main community chat' },
|
||||
{ id: 'development', label: '# development', description: 'Dev discussions' },
|
||||
{ id: 'gaming', label: '# gaming', description: 'Gaming chat' },
|
||||
{ id: 'off-topic', label: '# off-topic', description: 'Random discussions' },
|
||||
{ id: 'showcase', label: '# showcase', description: 'Share your projects' },
|
||||
];
|
||||
|
||||
const steps = [
|
||||
{ id: 'welcome', title: 'Welcome' },
|
||||
{ id: 'rules', title: 'Rules' },
|
||||
{ id: 'roles', title: 'Pick Roles' },
|
||||
{ id: 'channels', title: 'Channels' },
|
||||
];
|
||||
|
||||
const handleComplete = () => {
|
||||
onComplete?.({
|
||||
roles: selectedRoles,
|
||||
channels: selectedChannels,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRole = (roleId) => {
|
||||
setSelectedRoles(prev =>
|
||||
prev.includes(roleId)
|
||||
? prev.filter(id => id !== roleId)
|
||||
: [...prev, roleId]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleChannel = (channelId) => {
|
||||
setSelectedChannels(prev =>
|
||||
prev.includes(channelId)
|
||||
? prev.filter(id => id !== channelId)
|
||||
: [...prev, channelId]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="welcome-overlay">
|
||||
<div className="welcome-screen">
|
||||
{/* Progress dots */}
|
||||
<div className="welcome-progress">
|
||||
{steps.map((s, idx) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`progress-dot ${idx === step ? 'active' : ''} ${idx < step ? 'completed' : ''}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className="welcome-step">
|
||||
<div
|
||||
className="welcome-server-icon"
|
||||
style={{ background: serverData.gradient }}
|
||||
>
|
||||
{serverData.icon}
|
||||
</div>
|
||||
<h1>Welcome to {serverData.name}!</h1>
|
||||
<p className="welcome-description">{serverData.description}</p>
|
||||
<div className="welcome-stats">
|
||||
<span>👥 {serverData.memberCount} members</span>
|
||||
</div>
|
||||
<button className="welcome-btn primary" onClick={() => setStep(1)}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="welcome-step">
|
||||
<h2>📜 Server Rules</h2>
|
||||
<p className="step-subtitle">Please read and agree to our community rules</p>
|
||||
<div className="rules-list">
|
||||
{serverData.rules.map((rule, idx) => (
|
||||
<div key={rule.id} className="rule-item">
|
||||
<span className="rule-number">{idx + 1}</span>
|
||||
<div className="rule-content">
|
||||
<h4>{rule.title}</h4>
|
||||
<p>{rule.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn secondary" onClick={() => setStep(0)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="welcome-btn primary" onClick={() => setStep(2)}>
|
||||
I Agree
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="welcome-step">
|
||||
<h2>🎭 Pick Your Roles</h2>
|
||||
<p className="step-subtitle">Select roles that match your interests</p>
|
||||
<div className="options-grid">
|
||||
{roleOptions.map(role => (
|
||||
<button
|
||||
key={role.id}
|
||||
className={`option-btn ${selectedRoles.includes(role.id) ? 'selected' : ''}`}
|
||||
onClick={() => toggleRole(role.id)}
|
||||
>
|
||||
<span className="option-label">{role.label}</span>
|
||||
<span className="option-description">{role.description}</span>
|
||||
{selectedRoles.includes(role.id) && (
|
||||
<span className="option-check">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn secondary" onClick={() => setStep(1)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="welcome-btn primary" onClick={() => setStep(3)}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="welcome-step">
|
||||
<h2>📺 Choose Channels</h2>
|
||||
<p className="step-subtitle">Pick channels you want to see</p>
|
||||
<div className="options-grid">
|
||||
{channelOptions.map(channel => (
|
||||
<button
|
||||
key={channel.id}
|
||||
className={`option-btn ${selectedChannels.includes(channel.id) ? 'selected' : ''}`}
|
||||
onClick={() => toggleChannel(channel.id)}
|
||||
>
|
||||
<span className="option-label">{channel.label}</span>
|
||||
<span className="option-description">{channel.description}</span>
|
||||
{selectedChannels.includes(channel.id) && (
|
||||
<span className="option-check">✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="welcome-actions">
|
||||
<button className="welcome-btn secondary" onClick={() => setStep(2)}>
|
||||
Back
|
||||
</button>
|
||||
<button className="welcome-btn primary" onClick={handleComplete}>
|
||||
Finish Setup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="skip-btn" onClick={onClose}>
|
||||
Skip for now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/frontend/mockup/contexts/AuthContext.jsx
Normal file
84
src/frontend/mockup/contexts/AuthContext.jsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for stored session on mount
|
||||
const stored = localStorage.getItem('aethex_user');
|
||||
if (stored) {
|
||||
try {
|
||||
setUser(JSON.parse(stored));
|
||||
} catch (e) {
|
||||
localStorage.removeItem('aethex_user');
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = (userData) => {
|
||||
const userWithDefaults = {
|
||||
id: Date.now(),
|
||||
email: userData.email,
|
||||
username: userData.username || userData.email.split('@')[0],
|
||||
displayName: userData.displayName || userData.email.split('@')[0],
|
||||
avatar: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...userData,
|
||||
};
|
||||
setUser(userWithDefaults);
|
||||
localStorage.setItem('aethex_user', JSON.stringify(userWithDefaults));
|
||||
};
|
||||
|
||||
const register = (userData) => {
|
||||
const newUser = {
|
||||
id: Date.now(),
|
||||
email: userData.email,
|
||||
username: userData.username,
|
||||
displayName: userData.displayName || userData.username,
|
||||
avatar: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...userData,
|
||||
};
|
||||
setUser(newUser);
|
||||
localStorage.setItem('aethex_user', JSON.stringify(newUser));
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('aethex_user');
|
||||
};
|
||||
|
||||
const updateUser = (updates) => {
|
||||
const updated = { ...user, ...updates };
|
||||
setUser(updated);
|
||||
localStorage.setItem('aethex_user', JSON.stringify(updated));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
loading,
|
||||
isAuthenticated: !!user,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateUser,
|
||||
}}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
|
|
@ -1,13 +1,92 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { AuthProvider, useAuth } from "./contexts/AuthContext";
|
||||
import MainLayout from "./MainLayout";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import RegisterPage from "./pages/RegisterPage";
|
||||
import AboutPage from "./pages/AboutPage";
|
||||
import FeaturesPage from "./pages/FeaturesPage";
|
||||
import "./global.css";
|
||||
|
||||
// Protected route wrapper
|
||||
function ProtectedRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return isAuthenticated ? children : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
// Public route - redirect to app if already logged in
|
||||
function PublicRoute({ children }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-spinner"></div>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return !isAuthenticated ? children : <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { login, register, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public pages */}
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
|
||||
{/* Auth pages */}
|
||||
<Route path="/login" element={
|
||||
<PublicRoute>
|
||||
<LoginPage onLogin={login} />
|
||||
</PublicRoute>
|
||||
} />
|
||||
<Route path="/register" element={
|
||||
<PublicRoute>
|
||||
<RegisterPage onRegister={register} />
|
||||
</PublicRoute>
|
||||
} />
|
||||
|
||||
{/* Protected app */}
|
||||
<Route path="/app" element={
|
||||
<ProtectedRoute>
|
||||
<MainLayout onLogout={logout} />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<MainLayout />
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
10311
src/frontend/mockup/mockup.css
Normal file
10311
src/frontend/mockup/mockup.css
Normal file
File diff suppressed because it is too large
Load diff
163
src/frontend/mockup/pages/AboutPage.jsx
Normal file
163
src/frontend/mockup/pages/AboutPage.jsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function AboutPage() {
|
||||
const team = [
|
||||
{ name: 'Trevor', role: 'Founder & Lead Developer', avatar: 'T', color: '#ff0000' },
|
||||
{ name: 'Open Source', role: 'Community Contributors', avatar: '👥', color: '#5865f2' },
|
||||
];
|
||||
|
||||
const timeline = [
|
||||
{ year: '2024', event: 'AeThex Foundation established', desc: 'Core infrastructure and passport system development begins' },
|
||||
{ year: '2025', event: 'AeThex Connect launched', desc: 'Open source Discord alternative enters alpha' },
|
||||
{ year: '2026', event: 'Public beta release', desc: 'Full feature parity achieved, community growth accelerates' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="info-page">
|
||||
<nav className="info-nav">
|
||||
<Link to="/" className="nav-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span className="brand-text">AeThex Connect</span>
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<Link to="/features">Features</Link>
|
||||
<Link to="/about" className="active">About</Link>
|
||||
<a href="https://github.com/AeThex-Corporation" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</div>
|
||||
<div className="nav-auth">
|
||||
<Link to="/login" className="nav-login">Login</Link>
|
||||
<Link to="/register" className="nav-register">Get Started</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="info-content">
|
||||
<section className="about-hero">
|
||||
<h1>About <span className="gradient-text">AeThex</span></h1>
|
||||
<p className="hero-desc">
|
||||
Building the future of decentralized communication, one line of code at a time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="about-section">
|
||||
<h2>Our Mission</h2>
|
||||
<div className="mission-content">
|
||||
<div className="mission-text">
|
||||
<p>
|
||||
AeThex was founded on a simple belief: <strong>your data belongs to you</strong>.
|
||||
In a world where major platforms profit from your conversations, we chose a different path.
|
||||
</p>
|
||||
<p>
|
||||
AeThex Connect is our answer to the centralized communication monopoly.
|
||||
It's not just an alternative to Discord—it's a statement that privacy, security,
|
||||
and user ownership can coexist with a world-class communication experience.
|
||||
</p>
|
||||
<p>
|
||||
As an open-source project, every line of our code is public.
|
||||
We believe transparency isn't just nice to have—it's essential for trust.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mission-values">
|
||||
<div className="value-card">
|
||||
<span className="value-icon">🔒</span>
|
||||
<h3>Privacy First</h3>
|
||||
<p>End-to-end encryption by default. We can't read your messages even if we wanted to.</p>
|
||||
</div>
|
||||
<div className="value-card">
|
||||
<span className="value-icon">🌐</span>
|
||||
<h3>Open Source</h3>
|
||||
<p>100% of our code is public. Audit it, fork it, contribute to it.</p>
|
||||
</div>
|
||||
<div className="value-card">
|
||||
<span className="value-icon">⚡</span>
|
||||
<h3>User Owned</h3>
|
||||
<p>Your identity, your data, your control. Forever.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-section trinity-section">
|
||||
<h2>The Trinity</h2>
|
||||
<p className="section-intro">AeThex operates as three interconnected divisions</p>
|
||||
<div className="trinity-grid">
|
||||
<div className="trinity-card foundation">
|
||||
<div className="trinity-icon">🏛️</div>
|
||||
<h3>Foundation</h3>
|
||||
<p>The core. Authentication, security infrastructure, and the Passport identity system.</p>
|
||||
</div>
|
||||
<div className="trinity-card corporation">
|
||||
<div className="trinity-icon">🏢</div>
|
||||
<h3>Corporation</h3>
|
||||
<p>Enterprise solutions, business integrations, and commercial support services.</p>
|
||||
</div>
|
||||
<div className="trinity-card labs">
|
||||
<div className="trinity-icon">🔬</div>
|
||||
<h3>Labs</h3>
|
||||
<p>Experimental features, R&D, and cutting-edge technology development.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-section">
|
||||
<h2>Our Journey</h2>
|
||||
<div className="timeline">
|
||||
{timeline.map((item, i) => (
|
||||
<div key={i} className="timeline-item">
|
||||
<div className="timeline-year">{item.year}</div>
|
||||
<div className="timeline-content">
|
||||
<h3>{item.event}</h3>
|
||||
<p>{item.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-section">
|
||||
<h2>The Team</h2>
|
||||
<div className="team-grid">
|
||||
{team.map((member, i) => (
|
||||
<div key={i} className="team-card">
|
||||
<div className="team-avatar" style={{ background: member.color }}>
|
||||
{member.avatar}
|
||||
</div>
|
||||
<h3>{member.name}</h3>
|
||||
<p>{member.role}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="about-cta">
|
||||
<h2>Ready to join the revolution?</h2>
|
||||
<p>Be part of the community shaping the future of communication.</p>
|
||||
<div className="cta-actions">
|
||||
<Link to="/register" className="cta-primary">Create Account</Link>
|
||||
<a href="https://github.com/AeThex-Corporation/AeThex-Connect" className="cta-secondary" target="_blank" rel="noopener noreferrer">
|
||||
View on GitHub
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="info-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span>AeThex Connect</span>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<Link to="/features">Features</Link>
|
||||
<Link to="/about">About</Link>
|
||||
<Link to="/privacy">Privacy</Link>
|
||||
<Link to="/terms">Terms</Link>
|
||||
</div>
|
||||
<div className="footer-copy">
|
||||
© 2026 AeThex Corporation. Open source under MIT License.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
src/frontend/mockup/pages/FeaturesPage.jsx
Normal file
159
src/frontend/mockup/pages/FeaturesPage.jsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function FeaturesPage() {
|
||||
const featureCategories = [
|
||||
{
|
||||
title: 'Communication',
|
||||
features: [
|
||||
{ icon: '💬', name: 'Real-time Messaging', desc: 'Instant message delivery with typing indicators, reactions, and rich embeds' },
|
||||
{ icon: '🎙️', name: 'Voice Channels', desc: 'Crystal-clear voice chat with noise suppression and echo cancellation' },
|
||||
{ icon: '📹', name: 'Video Calls', desc: 'HD video calls with screen sharing and virtual backgrounds' },
|
||||
{ icon: '🧵', name: 'Threads', desc: 'Organized conversations that keep your channels clean' },
|
||||
{ icon: '📌', name: 'Pinned Messages', desc: 'Save important messages for easy access' },
|
||||
{ icon: '🔍', name: 'Powerful Search', desc: 'Find any message, file, or conversation instantly' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
features: [
|
||||
{ icon: '🏠', name: 'Servers', desc: 'Create communities with unlimited channels and categories' },
|
||||
{ icon: '🎭', name: 'Roles & Permissions', desc: 'Granular control over who can do what' },
|
||||
{ icon: '📢', name: 'Announcements', desc: 'Broadcast to your community with announcement channels' },
|
||||
{ icon: '📅', name: 'Events', desc: 'Schedule and manage community events' },
|
||||
{ icon: '🎪', name: 'Stage Channels', desc: 'Host live audio events for large audiences' },
|
||||
{ icon: '💬', name: 'Forum Channels', desc: 'Organized discussion boards for your community' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Security & Privacy',
|
||||
features: [
|
||||
{ icon: '🔐', name: 'End-to-End Encryption', desc: 'Your messages are encrypted and only you can read them' },
|
||||
{ icon: '🛡️', name: 'AeThex Passport', desc: 'Secure, decentralized identity that you own' },
|
||||
{ icon: '🔒', name: 'Two-Factor Auth', desc: 'Extra security for your account' },
|
||||
{ icon: '👻', name: 'Ephemeral Messages', desc: 'Self-destructing messages for sensitive conversations' },
|
||||
{ icon: '🚫', name: 'No Data Selling', desc: 'Your data is never sold to third parties. Ever.' },
|
||||
{ icon: '📋', name: 'Audit Logs', desc: 'Full transparency on server actions' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Customization',
|
||||
features: [
|
||||
{ icon: '🎨', name: 'Themes', desc: 'Dark, light, and custom themes to match your style' },
|
||||
{ icon: '😀', name: 'Custom Emoji', desc: 'Upload your own emoji and stickers' },
|
||||
{ icon: '🎵', name: 'Soundboard', desc: 'Play sound effects in voice channels' },
|
||||
{ icon: '🤖', name: 'Bots & Integrations', desc: 'Extend functionality with custom bots' },
|
||||
{ icon: '⚙️', name: 'Webhooks', desc: 'Connect external services to your channels' },
|
||||
{ icon: '📊', name: 'Server Insights', desc: 'Analytics to understand your community' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const comparisons = [
|
||||
{ feature: 'Open Source', aethex: true, discord: false, slack: false },
|
||||
{ feature: 'E2E Encryption', aethex: true, discord: false, slack: false },
|
||||
{ feature: 'Self-Hostable', aethex: true, discord: false, slack: false },
|
||||
{ feature: 'No Data Collection', aethex: true, discord: false, slack: false },
|
||||
{ feature: 'Voice/Video', aethex: true, discord: true, slack: true },
|
||||
{ feature: 'Unlimited History', aethex: true, discord: true, slack: false },
|
||||
{ feature: 'Custom Bots', aethex: true, discord: true, slack: true },
|
||||
{ feature: 'Free to Use', aethex: true, discord: true, slack: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="info-page">
|
||||
<nav className="info-nav">
|
||||
<Link to="/" className="nav-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span className="brand-text">AeThex Connect</span>
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<Link to="/features" className="active">Features</Link>
|
||||
<Link to="/about">About</Link>
|
||||
<a href="https://github.com/AeThex-Corporation" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</div>
|
||||
<div className="nav-auth">
|
||||
<Link to="/login" className="nav-login">Login</Link>
|
||||
<Link to="/register" className="nav-register">Get Started</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="info-content">
|
||||
<section className="features-hero">
|
||||
<h1>Everything you need,<br /><span className="gradient-text">nothing you don't</span></h1>
|
||||
<p className="hero-desc">
|
||||
All the features you love from Discord, with the privacy and ownership you deserve.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{featureCategories.map((category, idx) => (
|
||||
<section key={idx} className="feature-category">
|
||||
<h2>{category.title}</h2>
|
||||
<div className="features-grid">
|
||||
{category.features.map((feature, i) => (
|
||||
<div key={i} className="feature-card">
|
||||
<span className="feature-icon">{feature.icon}</span>
|
||||
<h3>{feature.name}</h3>
|
||||
<p>{feature.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<section className="comparison-section">
|
||||
<h2>How We Compare</h2>
|
||||
<div className="comparison-table-container">
|
||||
<table className="comparison-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Feature</th>
|
||||
<th className="highlight">AeThex Connect</th>
|
||||
<th>Discord</th>
|
||||
<th>Slack</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparisons.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.feature}</td>
|
||||
<td className="highlight">{row.aethex ? '✓' : '✕'}</td>
|
||||
<td>{row.discord ? '✓' : '✕'}</td>
|
||||
<td>{row.slack ? '✓' : '✕'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="features-cta">
|
||||
<h2>Ready to experience the difference?</h2>
|
||||
<p>Join thousands of users who've made the switch.</p>
|
||||
<div className="cta-actions">
|
||||
<Link to="/register" className="cta-primary">Get Started Free</Link>
|
||||
<Link to="/login" className="cta-secondary">Already have an account?</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="info-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span>AeThex Connect</span>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<Link to="/features">Features</Link>
|
||||
<Link to="/about">About</Link>
|
||||
<Link to="/privacy">Privacy</Link>
|
||||
<Link to="/terms">Terms</Link>
|
||||
</div>
|
||||
<div className="footer-copy">
|
||||
© 2026 AeThex Corporation. Open source under MIT License.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
src/frontend/mockup/pages/LandingPage.jsx
Normal file
202
src/frontend/mockup/pages/LandingPage.jsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="landing-page">
|
||||
{/* Navigation */}
|
||||
<nav className="landing-nav">
|
||||
<div className="nav-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span className="brand-text">AeThex Connect</span>
|
||||
</div>
|
||||
<div className="nav-links">
|
||||
<Link to="/features">Features</Link>
|
||||
<Link to="/about">About</Link>
|
||||
<a href="https://github.com/AeThex-Corporation" target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</div>
|
||||
<div className="nav-auth">
|
||||
<Link to="/login" className="nav-login">Login</Link>
|
||||
<Link to="/register" className="nav-register">Get Started</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="hero">
|
||||
<div className="hero-content">
|
||||
<div className="hero-badge">
|
||||
<span className="badge-dot"></span>
|
||||
Open Source • Privacy-First • Decentralized
|
||||
</div>
|
||||
<h1 className="hero-title">
|
||||
Connect Without
|
||||
<br />
|
||||
<span className="gradient-text">Compromise</span>
|
||||
</h1>
|
||||
<p className="hero-subtitle">
|
||||
The next-generation communication platform built for gamers, developers,
|
||||
and communities who value privacy, security, and true ownership of their data.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<Link to="/register" className="cta-primary">
|
||||
<span>Launch Connect</span>
|
||||
<span className="cta-arrow">→</span>
|
||||
</Link>
|
||||
<a href="#features" className="cta-secondary">
|
||||
Explore Features
|
||||
</a>
|
||||
</div>
|
||||
<div className="hero-stats">
|
||||
<div className="stat">
|
||||
<span className="stat-value">100%</span>
|
||||
<span className="stat-label">Open Source</span>
|
||||
</div>
|
||||
<div className="stat-divider"></div>
|
||||
<div className="stat">
|
||||
<span className="stat-value">E2E</span>
|
||||
<span className="stat-label">Encrypted</span>
|
||||
</div>
|
||||
<div className="stat-divider"></div>
|
||||
<div className="stat">
|
||||
<span className="stat-value">0</span>
|
||||
<span className="stat-label">Data Sold</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hero-visual">
|
||||
<div className="app-preview">
|
||||
<div className="preview-header">
|
||||
<div className="preview-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="preview-content">
|
||||
<div className="preview-sidebar">
|
||||
<div className="preview-server active"></div>
|
||||
<div className="preview-server"></div>
|
||||
<div className="preview-server"></div>
|
||||
</div>
|
||||
<div className="preview-channels">
|
||||
<div className="preview-channel"></div>
|
||||
<div className="preview-channel active"></div>
|
||||
<div className="preview-channel"></div>
|
||||
</div>
|
||||
<div className="preview-chat">
|
||||
<div className="preview-message"></div>
|
||||
<div className="preview-message short"></div>
|
||||
<div className="preview-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="features-section">
|
||||
<h2 className="section-title">Why AeThex Connect?</h2>
|
||||
<p className="section-subtitle">
|
||||
Built from the ground up with the features that matter most
|
||||
</p>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon foundation">🔐</div>
|
||||
<h3>End-to-End Encryption</h3>
|
||||
<p>Every message, call, and file is encrypted. Only you and your recipients can read them.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon corporation">🎮</div>
|
||||
<h3>GameForge Integration</h3>
|
||||
<p>Rich presence, game invites, and overlay support for your favorite games.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon labs">📞</div>
|
||||
<h3>Crystal Clear Calls</h3>
|
||||
<p>HD voice and video calls with screen sharing, powered by WebRTC.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">🌐</div>
|
||||
<h3>Cross-Platform</h3>
|
||||
<p>Web, Desktop, iOS, and Android. Your conversations sync everywhere.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">🔓</div>
|
||||
<h3>Open Source</h3>
|
||||
<p>Fully auditable code. No hidden backdoors. Community-driven development.</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<div className="feature-icon">⚡</div>
|
||||
<h3>Blazing Fast</h3>
|
||||
<p>Optimized for performance. No bloat, no lag, just pure speed.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trinity Section */}
|
||||
<section className="trinity-section">
|
||||
<h2 className="section-title">The AeThex Trinity</h2>
|
||||
<p className="section-subtitle">Three divisions, one ecosystem</p>
|
||||
<div className="trinity-cards">
|
||||
<div className="trinity-card foundation">
|
||||
<div className="trinity-icon">◈</div>
|
||||
<h3>Foundation</h3>
|
||||
<p>Core security, authentication, and identity. The bedrock of trust.</p>
|
||||
</div>
|
||||
<div className="trinity-card corporation">
|
||||
<div className="trinity-icon">◈</div>
|
||||
<h3>Corporation</h3>
|
||||
<p>Enterprise solutions and professional services. Built for scale.</p>
|
||||
</div>
|
||||
<div className="trinity-card labs">
|
||||
<div className="trinity-icon">◈</div>
|
||||
<h3>Labs</h3>
|
||||
<p>Research and experimental features. Tomorrow's technology today.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="cta-section">
|
||||
<div className="cta-content">
|
||||
<h2>Ready to Connect?</h2>
|
||||
<p>Join the community that values your privacy as much as you do.</p>
|
||||
<Link to="/register" className="cta-primary large">
|
||||
Create Your Account
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="landing-footer">
|
||||
<div className="footer-content">
|
||||
<div className="footer-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span>AeThex Connect</span>
|
||||
</div>
|
||||
<div className="footer-links">
|
||||
<div className="footer-column">
|
||||
<h4>Product</h4>
|
||||
<Link to="/features">Features</Link>
|
||||
<Link to="/about">About</Link>
|
||||
<a href="#">Download</a>
|
||||
</div>
|
||||
<div className="footer-column">
|
||||
<h4>Resources</h4>
|
||||
<a href="#">Documentation</a>
|
||||
<a href="#">API</a>
|
||||
<a href="https://github.com/AeThex-Corporation" target="_blank">GitHub</a>
|
||||
</div>
|
||||
<div className="footer-column">
|
||||
<h4>Legal</h4>
|
||||
<a href="#">Privacy Policy</a>
|
||||
<a href="#">Terms of Service</a>
|
||||
<a href="#">Security</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<p>© 2026 AeThex Corporation. Open source under MIT License.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/frontend/mockup/pages/LoginPage.jsx
Normal file
107
src/frontend/mockup/pages/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Mock login - replace with Supabase auth
|
||||
if (email && password) {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// For demo, accept any valid-looking email
|
||||
if (email.includes('@') && password.length >= 6) {
|
||||
onLogin?.({ email });
|
||||
navigate('/app');
|
||||
} else {
|
||||
setError('Invalid email or password');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError('An error occurred. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-container">
|
||||
<div className="auth-header">
|
||||
<Link to="/" className="auth-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span>AeThex Connect</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="auth-card">
|
||||
<h1>Welcome back!</h1>
|
||||
<p className="auth-subtitle">We're so excited to see you again!</p>
|
||||
|
||||
{error && (
|
||||
<div className="auth-error">
|
||||
<span>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">EMAIL OR USERNAME <span className="required">*</span></label>
|
||||
<input
|
||||
id="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">PASSWORD <span className="required">*</span></label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Link to="/forgot-password" className="forgot-link">Forgot your password?</Link>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="auth-submit" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Log In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
Need an account? <Link to="/register">Register</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<Link to="/">← Back to home</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-background">
|
||||
<div className="bg-gradient"></div>
|
||||
<div className="bg-pattern"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
src/frontend/mockup/pages/RegisterPage.jsx
Normal file
183
src/frontend/mockup/pages/RegisterPage.jsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function RegisterPage({ onRegister }) {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
setError('Password must be at least 8 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
setError('You must agree to the Terms of Service');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
onRegister?.({ email: formData.email, username: formData.username });
|
||||
navigate('/app');
|
||||
} catch (err) {
|
||||
setError('Registration failed. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-container">
|
||||
<div className="auth-header">
|
||||
<Link to="/" className="auth-brand">
|
||||
<span className="brand-icon">◈</span>
|
||||
<span>AeThex Connect</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="auth-card register-card">
|
||||
<h1>Create an account</h1>
|
||||
<p className="auth-subtitle">Join the future of communication</p>
|
||||
|
||||
{error && (
|
||||
<div className="auth-error">
|
||||
<span>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">EMAIL <span className="required">*</span></label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="displayName">DISPLAY NAME <span className="required">*</span></label>
|
||||
<input
|
||||
id="displayName"
|
||||
name="displayName"
|
||||
type="text"
|
||||
value={formData.displayName}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">USERNAME <span className="required">*</span></label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="lowercase, no spaces"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">PASSWORD <span className="required">*</span></label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">CONFIRM PASSWORD <span className="required">*</span></label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="terms-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<span>
|
||||
I agree to the <Link to="/terms">Terms of Service</Link> and{' '}
|
||||
<Link to="/privacy">Privacy Policy</Link>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="auth-submit" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Continue'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="auth-switch">
|
||||
Already have an account? <Link to="/login">Log In</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-footer">
|
||||
<Link to="/">← Back to home</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-visual">
|
||||
<div className="visual-content">
|
||||
<div className="trinity-logo">
|
||||
<div className="trinity-ring foundation"></div>
|
||||
<div className="trinity-ring corporation"></div>
|
||||
<div className="trinity-ring labs"></div>
|
||||
</div>
|
||||
<h2>The Trinity Awaits</h2>
|
||||
<p>Foundation • Corporation • Labs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
786
src/frontend/package-lock.json
generated
786
src/frontend/package-lock.json
generated
|
|
@ -10,6 +10,8 @@
|
|||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.1",
|
||||
"@supabase/supabase-js": "^2.94.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
@ -20,9 +22,24 @@
|
|||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
|
|
@ -701,7 +718,6 @@
|
|||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
|
|
@ -712,7 +728,6 @@
|
|||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
|
|
@ -723,7 +738,6 @@
|
|||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
|
|
@ -733,14 +747,12 @@
|
|||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
|
|
@ -1143,6 +1155,342 @@
|
|||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.94.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.94.1.tgz",
|
||||
"integrity": "sha512-Wt/SdmAtNNiqrcBbPlzWojLcE1bQ9OYb8PTaYF6QccFX5JeXZI0sZ01MLNE+E83UK6cK0lw4YznX0D2g08UQng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.94.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.94.1.tgz",
|
||||
"integrity": "sha512-A7Bx0gnclDNZ4m8+mnO2IEEzMxtUSg7cpPEBF6Ek1LpjIQkC7vvoidiV/RuntnKX43IiVcWV1f2FsAppMagEmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.94.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.94.1.tgz",
|
||||
"integrity": "sha512-N6MTghjHnMZddT48rAj8dIFgedCU97cc1ahQM74Tc+DF4UH7y2+iEfdYV3unJsylpaiWlu92Fy8Lj14Jbrmxog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.94.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.94.1.tgz",
|
||||
"integrity": "sha512-Wq8olpCAGmN4y2DH2kUdlcakdzNHRCde72BFS8zK5ub46bBeSUoE9DqrfeNFWKaF2gCE/cmK8aTUTorZD9jdtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.94.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.94.1.tgz",
|
||||
"integrity": "sha512-/Mi18LGyrugPwtfqETfAqEGcBQotY/7IMsTGYgEFdqr8cQq280BVQWjN2wI9KibWtshPp0Ryvil5Uzd5YfM7kA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.94.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.94.1.tgz",
|
||||
"integrity": "sha512-87vOY8n3WHB3m+a/KeySj07djOQVuRA5qgX5E7db1eDkaZ1of5M+3t/tv6eYYy4BfqxuHMZuCe5uVrO/oyvoow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.94.1",
|
||||
"@supabase/functions-js": "2.94.1",
|
||||
"@supabase/postgrest-js": "2.94.1",
|
||||
"@supabase/realtime-js": "2.94.1",
|
||||
"@supabase/storage-js": "2.94.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
|
||||
"integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.30.2",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||
"integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.18",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.18",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.18",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.18",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.18",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.18",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.18",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz",
|
||||
"integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz",
|
||||
"integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz",
|
||||
"integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz",
|
||||
"integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz",
|
||||
"integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz",
|
||||
"integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz",
|
||||
"integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz",
|
||||
"integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz",
|
||||
"integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz",
|
||||
"integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
"@emnapi/runtime",
|
||||
"@tybys/wasm-util",
|
||||
"@emnapi/wasi-threads",
|
||||
"tslib"
|
||||
],
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@emnapi/wasi-threads": "^1.1.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.0",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
"integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz",
|
||||
"integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz",
|
||||
"integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.18",
|
||||
"@tailwindcss/oxide": "4.1.18",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
|
@ -1195,6 +1543,21 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz",
|
||||
"integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
|
||||
"integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
|
|
@ -1224,6 +1587,15 @@
|
|||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
|
|
@ -1251,6 +1623,43 @@
|
|||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.24",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
|
||||
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"browserslist": "^4.28.1",
|
||||
"caniuse-lite": "^1.0.30001766",
|
||||
"fraction.js": "^5.3.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"postcss-value-parser": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"autoprefixer": "bin/autoprefixer"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
|
|
@ -1321,9 +1730,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001763",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
|
||||
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
|
||||
"version": "1.0.30001768",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz",
|
||||
"integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -1393,6 +1802,15 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
|
|
@ -1436,6 +1854,19 @@
|
|||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.19.0",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
|
||||
"integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"tapable": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
|
@ -1566,6 +1997,20 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
|
@ -1649,6 +2094,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
|
|
@ -1688,6 +2139,24 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
|
|
@ -1720,6 +2189,255 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
|
|
@ -1742,6 +2460,15 @@
|
|||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
|
|
@ -1782,7 +2509,6 @@
|
|||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -1817,14 +2543,12 @@
|
|||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
|
@ -1840,6 +2564,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -1849,6 +2574,13 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-value-parser": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
|
|
@ -2037,12 +2769,42 @@
|
|||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
|
||||
"integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^5.4.1",
|
||||
"@stripe/stripe-js": "^8.6.1",
|
||||
"@supabase/supabase-js": "^2.94.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
|
@ -21,6 +23,9 @@
|
|||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
src/frontend/postcss.config.js
Normal file
6
src/frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
159
src/frontend/services/messaging.js
Normal file
159
src/frontend/services/messaging.js
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { createClient } from '@supabase/supabase-js';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// Supabase client
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || 'http://127.0.0.1:3000';
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY || 'sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH';
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
// Socket.IO client
|
||||
const socketUrl = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
|
||||
export const socket = io(socketUrl, {
|
||||
autoConnect: false,
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
// Connect socket when user is authenticated
|
||||
export const connectSocket = (userId) => {
|
||||
if (!socket.connected) {
|
||||
socket.auth = { userId };
|
||||
socket.connect();
|
||||
}
|
||||
};
|
||||
|
||||
export const disconnectSocket = () => {
|
||||
if (socket.connected) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// Messaging API
|
||||
export const messagingService = {
|
||||
// Get all conversations for a user
|
||||
async getConversations(userId) {
|
||||
const { data, error } = await supabase
|
||||
.from('conversation_participants')
|
||||
.select(`
|
||||
conversation_id,
|
||||
conversations (
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
avatar_url,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('conversations(updated_at)', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
return data?.map(p => p.conversations) || [];
|
||||
},
|
||||
|
||||
// Get messages for a conversation
|
||||
async getMessages(conversationId, limit = 50) {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('conversation_id', conversationId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).reverse();
|
||||
},
|
||||
|
||||
// Send a message
|
||||
async sendMessage(conversationId, senderId, content) {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
conversation_id: conversationId,
|
||||
sender_id: senderId,
|
||||
content_encrypted: content, // TODO: Add actual encryption
|
||||
content_type: 'text',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Emit via socket for real-time
|
||||
socket.emit('message:send', {
|
||||
conversationId,
|
||||
message: data,
|
||||
});
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
// Create a new conversation
|
||||
async createConversation(type, title, participantIds) {
|
||||
const { data: conversation, error: convError } = await supabase
|
||||
.from('conversations')
|
||||
.insert({ type, title })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (convError) throw convError;
|
||||
|
||||
// Add participants
|
||||
const participants = participantIds.map(userId => ({
|
||||
conversation_id: conversation.id,
|
||||
user_id: userId,
|
||||
role: 'member',
|
||||
}));
|
||||
|
||||
const { error: partError } = await supabase
|
||||
.from('conversation_participants')
|
||||
.insert(participants);
|
||||
|
||||
if (partError) throw partError;
|
||||
|
||||
return conversation;
|
||||
},
|
||||
|
||||
// Subscribe to new messages in a conversation
|
||||
subscribeToMessages(conversationId, callback) {
|
||||
const channel = supabase
|
||||
.channel(`messages:${conversationId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'messages',
|
||||
filter: `conversation_id=eq.${conversationId}`,
|
||||
},
|
||||
(payload) => {
|
||||
callback(payload.new);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Socket event handlers
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket connected:', socket.id);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Socket disconnected');
|
||||
});
|
||||
|
||||
socket.on('message:new', (message) => {
|
||||
console.log('New message received:', message);
|
||||
// This will be handled by the React context
|
||||
});
|
||||
|
||||
socket.on('error', (error) => {
|
||||
console.error('Socket error:', error);
|
||||
});
|
||||
22
src/frontend/tailwind.config.js
Normal file
22
src/frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'discord-dark': '#0a0a0a',
|
||||
'discord-darker': '#050505',
|
||||
'discord-sidebar': '#0d0d0d',
|
||||
'discord-channel': '#111111',
|
||||
'discord-hover': '#1a1a1a',
|
||||
'foundation': '#fbbf24',
|
||||
'corporation': '#06b6d4',
|
||||
'labs': '#a855f7',
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Loading…
Reference in a new issue