Initial commit: EifelDC - Discord-like Matrix chat platform
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Rust Tests (push) Has been cancelled
CI / Frontend Check (push) Has been cancelled
CI / Build Tauri (macOS) (push) Has been cancelled
CI / Build Tauri (macOS Intel) (push) Has been cancelled
CI / Build Tauri (Linux) (push) Has been cancelled

Includes server (Rust/Axum API proxy with voice management),
Tauri desktop client with Svelte UI, bot-sdk, Docker infra
(Synapse, PostgreSQL, Coturn, Nginx), and CI/CD pipeline.
This commit is contained in:
root
2026-04-28 08:23:23 +02:00
commit 0978d0c2e9
82 changed files with 12417 additions and 0 deletions

13
client/src-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EifelDC</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1877
client/src-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "eifeldc-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3",
"@tauri-apps/api": "^1",
"svelte": "^4",
"svelte-check": "^3",
"svelte-preprocess": "^5.1.4",
"tslib": "^2",
"typescript": "^5.9.3",
"vite": "^5"
}
}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import LoginScreen from './components/LoginScreen.svelte';
import ServerSidebar from './components/ServerSidebar.svelte';
import ChannelSidebar from './components/ChannelSidebar.svelte';
import ChatArea from './components/ChatArea.svelte';
import MemberList from './components/MemberList.svelte';
import VoicePanel from './components/VoicePanel.svelte';
import { currentUser, refreshChannels } from './lib/store';
let loggedIn = false;
let loading = true;
function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
onMount(async () => {
if (isTauri()) {
try {
const { invoke } = await import('@tauri-apps/api/tauri');
const user: string | null = await invoke('get_current_user');
if (user) {
loggedIn = true;
currentUser.set({
id: user,
username: user.split(':')[0].replace('@', ''),
avatarUrl: null,
status: 'online',
});
await refreshChannels();
}
} catch (e) {
loggedIn = false;
}
} else {
const token = localStorage.getItem('eifeldc_token');
if (token) {
try {
const res = await fetch('/api/current-user', {
headers: { 'Authorization': `Bearer ${token}` },
});
if (res.ok) {
const userId: string | null = await res.json();
if (userId) {
loggedIn = true;
currentUser.set({
id: userId,
username: userId.split(':')[0].replace('@', ''),
avatarUrl: null,
status: 'online',
});
await refreshChannels();
}
}
} catch (e) {
localStorage.removeItem('eifeldc_token');
}
}
}
loading = false;
});
</script>
{#if loading}
<div class="splash">
<div class="splash-logo">
<svg viewBox="0 0 100 100" width="80" height="80">
<circle cx="50" cy="50" r="45" fill="var(--accent)" />
<text x="50" y="58" text-anchor="middle" fill="white" font-size="30" font-weight="bold">E</text>
</svg>
</div>
<p>Loading EifelDC...</p>
</div>
{:else if !loggedIn}
<LoginScreen on:login={() => (loggedIn = true)} />
{:else}
<div class="app-layout">
<ServerSidebar />
<ChannelSidebar />
<div class="main-content">
<ChatArea />
</div>
<MemberList />
</div>
<VoicePanel />
{/if}
<style>
.splash {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: var(--bg-primary);
color: var(--text-secondary);
}
.app-layout {
display: flex;
height: 100vh;
width: 100vw;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
</style>

View File

@@ -0,0 +1,595 @@
<script lang="ts">
import { currentServer, channels, currentChannel, textChannels, voiceChannels, currentUser, voiceState, refreshChannels } from '../lib/store';
import { onMount } from 'svelte';
import { getJoinedRooms, joinVoiceChannel, createRoom, joinRoom, leaveRoom, logout, setPresence, type VoiceStateInfo, type RoomInfo } from '../lib/api';
let showCreateRoom = false;
let showJoinRoom = false;
let newRoomName = '';
let newRoomTopic = '';
let joinRoomInput = '';
let createError = '';
let joinError = '';
let showSettings = false;
let presenceStatus: 'online' | 'idle' | 'dnd' | 'offline' = 'online';
onMount(async () => {
try {
const rooms: RoomInfo[] = await getJoinedRooms();
channels.set(rooms.map((r: RoomInfo) => ({
id: r.room_id,
name: r.name || r.room_id,
type: 'text' as const,
topic: r.topic || null,
parentId: null,
})));
} catch (e) {
console.error('Failed to load rooms', e);
}
});
async function handleCreateRoom() {
createError = '';
try {
await createRoom(newRoomName, newRoomTopic || undefined);
showCreateRoom = false;
newRoomName = '';
newRoomTopic = '';
await refreshChannels();
} catch (e: any) {
createError = e.toString();
}
}
async function handleJoinRoom() {
joinError = '';
try {
await joinRoom(joinRoomInput);
showJoinRoom = false;
joinRoomInput = '';
await refreshChannels();
} catch (e: any) {
joinError = e.toString();
}
}
async function handleJoinVoice(channel: any) {
try {
const result: VoiceStateInfo = await joinVoiceChannel(channel.id);
voiceState.set({
channelId: result.room_id,
muted: result.muted,
deafened: result.deafened,
streaming: result.streaming,
});
} catch (e) {
console.error('Failed to join voice', e);
}
}
async function handleLeaveRoom(roomId: string) {
try {
await leaveRoom(roomId);
await refreshChannels();
if ($currentChannel?.id === roomId) {
currentChannel.set(null);
}
} catch (e) {
console.error('Failed to leave room', e);
}
}
async function handleLogout() {
try {
await logout();
window.location.reload();
} catch (e) {
console.error('Failed to logout', e);
}
}
async function handlePresenceChange(status: string) {
try {
await setPresence(status);
presenceStatus = status as any;
} catch (e) {
console.error('Failed to set presence', e);
}
}
function extractUsername(userId: string): string {
return userId.split(':')[0].replace('@', '');
}
</script>
<div class="channel-sidebar">
<div class="server-header">
<span class="server-name">{$currentServer?.name || 'EifelDC'}</span>
<button class="btn-icon" on:click={() => showSettings = !showSettings}>
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
</button>
</div>
{#if showSettings}
<div class="settings-popup">
<div class="settings-section">
<div class="settings-label">Status</div>
<button class="status-option" on:click={() => handlePresenceChange('online')}>
<span class="status-indicator" style="background: var(--online)"></span> Online
</button>
<button class="status-option" on:click={() => handlePresenceChange('away')}>
<span class="status-indicator" style="background: var(--idle)"></span> Abwesend
</button>
<button class="status-option" on:click={() => handlePresenceChange('unavailable')}>
<span class="status-indicator" style="background: var(--dnd)"></span> Nicht stören
</button>
</div>
<hr />
<button class="settings-option" on:click={handleLogout}>Abmelden</button>
</div>
{/if}
<div class="channel-list">
<div class="channel-category">
<div class="category-header" on:click={() => showCreateRoom = !showCreateRoom}>
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
<span>Textkanäle</span>
<span class="category-add" on:click|stopPropagation={() => { showCreateRoom = true; showJoinRoom = false; }}>+</span>
</div>
{#each $textChannels as channel}
<button
class="channel-item"
class:active={$currentChannel?.id === channel.id}
on:click={() => currentChannel.set(channel)}
on:contextmenu|preventDefault={() => handleLeaveRoom(channel.id)}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon">
<path d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.89044C10.2015 3 10.4371 3.28107 10.3827 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8904C18.2015 3 18.4371 3.28107 18.3827 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.755C20.0656 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8696C13.5585 21 13.3229 20.7189 13.3773 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z"/>
</svg>
<span>{channel.name}</span>
</button>
{/each}
</div>
<div class="channel-category">
<div class="category-header">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M7 10l5 5 5-5z"/></svg>
<span>Sprachkanäle</span>
</div>
{#each $voiceChannels as channel}
<button
class="channel-item voice"
class:active={$currentChannel?.id === channel.id}
on:click={() => handleJoinVoice(channel)}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" class="channel-icon">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/>
</svg>
<span>{channel.name}</span>
</button>
{/each}
</div>
<button class="add-channel-btn" on:click={() => { showJoinRoom = true; showCreateRoom = false; }}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z"/></svg>
Raum beitreten
</button>
</div>
{#if showCreateRoom}
<div class="modal-overlay" on:click={() => showCreateRoom = false}>
<div class="modal" on:click|stopPropagation>
<h3>Raum erstellen</h3>
{#if createError}
<div class="modal-error">{createError}</div>
{/if}
<div class="modal-field">
<label>Name</label>
<input type="text" bind:value={newRoomName} placeholder="Raumname" />
</div>
<div class="modal-field">
<label>Thema (optional)</label>
<input type="text" bind:value={newRoomTopic} placeholder="Thema" />
</div>
<div class="modal-actions">
<button class="btn-secondary" on:click={() => showCreateRoom = false}>Abbrechen</button>
<button class="btn-primary" on:click={handleCreateRoom} disabled={!newRoomName}>Erstellen</button>
</div>
</div>
</div>
{/if}
{#if showJoinRoom}
<div class="modal-overlay" on:click={() => showJoinRoom = false}>
<div class="modal" on:click|stopPropagation>
<h3>Raum beitreten</h3>
{#if joinError}
<div class="modal-error">{joinError}</div>
{/if}
<div class="modal-field">
<label>Raum-ID oder Alias</label>
<input type="text" bind:value={joinRoomInput} placeholder="#raum:matrix.org" />
</div>
<div class="modal-actions">
<button class="btn-secondary" on:click={() => showJoinRoom = false}>Abbrechen</button>
<button class="btn-primary" on:click={handleJoinRoom} disabled={!joinRoomInput}>Beitreten</button>
</div>
</div>
</div>
{/if}
<div class="user-panel">
<div class="user-info">
<div class="avatar-small">{$currentUser?.username?.charAt(0).toUpperCase() || '?'}</div>
<div class="user-details">
<span class="username">{$currentUser?.username || 'Benutzer'}</span>
<span class="status-text">{presenceStatus === 'online' ? 'Online' : presenceStatus === 'idle' ? 'Abwesend' : presenceStatus === 'dnd' ? 'Nicht stören' : 'Offline'}</span>
</div>
</div>
<div class="user-actions">
<button class="btn-icon" title="Mikrofon" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, muted: !s.muted })); }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
</button>
<button class="btn-icon" title="Kopfhörer" on:click={() => { if ($voiceState.channelId) voiceState.update(s => ({ ...s, deafened: !s.deafened })); }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/></svg>
</button>
<button class="btn-icon" title="Einstellungen" on:click={() => showSettings = !showSettings}>⚙</button>
</div>
</div>
</div>
<style>
.channel-sidebar {
width: 240px;
min-width: 240px;
background: var(--bg-secondary);
display: flex;
flex-direction: column;
position: relative;
}
.server-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--bg-primary);
cursor: pointer;
}
.server-name {
font-weight: 600;
font-size: 0.95rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-list {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.channel-category {
margin-bottom: 8px;
}
.category-header {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 16px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
}
.category-header:hover {
color: var(--text-primary);
}
.category-add {
margin-left: auto;
font-size: 1rem;
line-height: 1;
}
.category-add:hover {
color: var(--text-primary);
}
.channel-item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 16px;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.9rem;
}
.channel-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-radius: var(--radius-sm);
}
.channel-item.active {
background: var(--bg-active);
color: var(--text-primary);
border-radius: var(--radius-sm);
}
.channel-icon {
flex-shrink: 0;
}
.add-channel-btn {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 8px 16px;
border: none;
background: transparent;
color: var(--success);
cursor: pointer;
font-size: 0.85rem;
}
.add-channel-btn:hover {
background: var(--bg-hover);
}
.user-panel {
height: 52px;
padding: 0 8px;
background: var(--bg-primary);
display: flex;
align-items: center;
justify-content: space-between;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.avatar-small {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.username {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-text {
font-size: 0.7rem;
color: var(--text-secondary);
}
.user-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.btn-icon {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.settings-popup {
position: absolute;
bottom: 52px;
left: 8px;
right: 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
padding: 8px;
z-index: 10;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.settings-section {
display: flex;
flex-direction: column;
gap: 2px;
}
.settings-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
padding: 4px 8px;
}
.status-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: none;
background: transparent;
color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: 0.85rem;
}
.status-option:hover {
background: var(--bg-hover);
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.settings-option {
width: 100%;
padding: 6px 8px;
border: none;
background: transparent;
color: var(--danger);
cursor: pointer;
border-radius: var(--radius-sm);
font-size: 0.85rem;
text-align: left;
}
.settings-option:hover {
background: var(--bg-hover);
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
padding: 24px;
width: 400px;
max-width: 90vw;
}
.modal h3 {
margin-bottom: 16px;
}
.modal-field {
margin-bottom: 12px;
}
.modal-field label {
display: block;
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 4px;
}
.modal-field input {
width: 100%;
padding: 8px 12px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.95rem;
outline: none;
}
.modal-field input:focus {
border-color: var(--accent);
}
.modal-error {
background: rgba(237, 66, 69, 0.15);
color: var(--danger);
padding: 8px;
border-radius: var(--radius-sm);
margin-bottom: 12px;
font-size: 0.85rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.btn-primary {
padding: 8px 16px;
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.btn-secondary:hover {
color: var(--text-primary);
border-color: var(--text-secondary);
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 8px 0;
}
</style>

View File

@@ -0,0 +1,284 @@
<script lang="ts">
import { messages, currentChannel, members } from '../lib/store';
import { getRoomMessages, sendMessage, getRoomMembers } from '../lib/api';
import { onMount, afterUpdate } from 'svelte';
let inputText = '';
let chatContainer: HTMLElement;
let loading = false;
async function loadMessages() {
if (!$currentChannel) return;
loading = true;
try {
const roomId = $currentChannel.id;
const msgs = await getRoomMessages(roomId, 50);
messages.set(msgs.map(m => ({
id: m.event_id,
sender: m.sender,
content: m.body,
timestamp: m.timestamp,
replyTo: m.reply_to,
isBot: false,
reactions: {},
})));
const memberIds = await getRoomMembers(roomId);
members.set(memberIds.map(id => ({
id,
username: id.split(':')[0].replace('@', ''),
avatarUrl: null,
status: 'online' as const,
roles: [],
})));
} catch (e) {
console.error('Failed to load messages', e);
}
loading = false;
}
$: if ($currentChannel) loadMessages();
async function handleSend() {
if (!inputText.trim() || !$currentChannel) return;
const text = inputText.trim();
inputText = '';
try {
await sendMessage($currentChannel.id, text);
} catch (e) {
console.error('Failed to send message', e);
}
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
function extractUsername(userId: string): string {
return userId.split(':')[0].replace('@', '');
}
afterUpdate(() => {
if (chatContainer) chatContainer.scrollTop = chatContainer.scrollHeight;
});
</script>
<div class="chat-area">
<div class="chat-header">
<span class="channel-hash">#</span>
<span class="channel-name">{$currentChannel?.name || 'Kanal auswählen'}</span>
{#if $currentChannel?.topic}
<span class="divider">|</span>
<span class="channel-topic">{$currentChannel.topic}</span>
{/if}
</div>
<div class="messages" bind:this={chatContainer}>
{#if loading}
<div class="loading">Nachrichten werden geladen...</div>
{:else if $messages.length === 0}
<div class="empty">Noch keine Nachrichten. Sei der Erste!</div>
{:else}
{#each $messages as msg (msg.id)}
<div class="message">
<div class="message-avatar">
<div class="avatar">{extractUsername(msg.sender).charAt(0).toUpperCase()}</div>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-author">{extractUsername(msg.sender)}</span>
<span class="message-time">{formatTime(msg.timestamp)}</span>
</div>
<div class="message-body">{msg.content}</div>
{#if Object.keys(msg.reactions).length > 0}
<div class="reactions">
{#each Object.entries(msg.reactions) as [emoji, users]}
<span class="reaction">{emoji} {users.length}</span>
{/each}
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
<div class="input-area">
{#if $currentChannel}
<div class="input-container">
<button class="input-addon" title="Datei anhängen">📎</button>
<input
type="text"
bind:value={inputText}
placeholder="Nachricht in #{$currentChannel?.name} senden"
on:keydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) handleSend(); }}
/>
<button class="input-addon" title="Emoji">😀</button>
</div>
{:else}
<div class="no-channel">Wähle einen Kanal aus</div>
{/if}
</div>
</div>
<style>
.chat-area {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.chat-header {
height: 48px;
padding: 0 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border);
background: var(--bg-secondary);
flex-shrink: 0;
}
.channel-hash {
font-size: 1.4rem;
color: var(--text-muted);
font-weight: 600;
}
.channel-name {
font-weight: 700;
font-size: 1rem;
}
.divider {
color: var(--border);
margin: 0 4px;
}
.channel-topic {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.message {
display: flex;
gap: 12px;
padding: 4px 0;
border-radius: var(--radius-sm);
}
.message:hover {
background: var(--bg-hover);
}
.message-avatar .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
align-items: baseline;
gap: 8px;
}
.message-author {
font-weight: 600;
font-size: 0.95rem;
}
.message-time {
font-size: 0.75rem;
color: var(--text-muted);
}
.message-body {
font-size: 0.95rem;
line-height: 1.4;
word-wrap: break-word;
}
.reactions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.reaction {
background: var(--bg-input);
padding: 2px 8px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
cursor: pointer;
}
.reaction:hover {
background: var(--bg-hover);
}
.input-area {
padding: 0 16px 16px;
flex-shrink: 0;
}
.input-container {
display: flex;
align-items: center;
background: var(--bg-input);
border-radius: var(--radius-md);
padding: 4px 8px;
}
.input-container input {
flex: 1;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 0.95rem;
padding: 8px;
outline: none;
}
.input-addon {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 1.2rem;
padding: 4px;
}
.input-addon:hover {
color: var(--text-primary);
}
.loading, .empty, .no-channel {
text-align: center;
color: var(--text-secondary);
padding: 2rem;
}
</style>

View File

@@ -0,0 +1,201 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { login, register } from '../lib/api';
const dispatch = createEventDispatcher();
let mode = 'login';
let homeserver = 'https://matrix.org';
let username = '';
let password = '';
let confirmPassword = '';
let error = '';
let loading = false;
async function handleSubmit() {
error = '';
loading = true;
try {
if (mode === 'login') {
const result = await login(homeserver, username, password);
if (result.success) {
dispatch('login', { userId: result.user_id });
} else {
error = result.error || 'Login fehlgeschlagen';
}
} else {
if (password !== confirmPassword) {
error = 'Passwörter stimmen nicht überein';
return;
}
const result = await register(homeserver, username, password);
if (result.success) {
dispatch('login', { userId: result.user_id });
} else {
error = result.error || 'Registrierung fehlgeschlagen';
}
}
} catch (e: any) {
error = e.toString();
} finally {
loading = false;
}
}
</script>
<div class="login-container">
<div class="login-box">
<div class="logo">
<svg viewBox="0 0 100 100" width="72" height="72">
<circle cx="50" cy="50" r="45" fill="var(--accent)" />
<text x="50" y="58" text-anchor="middle" fill="white" font-size="30" font-weight="bold">E</text>
</svg>
</div>
<h1>{mode === 'login' ? 'Willkommen zurück' : 'Account erstellen'}</h1>
<p class="subtitle">EifelDC — Matrix Client</p>
{#if error}
<div class="error">{error}</div>
{/if}
<form on:submit|preventDefault={handleSubmit}>
<div class="form-group">
<label for="homeserver">Homeserver</label>
<input id="homeserver" type="url" bind:value={homeserver} placeholder="https://matrix.org" />
</div>
<div class="form-group">
<label for="username">Benutzername</label>
<input id="username" type="text" bind:value={username} placeholder="Nutzername" />
</div>
<div class="form-group">
<label for="password">Passwort</label>
<input id="password" type="password" bind:value={password} placeholder="********" />
</div>
{#if mode === 'register'}
<div class="form-group">
<label for="confirm-password">Passwort bestätigen</label>
<input id="confirm-password" type="password" bind:value={confirmPassword} placeholder="********" />
</div>
{/if}
<button type="submit" class="btn-primary" disabled={loading}>
{loading ? '...' : mode === 'login' ? 'Anmelden' : 'Registrieren'}
</button>
</form>
<p class="switch-mode">
{#if mode === 'login'}
Noch kein Account? <button on:click={() => { mode = 'register'; error = ''; }}>Registrieren</button>
{:else}
Bereits registriert? <button on:click={() => { mode = 'login'; error = ''; }}>Anmelden</button>
{/if}
</p>
</div>
</div>
<style>
.login-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100vw;
background: var(--bg-primary);
}
.login-box {
background: var(--bg-secondary);
padding: 2rem;
border-radius: var(--radius-lg);
width: 420px;
max-width: 90vw;
}
.logo {
text-align: center;
margin-bottom: 1rem;
}
h1 {
text-align: center;
margin-bottom: 0.25rem;
}
.subtitle {
text-align: center;
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.error {
background: rgba(237, 66, 69, 0.15);
color: var(--danger);
padding: 0.75rem;
border-radius: var(--radius-sm);
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 0.5rem;
}
input {
width: 100%;
padding: 0.7rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 1rem;
outline: none;
}
input:focus {
border-color: var(--accent);
}
.btn-primary {
width: 100%;
padding: 0.75rem;
background: var(--accent);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
margin-top: 0.5rem;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.switch-mode {
text-align: center;
margin-top: 1rem;
color: var(--text-secondary);
}
.switch-mode button {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 1rem;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { sortedMembers, currentChannel } from '../lib/store';
function statusColor(status: string): string {
switch (status) {
case 'online': return 'var(--online)';
case 'idle': return 'var(--idle)';
case 'dnd': return 'var(--dnd)';
default: return 'var(--offline)';
}
}
function statusLabel(status: string): string {
switch (status) {
case 'online': return 'Online';
case 'idle': return 'Abwesend';
case 'dnd': return 'Nicht stören';
default: return 'Offline';
}
}
function extractUsername(id: string): string {
return id.split(':')[0].replace('@', '');
}
function groupByStatus(members: any[]): Record<string, any[]> {
const groups: Record<string, any[]> = { online: [], idle: [], dnd: [], offline: [] };
for (const m of members) {
(groups[m.status] || groups.offline).push(m);
}
return groups;
}
$: grouped = groupByStatus($sortedMembers);
</script>
<div class="member-list">
{#each [
{ key: 'online', label: 'Online' },
{ key: 'idle', label: 'Abwesend' },
{ key: 'dnd', label: 'Nicht stören' },
{ key: 'offline', label: 'Offline' }
] as group}
{#if grouped[group.key]?.length > 0}
<div class="member-group">
<div class="group-header">
{group.label}{grouped[group.key].length}
</div>
{#each grouped[group.key] as member}
<div class="member-item">
<div class="member-avatar">
<div class="avatar-circle">
{extractUsername(member.id).charAt(0).toUpperCase()}
</div>
<div class="status-dot" style="background: {statusColor(member.status)}"></div>
</div>
<div class="member-info">
<span class="member-name">{extractUsername(member.id)}</span>
{#if member.status !== 'offline'}
<span class="member-status">{statusLabel(member.status)}</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/each}
</div>
<style>
.member-list {
width: 240px;
min-width: 240px;
background: var(--bg-secondary);
padding: 8px 8px;
overflow-y: auto;
}
.member-group {
margin-bottom: 8px;
}
.group-header {
padding: 8px 8px 4px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text-secondary);
}
.member-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.member-item:hover {
background: var(--bg-hover);
}
.member-avatar {
position: relative;
}
.avatar-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 700;
}
.status-dot {
position: absolute;
bottom: -1px;
right: -1px;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid var(--bg-secondary);
}
.member-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.member-name {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-status {
font-size: 0.7rem;
color: var(--text-secondary);
}
</style>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { servers, currentServer } from '../lib/store';
import { onMount } from 'svelte';
let showAddServer = false;
function selectServer(server: any) {
currentServer.set(server);
}
</script>
<div class="server-sidebar">
<div class="server-list">
<button class="server-icon home" on:click={() => currentServer.set(null)}>
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
<path d="M12 2L2 12h3v8h6v-6h2v6h6v-8h3L12 2z" />
</svg>
</button>
<div class="separator"></div>
{#each $servers as server}
<button
class="server-icon"
class:active={$currentServer?.id === server.id}
on:click={() => selectServer(server)}
title={server.name}
>
{#if server.iconUrl}
<img src={server.iconUrl} alt={server.name} />
{:else}
<span class="server-initial">{server.name.charAt(0).toUpperCase()}</span>
{/if}
<div class="pill"></div>
</button>
{/each}
<button class="server-icon add" on:click={() => showAddServer = !showAddServer} title="Server beitreten">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
</svg>
</button>
</div>
</div>
<style>
.server-sidebar {
width: 72px;
min-width: 72px;
background: var(--bg-primary);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
overflow-y: auto;
}
.server-list {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.separator {
width: 32px;
height: 2px;
background: var(--border);
border-radius: 1px;
margin: 4px 0;
}
.server-icon {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
background: var(--bg-tertiary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-radius 0.2s, background 0.2s;
position: relative;
overflow: hidden;
}
.server-icon:hover {
border-radius: var(--radius-md);
background: var(--accent);
}
.server-icon.active {
border-radius: var(--radius-md);
background: var(--accent);
}
.server-icon img {
width: 48px;
height: 48px;
border-radius: inherit;
}
.server-initial {
font-size: 1.2rem;
font-weight: 700;
}
.home {
background: var(--bg-tertiary);
color: var(--accent);
}
.add {
background: transparent;
color: var(--success);
}
.add:hover {
background: var(--success);
color: white;
}
.pill {
position: absolute;
left: -4px;
width: 4px;
height: 0;
background: white;
border-radius: 0 4px 4px 0;
transition: height 0.2s;
}
.server-icon.active .pill,
.server-icon:hover .pill {
height: 20px;
}
</style>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { voiceState } from '../lib/store';
import { leaveVoiceChannel, toggleMute, toggleDeafen } from '../lib/api';
async function handleLeave() {
if (!$voiceState.channelId) return;
try {
await leaveVoiceChannel($voiceState.channelId);
voiceState.set({ channelId: null, muted: false, deafened: false, streaming: false });
} catch (e) {
console.error('Failed to leave voice', e);
}
}
async function handleMute() {
if (!$voiceState.channelId) return;
try {
const newMuted = await toggleMute($voiceState.channelId);
voiceState.update(s => ({ ...s, muted: newMuted }));
} catch (e) {
console.error('Failed to toggle mute', e);
}
}
async function handleDeafen() {
if (!$voiceState.channelId) return;
try {
const newDeaf = await toggleDeafen($voiceState.channelId);
voiceState.update(s => ({ ...s, deafened: newDeaf }));
} catch (e) {
console.error('Failed to toggle deafen', e);
}
}
</script>
{#if $voiceState.channelId}
<div class="voice-panel">
<div class="voice-info">
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--success)">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/>
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
</svg>
<span class="voice-label">Sprachverbunden</span>
</div>
<div class="voice-actions">
<button class="voice-btn" class:active={$voiceState.muted} on:click={handleMute} title="Mikrofon stumm">
{#if $voiceState.muted}
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.6.6L16 13.6V11c0-1.66-1.34-3-3-3-.36 0-.71.07-1.03.19l1.43 1.41zm3.6-6.8L3.86 16.14 5.27 17.55l2.62-2.62c.82.66 1.83 1.07 2.93 1.07h.18v3.08c-3.39.49-6 3.39-6 6.92h2c0-2.76 2.24-5 5-5h1v-4.77l5.27 5.27 1.41-1.41z"/></svg>
{:else}
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"/><path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
{/if}
</button>
<button class="voice-btn" class:active={$voiceState.deafened} on:click={handleDeafen} title="Kopfhörer stumm">
{#if $voiceState.deafened}
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M3.63 3.63a.996.996 0 000 1.41L7.29 8.7 7 9H4c-.55 0-1 .45-1 1v4c0 .55.45 1 1 1h3l4.29 4.29c.63.63 1.71.18 1.71-.71v-3.17l5.67 5.67a.996.996 0 101.41-1.41L5.04 3.63a.996.996 0 00-1.41 0zM19 12c0 .82-.33 1.56-.85 2.12l1.42 1.42A4.978 4.978 0 0021 12c0-2.12-1.31-3.93-3.17-4.68L17 8.62C18.23 9.24 19 10.53 19 12zm-4-6c0-2.76-2.24-5-5-5-.71 0-1.38.15-2 .42l1.46 1.46C9.96 2.54 10.96 2 12 2c1.66 0 3 1.34 3 3v4.17l2 2V6z"/></svg>
{:else}
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"/></svg>
{/if}
</button>
<button class="voice-btn disconnect" on:click={handleLeave} title="Verbindung trennen">
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28-.28 0-.53-.11-.71-.29L.29 13.08a.956.956 0 01-.29-.7c0-.28.11-.53.29-.71C3.09 8.27 7.33 6 12 6s8.91 2.27 11.71 5.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28a11.27 11.27 0 00-2.67-1.85.996.996 0 01-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z"/></svg>
</button>
</div>
</div>
{/if}
<style>
.voice-panel {
position: fixed;
bottom: 0;
left: 312px;
right: 240px;
height: 48px;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-top: 1px solid var(--border);
z-index: 100;
}
.voice-info {
display: flex;
align-items: center;
gap: 8px;
}
.voice-label {
font-size: 0.85rem;
color: var(--success);
font-weight: 600;
}
.voice-actions {
display: flex;
gap: 4px;
}
.voice-btn {
width: 36px;
height: 36px;
border: none;
background: var(--bg-input);
color: var(--text-primary);
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.voice-btn:hover {
background: var(--bg-hover);
}
.voice-btn.active {
background: var(--danger);
color: white;
}
.voice-btn.disconnect {
background: var(--danger);
color: white;
}
.voice-btn.disconnect:hover {
background: #c03033;
}
</style>

View File

@@ -0,0 +1,253 @@
export interface LoginResult {
success: boolean;
user_id: string;
error: string | null;
token?: string;
}
export interface RoomInfo {
room_id: string;
name: string;
avatar_url: string | null;
is_encrypted: boolean;
member_count: number;
topic: string | null;
}
export interface MessageInfo {
event_id: string;
sender: string;
body: string;
timestamp: number;
reply_to: string | null;
}
export interface RoleInfo {
id: string;
name: string;
color: string;
permissions: string[];
position: number;
}
export interface PermissionsInfo {
can_send_messages: boolean;
can_delete_messages: boolean;
can_manage_channels: boolean;
can_manage_roles: boolean;
can_kick: boolean;
can_ban: boolean;
can_manage_emoji: boolean;
can_manage_threads: boolean;
can_voice_connect: boolean;
can_voice_stream: boolean;
}
export interface VoiceStateInfo {
room_id: string;
muted: boolean;
deafened: boolean;
streaming: boolean;
}
export interface PresenceInfo {
user_id: string;
status: string;
status_msg: string | null;
last_active: number | null;
}
function isTauri(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
function getApiBase(): string {
return window.location.origin;
}
function getToken(): string | null {
return localStorage.getItem('eifeldc_token');
}
function setToken(token: string) {
localStorage.setItem('eifeldc_token', token);
}
function clearToken() {
localStorage.removeItem('eifeldc_token');
}
async function tauriInvoke(cmd: string, args: Record<string, unknown>): Promise<any> {
const { invoke } = await import('@tauri-apps/api/tauri');
return invoke(cmd, args);
}
async function httpGet(path: string): Promise<any> {
const res = await fetch(`${getApiBase()}${path}`, {
headers: { 'Authorization': `Bearer ${getToken()}` },
});
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
return res.json();
}
async function httpPost(path: string, body?: unknown): Promise<any> {
const res = await fetch(`${getApiBase()}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${getToken()}`,
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) { clearToken(); throw new Error('Unauthorized'); }
return res.json();
}
export async function login(homeserver: string, username: string, password: string): Promise<LoginResult> {
if (isTauri()) {
return tauriInvoke('login', { homeserver, username, password });
}
const result = await httpPost('/api/login', { homeserver, username, password });
if (result.token) { setToken(result.token); }
return result;
}
export async function logout(): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('logout', {});
}
clearToken();
return httpPost('/api/logout');
}
export async function register(homeserver: string, username: string, password: string): Promise<LoginResult> {
if (isTauri()) {
return tauriInvoke('register', { homeserver, username, password });
}
const result = await httpPost('/api/register', { homeserver, username, password });
if (result.token) { setToken(result.token); }
return result;
}
export async function getJoinedRooms(): Promise<RoomInfo[]> {
if (isTauri()) {
return tauriInvoke('get_joined_rooms', {});
}
return httpGet('/api/rooms');
}
export async function getRoomMessages(roomId: string, limit: number, from?: string): Promise<MessageInfo[]> {
if (isTauri()) {
return tauriInvoke('get_room_messages', { roomId, limit, from });
}
let url = `/api/rooms/${encodeURIComponent(roomId)}/messages?limit=${limit}`;
if (from) url += `&from=${encodeURIComponent(from)}`;
return httpGet(url);
}
export async function sendMessage(roomId: string, message: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('send_message', { roomId, message });
}
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/send`, { message });
}
export async function createRoom(name: string, topic?: string, visibility?: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('create_room', { name, topic, visibility: visibility || 'private' });
}
const result = await httpPost('/api/rooms/create', { name, topic, visibility: visibility || 'private' });
return result;
}
export async function joinRoom(roomIdOrAlias: string): Promise<string> {
if (isTauri()) {
return tauriInvoke('join_room', { roomIdOrAlias });
}
const result = await httpPost('/api/rooms/join', { room_id_or_alias: roomIdOrAlias });
return result;
}
export async function leaveRoom(roomId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('leave_room', { roomId });
}
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/leave`);
}
export async function getRoomMembers(roomId: string): Promise<string[]> {
if (isTauri()) {
return tauriInvoke('get_room_members', { roomId });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/members`);
}
export async function setPresence(status: string, statusMsg?: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('set_presence', { status, statusMsg });
}
return httpPost('/api/presence/set', { status, status_msg: statusMsg });
}
export async function getPresence(userId: string): Promise<PresenceInfo> {
if (isTauri()) {
return tauriInvoke('get_presence', { userId });
}
return httpGet(`/api/presence/${encodeURIComponent(userId)}`);
}
export async function joinVoiceChannel(roomId: string): Promise<VoiceStateInfo> {
if (isTauri()) {
return tauriInvoke('join_voice_channel', { roomId });
}
return httpPost('/api/voice/join', { room_id: roomId });
}
export async function leaveVoiceChannel(roomId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('leave_voice_channel', { roomId });
}
return httpPost('/api/voice/leave', { room_id: roomId });
}
export async function toggleMute(roomId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('toggle_mute', { roomId });
}
return httpPost('/api/voice/toggle-mute', { room_id: roomId });
}
export async function toggleDeafen(roomId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('toggle_deafen', { roomId });
}
return httpPost('/api/voice/toggle-deafen', { room_id: roomId });
}
export async function getRoles(roomId: string): Promise<RoleInfo[]> {
if (isTauri()) {
return tauriInvoke('get_roles', { roomId });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/roles`);
}
export async function assignRole(roomId: string, userId: string, roleId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('assign_role', { roomId, userId, roleId });
}
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/roles/assign`, { user_id: userId, role_id: roleId });
}
export async function removeRole(roomId: string, userId: string, roleId: string): Promise<boolean> {
if (isTauri()) {
return tauriInvoke('remove_role', { roomId, userId, roleId });
}
return httpPost(`/api/rooms/${encodeURIComponent(roomId)}/roles/remove`, { user_id: userId, role_id: roleId });
}
export async function getPermissions(roomId: string, userId: string): Promise<PermissionsInfo> {
if (isTauri()) {
return tauriInvoke('get_permissions', { roomId, userId });
}
return httpGet(`/api/rooms/${encodeURIComponent(roomId)}/permissions/${encodeURIComponent(userId)}`);
}

View File

@@ -0,0 +1,111 @@
import { writable, derived } from 'svelte/store';
import { invoke } from '@tauri-apps/api/tauri';
interface User {
id: string;
username: string;
avatarUrl: string | null;
status: 'online' | 'idle' | 'dnd' | 'offline';
}
interface Room {
id: string;
name: string;
avatarUrl: string | null;
unreadCount: number;
isVoice: boolean;
}
interface Channel {
id: string;
name: string;
type: 'text' | 'voice' | 'announcement';
topic: string | null;
parentId: string | null;
}
interface Message {
id: string;
sender: string;
content: string;
timestamp: number;
replyTo: string | null;
isBot: boolean;
reactions: Record<string, string[]>;
}
interface Server {
id: string;
name: string;
iconUrl: string | null;
roles: Role[];
}
interface Role {
id: string;
name: string;
color: string;
permissions: string[];
position: number;
}
interface Member {
id: string;
username: string;
avatarUrl: string | null;
status: 'online' | 'idle' | 'dnd' | 'offline';
roles: string[];
}
export const currentUser = writable<User | null>(null);
export const currentServer = writable<Server | null>(null);
export const currentChannel = writable<Channel | null>(null);
export const servers = writable<Server[]>([]);
export const channels = writable<Channel[]>([]);
export const messages = writable<Message[]>([]);
export const members = writable<Member[]>([]);
export const voiceState = writable<{
channelId: string | null;
muted: boolean;
deafened: boolean;
streaming: boolean;
}>({
channelId: null,
muted: false,
deafened: false,
streaming: false,
});
export const sortedMembers = derived(members, ($members) => {
return [...$members].sort((a, b) => {
const statusOrder = { online: 0, idle: 1, dnd: 2, offline: 3 };
return statusOrder[a.status] - statusOrder[b.status];
});
});
export const textChannels = derived(channels, ($channels) =>
$channels.filter((c) => c.type === 'text')
);
export const voiceChannels = derived(channels, ($channels) =>
$channels.filter((c) => c.type === 'voice')
);
export async function getJoinedRooms(): Promise<import('./api').RoomInfo[]> {
return invoke('get_joined_rooms');
}
export async function refreshChannels() {
try {
const rooms = await getJoinedRooms();
channels.set(rooms.map(r => ({
id: r.room_id,
name: r.name || r.room_id,
type: 'text' as const,
topic: null,
parentId: null,
})));
} catch (e) {
console.error('Failed to refresh channels', e);
}
}

View File

@@ -0,0 +1,6 @@
import App from './App.svelte';
import './styles/global.css';
const app = new App({ target: document.getElementById('app')! });
export default app;

View File

@@ -0,0 +1,81 @@
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-tertiary: #0f3460;
--bg-channel: #1e2a4a;
--bg-chat: #1a1a2e;
--bg-input: #253254;
--bg-hover: #2a3a5c;
--bg-active: #3a4f7a;
--text-primary: #e0e0e0;
--text-secondary: #8e8e8e;
--text-muted: #6a6a6a;
--accent: #5865f2;
--accent-hover: #4752c4;
--success: #57f287;
--warning: #fee75c;
--danger: #ed4245;
--online: #3ba55c;
--idle: #faa61a;
--dnd: #ed4245;
--offline: #747f8d;
--border: #2a3a5c;
--scrollbar: #2a3a5c;
--scrollbar-hover: #3a4f7a;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
--font-primary: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-primary);
background-color: var(--bg-primary);
color: var(--text-primary);
overflow: hidden;
height: 100vh;
width: 100vw;
}
#app {
height: 100vh;
width: 100vw;
display: flex;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border-radius: var(--radius-sm);
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}
input, textarea, button {
font-family: inherit;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}

View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"moduleResolution": "bundler",
"target": "ESNext",
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"strict": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,29 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { defineConfig } from 'vite';
import sveltePreprocess from 'svelte-preprocess';
const host = process.env.TAURI_DEV_HOST;
const isTauri = !!process.env.TAURI_DEV_HOST || !!process.env.TAURI_ENV_PLATFORM;
export default defineConfig({
plugins: [svelte({
preprocess: sveltePreprocess(),
})],
clearScreen: false,
server: {
port: 5173,
strictPort: true,
host: host || false,
hmr: host ? { protocol: 'ws', host, port: 5174 } : undefined,
watch: isTauri ? { ignored: ['**/src-tauri/**'] } : undefined,
proxy: isTauri ? undefined : {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
});