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:
12
CLAUDE.md
12
CLAUDE.md
@@ -14,9 +14,10 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
|
|
||||||
## Key Convex Files (convex/)
|
## Key Convex Files (convex/)
|
||||||
|
|
||||||
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), channels (with category, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState
|
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState
|
||||||
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
|
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus), updateProfile, updateStatus
|
||||||
- `channels.ts` - list, get, create (with category/topic/position), rename, remove (cascade), updateTopic
|
- `categories.ts` - list, create, rename, remove, reorder
|
||||||
|
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
|
||||||
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
|
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
|
||||||
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
- `channelKeys.ts` - uploadKeys, getKeysForUser
|
||||||
- `messages.ts` - list (with reactions + username), send, remove
|
- `messages.ts` - list (with reactions + username), send, remove
|
||||||
@@ -36,9 +37,9 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
- `main.jsx` - ConvexProvider + VoiceProvider + HashRouter
|
- `main.jsx` - ConvexProvider + VoiceProvider + HashRouter
|
||||||
- `pages/Login.jsx` - Convex auth (getSalt + verifyUser)
|
- `pages/Login.jsx` - Convex auth (getSalt + verifyUser)
|
||||||
- `pages/Register.jsx` - Convex auth (createUserWithProfile + invite flow)
|
- `pages/Register.jsx` - Convex auth (createUserWithProfile + invite flow)
|
||||||
- `pages/Chat.jsx` - useQuery for channels, channelKeys, DMs
|
- `pages/Chat.jsx` - useQuery for channels, categories, channelKeys, DMs
|
||||||
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
|
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
|
||||||
- `components/Sidebar.jsx` - Channel creation, key distribution, invites via Convex
|
- `components/Sidebar.jsx` - Channel/category creation, key distribution, invites, drag-and-drop reordering via @dnd-kit
|
||||||
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
|
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit room management
|
||||||
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
|
- `components/ChannelSettingsModal.jsx` - Channel rename/delete via Convex mutations
|
||||||
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
|
- `components/ServerSettingsModal.jsx` - Role management via Convex queries/mutations
|
||||||
@@ -62,7 +63,8 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
|||||||
- Typing indicators use scheduled functions for TTL cleanup
|
- Typing indicators use scheduled functions for TTL cleanup
|
||||||
- CSS uses Discord dark theme colors via `:root` variables (`--bg-primary: #313338`, `--bg-secondary: #2b2d31`, `--bg-tertiary: #1e1f22`)
|
- CSS uses Discord dark theme colors via `:root` variables (`--bg-primary: #313338`, `--bg-secondary: #2b2d31`, `--bg-tertiary: #1e1f22`)
|
||||||
- Sidebar width is 312px (72px server strip + 240px channel panel)
|
- Sidebar width is 312px (72px server strip + 240px channel panel)
|
||||||
- Channels are grouped by `category` field with collapsible headers
|
- Channels are grouped by `categoryId` (references `categories` table) with collapsible headers and drag-and-drop reordering (@dnd-kit)
|
||||||
|
- Categories are first-class entities with position-based ordering; uncategorized channels show under "Channels" group
|
||||||
- Members list groups by hoisted roles (isHoist) then Online/Offline
|
- Members list groups by hoisted roles (isHoist) then Online/Offline
|
||||||
- Avatar component supports both image URLs and colored-initial fallback
|
- Avatar component supports both image URLs and colored-initial fallback
|
||||||
- Title bar has back/forward navigation arrows
|
- Title bar has back/forward navigation arrows
|
||||||
|
|||||||
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" />
|
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>discord</title>
|
<title>discord</title>
|
||||||
<script type="module" crossorigin src="./assets/index-DIG5pjLm.js"></script>
|
<script type="module" crossorigin src="./assets/index-BhwDWh5r.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-Ckz_sV-I.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-D8p__dJ4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
60
Frontend/Electron/package-lock.json
generated
60
Frontend/Electron/package-lock.json
generated
@@ -1,14 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"version": "1.0.2",
|
"version": "1.0.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"version": "1.0.2",
|
"version": "1.0.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/presence": "^0.3.0",
|
"@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-react": "^2.9.17",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"convex": "^1.31.2",
|
"convex": "^1.31.2",
|
||||||
@@ -375,6 +378,59 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/@electron/asar": {
|
||||||
"version": "3.4.1",
|
"version": "3.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "discord",
|
"name": "discord",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.7",
|
"version": "1.0.8",
|
||||||
"description": "A Discord clone built with Convex, React, and Electron",
|
"description": "A Discord clone built with Convex, React, and Electron",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -60,6 +60,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@convex-dev/presence": "^0.3.0",
|
"@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-react": "^2.9.17",
|
||||||
"@livekit/components-styles": "^1.2.0",
|
"@livekit/components-styles": "^1.2.0",
|
||||||
"convex": "^1.31.2",
|
"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 (
|
return (
|
||||||
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', marginTop: '4px', flexWrap: 'wrap' }}>
|
||||||
{Object.entries(msg.reactions).map(([emojiName, data]) => (
|
{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' }}>
|
<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={data.me ? null : 'var(--header-secondary)'} />
|
<ColoredIcon src={getReactionIcon(emojiName)} size="16px" color={null} />
|
||||||
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
<span style={{ fontSize: '12px', color: data.me ? '#dee0fc' : 'var(--header-secondary)', fontWeight: 600 }}>{data.count}</span>
|
||||||
</div>
|
</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 { useNavigate } from 'react-router-dom';
|
||||||
import { useConvex, useMutation, useQuery } from 'convex/react';
|
import { useConvex, useMutation, useQuery } from 'convex/react';
|
||||||
import { api } from '../../../../convex/_generated/api';
|
import { api } from '../../../../convex/_generated/api';
|
||||||
@@ -11,6 +11,9 @@ import DMList from './DMList';
|
|||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import UserSettings from './UserSettings';
|
import UserSettings from './UserSettings';
|
||||||
import { Track } from 'livekit-client';
|
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 muteIcon from '../assets/icons/mute.svg';
|
||||||
import mutedIcon from '../assets/icons/muted.svg';
|
import mutedIcon from '../assets/icons/muted.svg';
|
||||||
import defeanIcon from '../assets/icons/defean.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 cameraIcon from '../assets/icons/camera.svg';
|
||||||
import screenIcon from '../assets/icons/screen.svg';
|
import screenIcon from '../assets/icons/screen.svg';
|
||||||
import inviteUserIcon from '../assets/icons/invite_user.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';
|
import PingSound from '../assets/sounds/ping.mp3';
|
||||||
|
|
||||||
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
|
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 [isCreating, setIsCreating] = useState(false);
|
||||||
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
const [isServerSettingsOpen, setIsServerSettingsOpen] = useState(false);
|
||||||
const [newChannelName, setNewChannelName] = useState('');
|
const [newChannelName, setNewChannelName] = useState('');
|
||||||
@@ -345,9 +594,19 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
const [editingChannel, setEditingChannel] = useState(null);
|
const [editingChannel, setEditingChannel] = useState(null);
|
||||||
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
const [isScreenShareModalOpen, setIsScreenShareModalOpen] = useState(false);
|
||||||
const [collapsedCategories, setCollapsedCategories] = useState({});
|
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();
|
const convex = useConvex();
|
||||||
|
|
||||||
|
// DnD sensors
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||||
|
);
|
||||||
|
|
||||||
// Unread tracking
|
// Unread tracking
|
||||||
const channelIds = React.useMemo(() => [
|
const channelIds = React.useMemo(() => [
|
||||||
...channels.map(c => c._id),
|
...channels.map(c => c._id),
|
||||||
@@ -597,17 +856,137 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
setCollapsedCategories(prev => ({ ...prev, [cat]: !prev[cat] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Group channels by categoryId
|
||||||
const groupedChannels = React.useMemo(() => {
|
const groupedChannels = React.useMemo(() => {
|
||||||
const groups = {};
|
const groups = [];
|
||||||
|
const channelsByCategory = new Map();
|
||||||
|
|
||||||
channels.forEach(ch => {
|
channels.forEach(ch => {
|
||||||
const cat = ch.type === 'voice'
|
const catId = ch.categoryId || '__uncategorized__';
|
||||||
? (ch.category || 'Voice Channels')
|
if (!channelsByCategory.has(catId)) channelsByCategory.set(catId, []);
|
||||||
: (ch.category || 'Text Channels');
|
channelsByCategory.get(catId).push(ch);
|
||||||
if (!groups[cat]) groups[cat] = [];
|
|
||||||
groups[cat].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;
|
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 = () => (
|
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' }}>
|
<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>
|
</button>
|
||||||
</div>
|
</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 && (
|
{isCreating && (
|
||||||
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
<div style={{ padding: '0 8px', marginBottom: '4px' }}>
|
||||||
<form onSubmit={handleSubmitCreate}>
|
<form onSubmit={handleSubmitCreate}>
|
||||||
@@ -654,72 +1038,114 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{Object.entries(groupedChannels).map(([category, catChannels]) => (
|
<DndContext
|
||||||
<div key={category}>
|
sensors={sensors}
|
||||||
<div className="channel-category-header" onClick={() => toggleCategory(category)}>
|
collisionDetection={closestCenter}
|
||||||
<span className="category-label">{category}</span>
|
onDragStart={handleDragStart}
|
||||||
<button className="category-add-btn" onClick={(e) => { e.stopPropagation(); handleStartCreate(); }} title="Create Channel">
|
onDragEnd={handleDragEnd}
|
||||||
+
|
>
|
||||||
</button>
|
<SortableContext items={categoryDndIds} strategy={verticalListSortingStrategy}>
|
||||||
</div>
|
{groupedChannels.map(group => {
|
||||||
{!collapsedCategories[category] && catChannels.map(channel => {
|
const channelDndIds = group.channels.map(ch => `channel-${ch._id}`);
|
||||||
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={channel._id}>
|
<SortableCategory key={group.id} id={`category-${group.id}`}>
|
||||||
<div
|
<CategoryHeader
|
||||||
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
group={group}
|
||||||
onClick={() => handleChannelClick(channel)}
|
collapsed={collapsedCategories[group.id]}
|
||||||
style={{
|
onToggle={() => toggleCategory(group.id)}
|
||||||
position: 'relative',
|
onAddChannel={() => {
|
||||||
display: 'flex',
|
setCreateChannelCategoryId(group.id === '__uncategorized__' ? null : group.id);
|
||||||
justifyContent: 'space-between',
|
setShowCreateChannelModal(true);
|
||||||
alignItems: 'center',
|
}}
|
||||||
paddingRight: '8px'
|
/>
|
||||||
}}
|
{(() => {
|
||||||
>
|
const visibleChannels = collapsedCategories[group.id]
|
||||||
{isUnread && <div className="channel-unread-indicator" />}
|
? group.channels.filter(ch => ch._id === activeChannel)
|
||||||
<div style={{ display: 'flex', alignItems: 'center', overflow: 'hidden', flex: 1 }}>
|
: group.channels;
|
||||||
{channel.type === 'voice' ? (
|
if (visibleChannels.length === 0) return null;
|
||||||
<div style={{ marginRight: 6 }}>
|
const visibleDndIds = visibleChannels.map(ch => `channel-${ch._id}`);
|
||||||
<ColoredIcon
|
return (
|
||||||
src={voiceIcon}
|
<SortableContext items={visibleDndIds} strategy={verticalListSortingStrategy}>
|
||||||
size="16px"
|
{visibleChannels.map(channel => {
|
||||||
color={voiceStates[channel._id]?.length > 0 ? VOICE_ACTIVE_COLOR : "var(--interactive-normal)"}
|
const isUnread = activeChannel !== channel._id && unreadChannels.has(channel._id);
|
||||||
/>
|
return (
|
||||||
</div>
|
<SortableChannel key={channel._id} id={`channel-${channel._id}`}>
|
||||||
) : (
|
<React.Fragment>
|
||||||
<span style={{ color: 'var(--interactive-normal)', marginRight: '6px', flexShrink: 0 }}>#</span>
|
<div
|
||||||
)}
|
className={`channel-item ${activeChannel === channel._id ? 'active' : ''} ${voiceChannelId === channel._id ? 'voice-active' : ''}`}
|
||||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', ...(isUnread ? { color: 'var(--header-primary)', fontWeight: 600 } : {}) }}>{channel.name}</span>
|
onClick={() => handleChannelClick(channel)}
|
||||||
</div>
|
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
|
<button
|
||||||
className="channel-settings-icon"
|
className="channel-settings-icon"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setEditingChannel(channel);
|
setEditingChannel(channel);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
color: 'var(--interactive-normal)',
|
cursor: 'pointer',
|
||||||
cursor: 'pointer',
|
padding: '2px 4px',
|
||||||
fontSize: '12px',
|
display: 'flex', alignItems: 'center',
|
||||||
padding: '2px 4px',
|
}}
|
||||||
display: 'flex', alignItems: 'center',
|
>
|
||||||
opacity: '0',
|
<ColoredIcon src={settingsIcon} color="var(--interactive-normal)" size="14px" />
|
||||||
transition: 'opacity 0.2s'
|
</button>
|
||||||
}}
|
</div>
|
||||||
>
|
{renderVoiceUsers(channel)}
|
||||||
⚙️
|
</React.Fragment>
|
||||||
</button>
|
</SortableChannel>
|
||||||
</div>
|
);
|
||||||
{renderVoiceUsers(channel)}
|
})}
|
||||||
</React.Fragment>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -844,8 +1270,64 @@ const Sidebar = ({ channels, activeChannel, onSelectChannel, username, channelKe
|
|||||||
onSelectSource={handleScreenShareSelect}
|
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>
|
</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;
|
export default Sidebar;
|
||||||
|
|||||||
@@ -1930,7 +1930,8 @@ body {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-item:hover .channel-settings-icon {
|
.channel-item:hover .channel-settings-icon,
|
||||||
|
.channel-item.active .channel-settings-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2016,7 +2017,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-chevron {
|
.category-chevron {
|
||||||
margin-right: 2px;
|
margin-left: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.2s;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -2027,13 +2028,13 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-label {
|
.category-label {
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-add-btn {
|
.category-add-btn {
|
||||||
|
margin-left: auto;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--interactive-normal);
|
color: var(--interactive-normal);
|
||||||
@@ -2603,3 +2604,219 @@ body {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
z-index: 1;
|
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]);
|
}, [toggleMute]);
|
||||||
|
|
||||||
const channels = useQuery(api.channels.list) || [];
|
const channels = useQuery(api.channels.list) || [];
|
||||||
|
const categories = useQuery(api.categories.list) || [];
|
||||||
|
|
||||||
const rawChannelKeys = useQuery(
|
const rawChannelKeys = useQuery(
|
||||||
api.channelKeys.getKeysForUser,
|
api.channelKeys.getKeysForUser,
|
||||||
@@ -246,6 +247,7 @@ const Chat = () => {
|
|||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
channels={channels}
|
channels={channels}
|
||||||
|
categories={categories}
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
onSelectChannel={handleSelectChannel}
|
onSelectChannel={handleSelectChannel}
|
||||||
username={username}
|
username={username}
|
||||||
|
|||||||
7
TODO.md
7
TODO.md
@@ -10,3 +10,10 @@
|
|||||||
- In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages.
|
- In the mention list we should also have roles, so if people do @everyone they should mention everyone in the server, or they can @ a certain role they want to mention from the server. This does not apply to private messages.
|
||||||
|
|
||||||
- Owners should be able to delete anyones message in the server.
|
- Owners should be able to delete anyones message in the server.
|
||||||
|
|
||||||
|
- When we collapse a category and lets say for example it has a text channel in that category and we have it selected we should still show that text channel but all the others are collapsed
|
||||||
|
|
||||||
|
- Next to the category name lets put the category_collapsed_icon.svg icon. This icon is facing down so we will show it normal when a category is not collapsed and rotate it -45 degrees when it is collapsed
|
||||||
|
|
||||||
|
For reactions that we didnt react to we have the background to var(--embed-background), lets make it
|
||||||
|
hsl(240 calc(1*4%) 60.784% /0.0784313725490196)
|
||||||
|
|||||||
2
convex/_generated/api.d.ts
vendored
2
convex/_generated/api.d.ts
vendored
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
|
import type * as categories from "../categories.js";
|
||||||
import type * as channelKeys from "../channelKeys.js";
|
import type * as channelKeys from "../channelKeys.js";
|
||||||
import type * as channels from "../channels.js";
|
import type * as channels from "../channels.js";
|
||||||
import type * as dms from "../dms.js";
|
import type * as dms from "../dms.js";
|
||||||
@@ -33,6 +34,7 @@ import type {
|
|||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
|
categories: typeof categories;
|
||||||
channelKeys: typeof channelKeys;
|
channelKeys: typeof channelKeys;
|
||||||
channels: typeof channels;
|
channels: typeof channels;
|
||||||
dms: typeof dms;
|
dms: typeof dms;
|
||||||
|
|||||||
99
convex/categories.ts
Normal file
99
convex/categories.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { query, mutation } from "./_generated/server";
|
||||||
|
import { v } from "convex/values";
|
||||||
|
|
||||||
|
// List all categories ordered by position
|
||||||
|
export const list = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id("categories"),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
name: v.string(),
|
||||||
|
position: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query("categories")
|
||||||
|
.withIndex("by_position")
|
||||||
|
.collect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new category
|
||||||
|
export const create = mutation({
|
||||||
|
args: { name: v.string() },
|
||||||
|
returns: v.object({ id: v.id("categories") }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const name = args.name.trim();
|
||||||
|
if (!name) throw new Error("Category name required");
|
||||||
|
|
||||||
|
// Auto-assign position at end
|
||||||
|
const all = await ctx.db.query("categories").collect();
|
||||||
|
const maxPos = all.reduce((max, c) => Math.max(max, c.position), -1000);
|
||||||
|
|
||||||
|
const id = await ctx.db.insert("categories", {
|
||||||
|
name,
|
||||||
|
position: maxPos + 1000,
|
||||||
|
});
|
||||||
|
return { id };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rename a category
|
||||||
|
export const rename = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("categories"),
|
||||||
|
name: v.string(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const name = args.name.trim();
|
||||||
|
if (!name) throw new Error("Category name required");
|
||||||
|
const cat = await ctx.db.get(args.id);
|
||||||
|
if (!cat) throw new Error("Category not found");
|
||||||
|
await ctx.db.patch(args.id, { name });
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a category (moves its channels to uncategorized)
|
||||||
|
export const remove = mutation({
|
||||||
|
args: { id: v.id("categories") },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const cat = await ctx.db.get(args.id);
|
||||||
|
if (!cat) throw new Error("Category not found");
|
||||||
|
|
||||||
|
// Move channels to uncategorized
|
||||||
|
const channels = await ctx.db
|
||||||
|
.query("channels")
|
||||||
|
.withIndex("by_category", (q) => q.eq("categoryId", args.id))
|
||||||
|
.collect();
|
||||||
|
for (const ch of channels) {
|
||||||
|
await ctx.db.patch(ch._id, { categoryId: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch reorder categories
|
||||||
|
export const reorder = mutation({
|
||||||
|
args: {
|
||||||
|
updates: v.array(
|
||||||
|
v.object({
|
||||||
|
id: v.id("categories"),
|
||||||
|
position: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
for (const u of args.updates) {
|
||||||
|
await ctx.db.patch(u.id, { position: u.position });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -32,7 +32,7 @@ export const list = query({
|
|||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(),
|
type: v.string(),
|
||||||
category: v.optional(v.string()),
|
categoryId: v.optional(v.id("categories")),
|
||||||
topic: v.optional(v.string()),
|
topic: v.optional(v.string()),
|
||||||
position: v.optional(v.number()),
|
position: v.optional(v.number()),
|
||||||
})
|
})
|
||||||
@@ -54,7 +54,7 @@ export const get = query({
|
|||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(),
|
type: v.string(),
|
||||||
category: v.optional(v.string()),
|
categoryId: v.optional(v.id("categories")),
|
||||||
topic: v.optional(v.string()),
|
topic: v.optional(v.string()),
|
||||||
position: v.optional(v.number()),
|
position: v.optional(v.number()),
|
||||||
}),
|
}),
|
||||||
@@ -70,7 +70,7 @@ export const create = mutation({
|
|||||||
args: {
|
args: {
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.optional(v.string()),
|
type: v.optional(v.string()),
|
||||||
category: v.optional(v.string()),
|
categoryId: v.optional(v.id("categories")),
|
||||||
topic: v.optional(v.string()),
|
topic: v.optional(v.string()),
|
||||||
position: v.optional(v.number()),
|
position: v.optional(v.number()),
|
||||||
},
|
},
|
||||||
@@ -89,12 +89,26 @@ export const create = mutation({
|
|||||||
throw new Error("Channel already exists");
|
throw new Error("Channel already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-calculate position if not provided
|
||||||
|
let position = args.position;
|
||||||
|
if (position === undefined) {
|
||||||
|
const allChannels = await ctx.db.query("channels").collect();
|
||||||
|
const sameCategory = allChannels.filter(
|
||||||
|
(c) => c.categoryId === args.categoryId && c.type !== "dm"
|
||||||
|
);
|
||||||
|
const maxPos = sameCategory.reduce(
|
||||||
|
(max, c) => Math.max(max, c.position ?? 0),
|
||||||
|
-1000
|
||||||
|
);
|
||||||
|
position = maxPos + 1000;
|
||||||
|
}
|
||||||
|
|
||||||
const id = await ctx.db.insert("channels", {
|
const id = await ctx.db.insert("channels", {
|
||||||
name: args.name,
|
name: args.name,
|
||||||
type: args.type || "text",
|
type: args.type || "text",
|
||||||
category: args.category,
|
categoryId: args.categoryId,
|
||||||
topic: args.topic,
|
topic: args.topic,
|
||||||
position: args.position,
|
position,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id };
|
return { id };
|
||||||
@@ -127,7 +141,7 @@ export const rename = mutation({
|
|||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(),
|
type: v.string(),
|
||||||
category: v.optional(v.string()),
|
categoryId: v.optional(v.id("categories")),
|
||||||
topic: v.optional(v.string()),
|
topic: v.optional(v.string()),
|
||||||
position: v.optional(v.number()),
|
position: v.optional(v.number()),
|
||||||
}),
|
}),
|
||||||
@@ -146,6 +160,48 @@ export const rename = mutation({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Move a channel to a different category with a new position
|
||||||
|
export const moveChannel = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id("channels"),
|
||||||
|
categoryId: v.optional(v.id("categories")),
|
||||||
|
position: v.number(),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const channel = await ctx.db.get(args.id);
|
||||||
|
if (!channel) throw new Error("Channel not found");
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
categoryId: args.categoryId,
|
||||||
|
position: args.position,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Batch reorder channels
|
||||||
|
export const reorderChannels = mutation({
|
||||||
|
args: {
|
||||||
|
updates: v.array(
|
||||||
|
v.object({
|
||||||
|
id: v.id("channels"),
|
||||||
|
categoryId: v.optional(v.id("categories")),
|
||||||
|
position: v.number(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
for (const u of args.updates) {
|
||||||
|
await ctx.db.patch(u.id, {
|
||||||
|
categoryId: u.categoryId,
|
||||||
|
position: u.position,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Delete channel + cascade messages and keys
|
// Delete channel + cascade messages and keys
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: { id: v.id("channels") },
|
args: { id: v.id("channels") },
|
||||||
|
|||||||
@@ -18,13 +18,19 @@ export default defineSchema({
|
|||||||
customStatus: v.optional(v.string()),
|
customStatus: v.optional(v.string()),
|
||||||
}).index("by_username", ["username"]),
|
}).index("by_username", ["username"]),
|
||||||
|
|
||||||
|
categories: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
position: v.number(),
|
||||||
|
}).index("by_position", ["position"]),
|
||||||
|
|
||||||
channels: defineTable({
|
channels: defineTable({
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
type: v.string(), // 'text' | 'voice' | 'dm'
|
type: v.string(), // 'text' | 'voice' | 'dm'
|
||||||
category: v.optional(v.string()),
|
categoryId: v.optional(v.id("categories")),
|
||||||
topic: v.optional(v.string()),
|
topic: v.optional(v.string()),
|
||||||
position: v.optional(v.number()),
|
position: v.optional(v.number()),
|
||||||
}).index("by_name", ["name"]),
|
}).index("by_name", ["name"])
|
||||||
|
.index("by_category", ["categoryId"]),
|
||||||
|
|
||||||
messages: defineTable({
|
messages: defineTable({
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
|
|||||||
Reference in New Issue
Block a user