feat: Implement core chat page with channel navigation, direct messages, and voice chat integration.
All checks were successful
Build and Release / build-and-release (push) Successful in 9m12s
All checks were successful
Build and Release / build-and-release (push) Successful in 9m12s
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
Frontend/Electron/dist-react/assets/index-D8p__dJ4.css
Normal file
1
Frontend/Electron/dist-react/assets/index-D8p__dJ4.css
Normal file
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>discord</title>
|
||||
<script type="module" crossorigin src="./assets/index-DIG5pjLm.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-Ckz_sV-I.css">
|
||||
<script type="module" crossorigin src="./assets/index-BhwDWh5r.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-D8p__dJ4.css">
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
|
||||
60
Frontend/Electron/package-lock.json
generated
60
Frontend/Electron/package-lock.json
generated
@@ -1,14 +1,17 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "discord",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.7",
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@livekit/components-react": "^2.9.17",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"convex": "^1.31.2",
|
||||
@@ -375,6 +378,59 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@electron/asar": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "discord",
|
||||
"private": true,
|
||||
"version": "1.0.7",
|
||||
"version": "1.0.8",
|
||||
"description": "A Discord clone built with Convex, React, and Electron",
|
||||
"author": "Moyettes",
|
||||
"type": "module",
|
||||
@@ -60,6 +60,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@convex-dev/presence": "^0.3.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@livekit/components-react": "^2.9.17",
|
||||
"@livekit/components-styles": "^1.2.0",
|
||||
"convex": "^1.31.2",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg class="icon__29444" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24"><path fill="currentColor" d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z" class=""></path></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -229,8 +229,8 @@ const MessageItem = React.memo(({
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
||||
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'var(--embed-background)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
||||
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={data.me ? null : 'var(--header-secondary)'} />
|
||||
<div key={emojiName} onClick={() => onReactionClick(msg.id, emojiName, data.me)} style={{ display: 'flex', alignItems: 'center', backgroundColor: data.me ? 'rgba(88, 101, 242, 0.15)' : 'hsl(240 calc(1*4%) 60.784% / 0.0784313725490196)', border: data.me ? '1px solid var(--brand-experiment)' : '1px solid transparent', borderRadius: '8px', padding: '2px 6px', cursor: 'pointer', gap: '4px' }}>
|
||||
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
|
||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useConvex, useMutation, useQuery } from 'convex/react';
|
||||
import { api } from '../../../../convex/_generated/api';
|
||||
@@ -11,6 +11,9 @@ import DMList from './DMList';
|
||||
import Avatar from './Avatar';
|
||||
import UserSettings from './UserSettings';
|
||||
import { Track } from 'livekit-client';
|
||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragOverlay } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import muteIcon from '../assets/icons/mute.svg';
|
||||
import mutedIcon from '../assets/icons/muted.svg';
|
||||
import defeanIcon from '../assets/icons/defean.svg';
|
||||
@@ -21,6 +24,7 @@ import disconnectIcon from '../assets/icons/disconnect.svg';
|
||||
import cameraIcon from '../assets/icons/camera.svg';
|
||||
import screenIcon from '../assets/icons/screen.svg';
|
||||
import inviteUserIcon from '../assets/icons/invite_user.svg';
|
||||
import categoryCollapsedIcon from '../assets/icons/category_collapsed_icon.svg';
|
||||
import PingSound from '../assets/sounds/ping.mp3';
|
||||
|
||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
||||
@@ -337,7 +341,252 @@ function getScreenCaptureConstraints(selection) {
|
||||
};
|
||||
}
|
||||
|
||||
const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => {
|
||||
const ChannelListContextMenu = ({ x, y, onClose, onCreateChannel, onCreateCategory }) => {
|
||||
const menuRef = useRef(null);
|
||||
const [pos, setPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
const h = () => onClose();
|
||||
window.addEventListener('click', h);
|
||||
return () => window.removeEventListener('click', h);
|
||||
}, [onClose]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!menuRef.current) return;
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
let newTop = y, newLeft = x;
|
||||
if (x + rect.width > window.innerWidth) newLeft = x - rect.width;
|
||||
if (y + rect.height > window.innerHeight) newTop = y - rect.height;
|
||||
if (newLeft < 0) newLeft = 10;
|
||||
if (newTop < 0) newTop = 10;
|
||||
setPos({ top: newTop, left: newLeft });
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className="context-menu" style={{ top: pos.top, left: pos.left }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onCreateChannel(); onClose(); }}>
|
||||
<span>Create Channel</span>
|
||||
</div>
|
||||
<div className="context-menu-item" onClick={(e) => { e.stopPropagation(); onCreateCategory(); onClose(); }}>
|
||||
<span>Create Category</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateChannelModal = ({ onClose, onSubmit, categoryId }) => {
|
||||
const [channelType, setChannelType] = useState('text');
|
||||
const [channelName, setChannelName] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!channelName.trim()) return;
|
||||
onSubmit(channelName.trim(), channelType, categoryId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-channel-modal-overlay" onClick={onClose}>
|
||||
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="create-channel-modal-header">
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: 'var(--header-primary)', fontSize: '20px', fontWeight: 700 }}>Create Channel</h2>
|
||||
<p style={{ margin: '4px 0 0', color: 'var(--header-secondary)', fontSize: '12px' }}>in Text Channels</p>
|
||||
</div>
|
||||
<button className="create-channel-modal-close" onClick={onClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Channel Type</label>
|
||||
|
||||
<div
|
||||
className={`channel-type-option ${channelType === 'text' ? 'selected' : ''}`}
|
||||
onClick={() => setChannelType('text')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<span style={{ fontSize: '24px', color: 'var(--interactive-normal)', width: '24px', textAlign: 'center' }}>#</span>
|
||||
<div>
|
||||
<div style={{ color: 'var(--header-primary)', fontWeight: 500, fontSize: '16px' }}>Text</div>
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '2px' }}>Send messages, images, GIFs, emoji, opinions, and puns</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`channel-type-radio ${channelType === 'text' ? 'selected' : ''}`}>
|
||||
{channelType === 'text' && <div className="channel-type-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`channel-type-option ${channelType === 'voice' ? 'selected' : ''}`}
|
||||
onClick={() => setChannelType('voice')}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flex: 1 }}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" style={{ flexShrink: 0, color: 'var(--interactive-normal)' }}>
|
||||
<path fill="currentColor" d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904ZM14 5.00304V7.00304C16.757 7.00304 19 9.24604 19 12.003C19 14.76 16.757 17.003 14 17.003V19.003C17.86 19.003 21 15.863 21 12.003C21 8.14304 17.86 5.00304 14 5.00304ZM14 9.00304V15.003C15.654 15.003 17 13.657 17 12.003C17 10.349 15.654 9.00304 14 9.00304Z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div style={{ color: 'var(--header-primary)', fontWeight: 500, fontSize: '16px' }}>Voice</div>
|
||||
<div style={{ color: 'var(--header-secondary)', fontSize: '12px', marginTop: '2px' }}>Hang out together with voice, video, and screen share</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`channel-type-radio ${channelType === 'voice' ? 'selected' : ''}`}>
|
||||
{channelType === 'voice' && <div className="channel-type-radio-dot" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Channel Name</label>
|
||||
<div className="create-channel-name-input-wrapper">
|
||||
<span style={{ color: 'var(--interactive-normal)', fontSize: '16px', marginRight: '4px' }}>
|
||||
{channelType === 'text' ? '#' : '🔊'}
|
||||
</span>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="new-channel"
|
||||
value={channelName}
|
||||
onChange={(e) => setChannelName(e.target.value.toLowerCase().replace(/\s+/g, '-'))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
className="create-channel-name-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-channel-modal-footer">
|
||||
<button className="create-channel-cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="create-channel-submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={!channelName.trim()}
|
||||
>
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateCategoryModal = ({ onClose, onSubmit }) => {
|
||||
const [categoryName, setCategoryName] = useState('');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!categoryName.trim()) return;
|
||||
onSubmit(categoryName.trim());
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="create-channel-modal-overlay" onClick={onClose}>
|
||||
<div className="create-channel-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="create-channel-modal-header">
|
||||
<h2 style={{ margin: 0, color: 'var(--header-primary)', fontSize: '20px', fontWeight: 700 }}>Create Category</h2>
|
||||
<button className="create-channel-modal-close" onClick={onClose}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', color: 'var(--header-secondary)', fontSize: '12px', fontWeight: 700, textTransform: 'uppercase', marginBottom: '8px' }}>Category Name</label>
|
||||
<div className="create-channel-name-input-wrapper">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="New Category"
|
||||
value={categoryName}
|
||||
onChange={(e) => setCategoryName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
|
||||
className="create-channel-name-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" style={{ color: 'var(--interactive-normal)' }}>
|
||||
<path fill="currentColor" d="M17 11V7C17 4.243 14.757 2 12 2C9.243 2 7 4.243 7 7V11C5.897 11 5 11.896 5 13V20C5 21.103 5.897 22 7 22H17C18.103 22 19 21.103 19 20V13C19 11.896 18.103 11 17 11ZM12 18C11.172 18 10.5 17.328 10.5 16.5C10.5 15.672 11.172 15 12 15C12.828 15 13.5 15.672 13.5 16.5C13.5 17.328 12.828 18 12 18ZM15 11H9V7C9 5.346 10.346 4 12 4C13.654 4 15 5.346 15 7V11Z" />
|
||||
</svg>
|
||||
<span style={{ color: 'var(--header-primary)', fontSize: '14px', fontWeight: 500 }}>Private Category</span>
|
||||
</div>
|
||||
<div className="category-toggle-switch">
|
||||
<div className="category-toggle-knob" />
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 16px', lineHeight: '16px' }}>
|
||||
By making a category private, only selected members and roles will be able to view this category. Synced channels will automatically match this category's permissions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="create-channel-modal-footer">
|
||||
<button className="create-channel-cancel-btn" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
className="create-channel-submit-btn"
|
||||
onClick={handleSubmit}
|
||||
disabled={!categoryName.trim()}
|
||||
>
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- DnD wrapper components ---
|
||||
|
||||
const SortableCategory = ({ id, children }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
data: { type: 'category' },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{React.Children.map(children, (child, i) => {
|
||||
// First child is the category header — attach drag listeners to it
|
||||
if (i === 0 && React.isValidElement(child)) {
|
||||
return React.cloneElement(child, { dragListeners: listeners });
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortableChannel = ({ id, children }) => {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id,
|
||||
data: { type: 'channel' },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, username, channelKeys, view, onViewChange, onOpenDM, activeDMChannel, setActiveDMChannel, dmChannels, userId }) => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||
const [newChannelName, setNewChannelName] = useState('');
|
||||
@@ -345,9 +594,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
const [editingChannel, setEditingChannel] = useState(null);
|
||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
||||
const [channelListContextMenu, setChannelListContextMenu] = useState(null);
|
||||
const [showCreateChannelModal, setShowCreateChannelModal] = useState(false);
|
||||
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
|
||||
const [createChannelCategoryId, setCreateChannelCategoryId] = useState(null);
|
||||
const [activeDragItem, setActiveDragItem] = useState(null);
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
);
|
||||
|
||||
// Unread tracking
|
||||
const channelIds = React.useMemo(() => [
|
||||
...channels.map(c => c._id),
|
||||
@@ -597,17 +856,137 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||
};
|
||||
|
||||
// Group channels by categoryId
|
||||
const groupedChannels = React.useMemo(() => {
|
||||
const groups = {};
|
||||
const groups = [];
|
||||
const channelsByCategory = new Map();
|
||||
|
||||
channels.forEach(ch => {
|
||||
const cat = ch.type === 'voice'
|
||||
? (ch.category || 'Voice Channels')
|
||||
: (ch.category || 'Text Channels');
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(ch);
|
||||
const catId = ch.categoryId || '__uncategorized__';
|
||||
if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []);
|
||||
channelsByCategory.get(catId).push(ch);
|
||||
});
|
||||
|
||||
// Sort channels within each category by position
|
||||
for (const [, list] of channelsByCategory) {
|
||||
list.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
}
|
||||
|
||||
// Add uncategorized at top
|
||||
const uncategorized = channelsByCategory.get('__uncategorized__');
|
||||
if (uncategorized?.length) {
|
||||
groups.push({ id: '__uncategorized__', name: 'Channels', channels: uncategorized });
|
||||
}
|
||||
|
||||
// Add categories in position order
|
||||
for (const cat of (categories || [])) {
|
||||
groups.push({ id: cat._id, name: cat.name, channels: channelsByCategory.get(cat._id) || [] });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [channels]);
|
||||
}, [channels, categories]);
|
||||
|
||||
// DnD items
|
||||
const categoryDndIds = React.useMemo(() => groupedChannels.map(g => `category-${g.id}`), [groupedChannels]);
|
||||
|
||||
const handleDragStart = (event) => {
|
||||
const { active } = event;
|
||||
const activeType = active.data.current?.type;
|
||||
if (activeType === 'category') {
|
||||
const catId = active.id.replace('category-', '');
|
||||
const group = groupedChannels.find(g => g.id === catId);
|
||||
setActiveDragItem({ type: 'category', name: group?.name || '' });
|
||||
} else if (activeType === 'channel') {
|
||||
const chId = active.id.replace('channel-', '');
|
||||
const ch = channels.find(c => c._id === chId);
|
||||
setActiveDragItem({ type: 'channel', channel: ch });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event) => {
|
||||
setActiveDragItem(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const activeType = active.data.current?.type;
|
||||
const overType = over.data.current?.type;
|
||||
|
||||
if (activeType === 'category' && overType === 'category') {
|
||||
// Reorder categories
|
||||
const oldIndex = groupedChannels.findIndex(g => `category-${g.id}` === active.id);
|
||||
const newIndex = groupedChannels.findIndex(g => `category-${g.id}` === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
// Build reordered array (only real categories, skip uncategorized)
|
||||
const reordered = [...groupedChannels];
|
||||
const [moved] = reordered.splice(oldIndex, 1);
|
||||
reordered.splice(newIndex, 0, moved);
|
||||
|
||||
const updates = reordered
|
||||
.filter(g => g.id !== '__uncategorized__')
|
||||
.map((g, i) => ({ id: g.id, position: i * 1000 }));
|
||||
|
||||
if (updates.length > 0) {
|
||||
try {
|
||||
await convex.mutation(api.categories.reorder, { updates });
|
||||
} catch (e) {
|
||||
console.error('Failed to reorder categories:', e);
|
||||
}
|
||||
}
|
||||
} else if (activeType === 'channel') {
|
||||
const activeChId = active.id.replace('channel-', '');
|
||||
|
||||
if (overType === 'channel') {
|
||||
const overChId = over.id.replace('channel-', '');
|
||||
const activeChannel = channels.find(c => c._id === activeChId);
|
||||
const overChannel = channels.find(c => c._id === overChId);
|
||||
if (!activeChannel || !overChannel) return;
|
||||
|
||||
const targetCategoryId = overChannel.categoryId;
|
||||
const targetGroup = groupedChannels.find(g => g.id === (targetCategoryId || '__uncategorized__'));
|
||||
if (!targetGroup) return;
|
||||
|
||||
// Build new order for the target category
|
||||
const targetChannels = [...targetGroup.channels];
|
||||
|
||||
// Remove active channel if it's already in this category
|
||||
const existingIdx = targetChannels.findIndex(c => c._id === activeChId);
|
||||
if (existingIdx !== -1) targetChannels.splice(existingIdx, 1);
|
||||
|
||||
// Insert at the position of the over channel
|
||||
const overIdx = targetChannels.findIndex(c => c._id === overChId);
|
||||
targetChannels.splice(overIdx, 0, activeChannel);
|
||||
|
||||
const updates = targetChannels.map((ch, i) => ({
|
||||
id: ch._id,
|
||||
categoryId: targetCategoryId,
|
||||
position: i * 1000,
|
||||
}));
|
||||
|
||||
try {
|
||||
await convex.mutation(api.channels.reorderChannels, { updates });
|
||||
} catch (e) {
|
||||
console.error('Failed to reorder channels:', e);
|
||||
}
|
||||
} else if (overType === 'category') {
|
||||
// Drop channel onto a category header — move it to end of that category
|
||||
const targetCatId = over.id.replace('category-', '');
|
||||
const targetCategoryId = targetCatId === '__uncategorized__' ? undefined : targetCatId;
|
||||
const targetGroup = groupedChannels.find(g => g.id === targetCatId);
|
||||
const maxPos = (targetGroup?.channels || []).reduce((max, c) => Math.max(max, c.position ?? 0), -1000);
|
||||
|
||||
try {
|
||||
await convex.mutation(api.channels.moveChannel, {
|
||||
id: activeChId,
|
||||
categoryId: targetCategoryId,
|
||||
position: maxPos + 1000,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to move channel:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderServerView = () => (
|
||||
<div style={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--bg-tertiary)', borderLeft: '1px solid var(--app-frame-border)', borderTop: '1px solid var(--app-frame-border)', borderRadius: '8px 0 0 0' }}>
|
||||
@@ -618,7 +997,12 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }}>
|
||||
<div className="channel-list" style={{ flex: 1, overflowY: 'auto', backgroundColor: 'var(--bg-tertiary)' }} onContextMenu={(e) => {
|
||||
if (!e.target.closest('.channel-item') && !e.target.closest('.channel-category-header')) {
|
||||
e.preventDefault();
|
||||
setChannelListContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
}}>
|
||||
{isCreating && (
|
||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||
<form onSubmit={handleSubmitCreate}>
|
||||
@@ -654,72 +1038,114 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.entries(groupedChannels).map(([category, catChannels]) => (
|
||||
<div key={category}>
|
||||
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
|
||||
<span className="category-label">{category}</span>
|
||||
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{!collapsedCategories[category] && catChannels.map(channel => {
|
||||
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
|
||||
{groupedChannels.map(group => {
|
||||
const channelDndIds = group.channels.map(ch => `channel-${ch._id}`);
|
||||
return (
|
||||
<React.Fragment key={channel._id}>
|
||||
<div
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: '8px'
|
||||
}}
|
||||
>
|
||||
{isUnread && <div className="channel-unread-indicator" />}
|
||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||
{channel.type === 'voice' ? (
|
||||
<div style={{ marginRight: 6 }}>
|
||||
<ColoredIcon
|
||||
src={voiceIcon}
|
||||
size="16px"
|
||||
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
|
||||
)}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
|
||||
</div>
|
||||
<SortableCategory key={group.id} id={`category-${group.id}`}>
|
||||
<CategoryHeader
|
||||
group={group}
|
||||
collapsed={collapsedCategories[group.id]}
|
||||
onToggle={() => toggleCategory(group.id)}
|
||||
onAddChannel={() => {
|
||||
setCreateChannelCategoryId(group.id === '__uncategorized__' ? null : group.id);
|
||||
setShowCreateChannelModal(true);
|
||||
}}
|
||||
/>
|
||||
{(() => {
|
||||
const visibleChannels = collapsedCategories[group.id]
|
||||
? group.channels.filter(ch => ch._id === activeChannel)
|
||||
: group.channels;
|
||||
if (visibleChannels.length === 0) return null;
|
||||
const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`);
|
||||
return (
|
||||
<SortableContext items={visibleDndIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleChannels.map(channel => {
|
||||
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
|
||||
return (
|
||||
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
|
||||
<React.Fragment>
|
||||
<div
|
||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||
onClick={() => handleChannelClick(channel)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: '8px'
|
||||
}}
|
||||
>
|
||||
{isUnread && <div className="channel-unread-indicator" />}
|
||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
||||
{channel.type === 'voice' ? (
|
||||
<div style={{ marginRight: 6 }}>
|
||||
<ColoredIcon
|
||||
src={voiceIcon}
|
||||
size="16px"
|
||||
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
|
||||
)}
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel);
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: 'var(--interactive-normal)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '2px 4px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
opacity: '0',
|
||||
transition: 'opacity 0.2s'
|
||||
}}
|
||||
>
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
{renderVoiceUsers(channel)}
|
||||
</React.Fragment>
|
||||
);
|
||||
<button
|
||||
className="channel-settings-icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingChannel(channel);
|
||||
}}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 4px',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
||||
</button>
|
||||
</div>
|
||||
{renderVoiceUsers(channel)}
|
||||
</React.Fragment>
|
||||
</SortableChannel>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
);
|
||||
})()}
|
||||
</SortableCategory>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
<DragOverlay>
|
||||
{activeDragItem?.type === 'channel' && activeDragItem.channel && (
|
||||
<div className="drag-overlay-channel">
|
||||
{activeDragItem.channel.type === 'voice' ? (
|
||||
<ColoredIcon src={voiceIcon} size="16px" color="var(--interactive-normal)" />
|
||||
) : (
|
||||
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px' }}>#</span>
|
||||
)}
|
||||
<span>{activeDragItem.channel.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{activeDragItem?.type === 'category' && (
|
||||
<div className="drag-overlay-category">
|
||||
{activeDragItem.name}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -844,8 +1270,64 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
||||
onSelectSource={handleScreenShareSelect}
|
||||
/>
|
||||
)}
|
||||
{channelListContextMenu && (
|
||||
<ChannelListContextMenu
|
||||
x={channelListContextMenu.x}
|
||||
y={channelListContextMenu.y}
|
||||
onClose={() => setChannelListContextMenu(null)}
|
||||
onCreateChannel={() => {
|
||||
setCreateChannelCategoryId(null);
|
||||
setShowCreateChannelModal(true);
|
||||
}}
|
||||
onCreateCategory={() => setShowCreateCategoryModal(true)}
|
||||
/>
|
||||
)}
|
||||
{showCreateChannelModal && (
|
||||
<CreateChannelModal
|
||||
categoryId={createChannelCategoryId}
|
||||
onClose={() => setShowCreateChannelModal(false)}
|
||||
onSubmit={async (name, type, catId) => {
|
||||
const userId = localStorage.getItem('userId');
|
||||
if (!userId) { alert("Please login first."); return; }
|
||||
try {
|
||||
const createArgs = { name, type };
|
||||
if (catId) createArgs.categoryId = catId;
|
||||
const { id: channelId } = await convex.mutation(api.channels.create, createArgs);
|
||||
const keyHex = randomHex(32);
|
||||
try { await encryptKeyForUsers(convex, channelId, keyHex); }
|
||||
catch (keyErr) { console.error("Critical: Failed to distribute keys", keyErr); alert("Channel created but key distribution failed."); }
|
||||
} catch (err) { console.error(err); alert("Failed to create channel: " + err.message); }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showCreateCategoryModal && (
|
||||
<CreateCategoryModal
|
||||
onClose={() => setShowCreateCategoryModal(false)}
|
||||
onSubmit={async (name) => {
|
||||
try {
|
||||
await convex.mutation(api.categories.create, { name });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Failed to create category: " + err.message);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Category header component (extracted for DnD drag handle)
|
||||
const CategoryHeader = ({ group, collapsed, onToggle, onAddChannel, dragListeners }) => (
|
||||
<div className="channel-category-header" onClick={onToggle} {...(dragListeners || {})}>
|
||||
<span className="category-label">{group.name}</span>
|
||||
<div className={`category-chevron ${collapsed ? 'collapsed' : ''}`}>
|
||||
<ColoredIcon src={categoryCollapsedIcon} color="currentColor" size="12px" />
|
||||
</div>
|
||||
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); onAddChannel(); }} title="Create Channel">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
@@ -1930,7 +1930,8 @@ body {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.channel-item:hover .channel-settings-icon {
|
||||
.channel-item:hover .channel-settings-icon,
|
||||
.channel-item.active .channel-settings-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -2016,7 +2017,7 @@ body {
|
||||
}
|
||||
|
||||
.category-chevron {
|
||||
margin-right: 2px;
|
||||
margin-left: 4px;
|
||||
font-size: 10px;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
@@ -2027,13 +2028,13 @@ body {
|
||||
}
|
||||
|
||||
.category-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.category-add-btn {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--interactive-normal);
|
||||
@@ -2602,4 +2603,220 @@ body {
|
||||
border: 3px solid var(--bg-tertiary);
|
||||
border-radius: 50%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CREATE CHANNEL MODAL
|
||||
============================================ */
|
||||
.create-channel-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.create-channel-modal {
|
||||
width: 440px;
|
||||
max-width: 90vw;
|
||||
background-color: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.create-channel-modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
.create-channel-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.create-channel-modal-close:hover {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.channel-type-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-secondary);
|
||||
margin-bottom: 8px;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.channel-type-option:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.channel-type-option.selected {
|
||||
background-color: var(--background-modifier-selected);
|
||||
}
|
||||
|
||||
.channel-type-radio {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--interactive-normal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.channel-type-radio.selected {
|
||||
border-color: var(--brand-experiment);
|
||||
}
|
||||
|
||||
.channel-type-radio-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--brand-experiment);
|
||||
}
|
||||
|
||||
.create-channel-name-input-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.create-channel-name-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-normal);
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.create-channel-name-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.create-channel-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.create-channel-cancel-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-normal);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.create-channel-cancel-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.create-channel-submit-btn {
|
||||
background-color: var(--brand-experiment);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.create-channel-submit-btn:hover {
|
||||
background-color: var(--brand-experiment-hover);
|
||||
}
|
||||
|
||||
.create-channel-submit-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
CATEGORY TOGGLE SWITCH (visual only)
|
||||
============================================ */
|
||||
.category-toggle-switch {
|
||||
width: 40px;
|
||||
height: 24px;
|
||||
background-color: var(--interactive-normal);
|
||||
border-radius: 12px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.category-toggle-knob {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: var(--header-primary);
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
DRAG OVERLAY
|
||||
============================================ */
|
||||
.drag-overlay-channel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background-color: var(--background-modifier-selected);
|
||||
border-radius: 4px;
|
||||
color: var(--interactive-active);
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.drag-overlay-category {
|
||||
padding: 8px 12px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
cursor: grabbing;
|
||||
opacity: 0.9;
|
||||
width: 200px;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const Chat = () => {
|
||||
}, [toggleMute]);
|
||||
|
||||
const channels = useQuery(api.channels.list) || [];
|
||||
const categories = useQuery(api.categories.list) || [];
|
||||
|
||||
const rawChannelKeys = useQuery(
|
||||
api.channelKeys.getKeysForUser,
|
||||
@@ -246,6 +247,7 @@ const Chat = () => {
|
||||
<div className="app-container">
|
||||
<Sidebar
|
||||
channels={channels}
|
||||
categories={categories}
|
||||
activeChannel={activeChannel}
|
||||
onSelectChannel={handleSelectChannel}
|
||||
username={username}
|
||||
|
||||
Reference in New Issue
Block a user