Initial commit
I asked claude to scaffold a project. I also made changes to it afterwards but they were mostly in getting workflows and testing stuff.
This commit is contained in:
119
src/lib/components/AuthCard.svelte
Normal file
119
src/lib/components/AuthCard.svelte
Normal file
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
authMode: 'signin' | 'signup';
|
||||
err: string;
|
||||
fEmail: string;
|
||||
fPass: string;
|
||||
fUser: string;
|
||||
onSignin: () => void;
|
||||
onSignup: () => void;
|
||||
onToggleMode: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
authMode,
|
||||
err,
|
||||
fEmail = $bindable(),
|
||||
fPass = $bindable(),
|
||||
fUser = $bindable(),
|
||||
onSignin,
|
||||
onSignup,
|
||||
onToggleMode,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="auth-wrap">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-brand">OXYDE</h1>
|
||||
<p class="auth-tagline">encrypted · realtime · distributed</p>
|
||||
|
||||
{#if err}
|
||||
<div class="err-banner">{err}</div>
|
||||
{/if}
|
||||
|
||||
{#if authMode === 'signin'}
|
||||
<div class="field-stack">
|
||||
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" />
|
||||
<input class="field" type="password" placeholder="password" bind:value={fPass}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSignin()} autocomplete="current-password" />
|
||||
<button class="btn-primary" onclick={onSignin}>sign in</button>
|
||||
</div>
|
||||
<button class="btn-ghost" onclick={onToggleMode}>
|
||||
no account? create one →
|
||||
</button>
|
||||
{:else}
|
||||
<div class="field-stack">
|
||||
<input class="field" type="text" placeholder="username" bind:value={fUser} autocomplete="username" />
|
||||
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" />
|
||||
<input class="field" type="password" placeholder="password" bind:value={fPass}
|
||||
onkeydown={(e) => e.key === 'Enter' && onSignup()} autocomplete="new-password" />
|
||||
<button class="btn-primary" onclick={onSignup}>create account</button>
|
||||
</div>
|
||||
<button class="btn-ghost" onclick={onToggleMode}>
|
||||
← back to sign in
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-wrap {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100vh; background: var(--bg);
|
||||
animation: rise 0.28s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.auth-card {
|
||||
width: 360px; padding: 52px 44px;
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
}
|
||||
.auth-brand {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: 52px; font-weight: 700;
|
||||
color: var(--accent); letter-spacing: 0.22em;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-tagline {
|
||||
text-align: center; color: var(--muted);
|
||||
font-size: 9.5px; letter-spacing: 0.15em;
|
||||
margin-top: 8px; margin-bottom: 36px;
|
||||
}
|
||||
.err-banner {
|
||||
padding: 10px 14px; margin-bottom: 18px;
|
||||
background: rgba(184, 48, 48, 0.10);
|
||||
border: 1px solid rgba(184, 48, 48, 0.28);
|
||||
border-radius: var(--r);
|
||||
color: #d98080; font-size: 11px; line-height: 1.5;
|
||||
}
|
||||
.field-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.field {
|
||||
width: 100%; padding: 10px 14px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text); font-family: inherit; font-size: 12px;
|
||||
outline: none; transition: border-color 0.12s;
|
||||
}
|
||||
.field:focus { border-color: var(--accent); }
|
||||
.field::placeholder { color: var(--muted); }
|
||||
.btn-primary {
|
||||
width: 100%; padding: 11px;
|
||||
background: var(--accent); border: none; border-radius: var(--r);
|
||||
color: #fff; font-family: inherit; font-size: 12px;
|
||||
font-weight: 500; letter-spacing: 0.07em;
|
||||
cursor: pointer; transition: opacity 0.12s, transform 0.08s;
|
||||
}
|
||||
.btn-primary:hover { opacity: 0.85; }
|
||||
.btn-primary:active { transform: scale(0.98); }
|
||||
.btn-ghost {
|
||||
display: block; width: 100%; text-align: center;
|
||||
padding: 9px; background: none; border: none;
|
||||
color: var(--muted); font-family: inherit; font-size: 11px;
|
||||
cursor: pointer; transition: color 0.12s;
|
||||
}
|
||||
.btn-ghost:hover { color: var(--text-2); }
|
||||
</style>
|
||||
214
src/lib/components/ChatMain.svelte
Normal file
214
src/lib/components/ChatMain.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { Room, Message, ContextMenuItem } from '$lib/types';
|
||||
import { full, sid, fmt } from '$lib/helpers';
|
||||
|
||||
interface Props {
|
||||
activeRoom: Room | null;
|
||||
messages: Message[];
|
||||
err: string;
|
||||
fMsg: string;
|
||||
onSendMessage: () => void;
|
||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
activeRoom,
|
||||
messages,
|
||||
err,
|
||||
fMsg = $bindable(),
|
||||
onSendMessage,
|
||||
onShowMenu,
|
||||
}: Props = $props();
|
||||
|
||||
let msgEl: HTMLElement;
|
||||
let inputEl: HTMLTextAreaElement;
|
||||
|
||||
function scrollBottom() {
|
||||
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; });
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
if (!inputEl) return;
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSendMessage(); }
|
||||
}
|
||||
|
||||
function isGrouped(i: number): boolean {
|
||||
if (i === 0) return false;
|
||||
return full(messages[i].author) === full(messages[i - 1].author);
|
||||
}
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
$effect(() => {
|
||||
messages.length; // track length
|
||||
scrollBottom();
|
||||
});
|
||||
|
||||
// Reset textarea height after message is cleared
|
||||
$effect(() => {
|
||||
if (fMsg === '') autoResize();
|
||||
});
|
||||
</script>
|
||||
|
||||
<main class="main">
|
||||
|
||||
<!-- Channel header -->
|
||||
<header class="channel-header">
|
||||
<span class="ch-hash">#</span>
|
||||
<span class="ch-name">{activeRoom?.name ?? 'select a room'}</span>
|
||||
{#if err}<span class="header-err">{err}</span>{/if}
|
||||
</header>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="messages" bind:this={msgEl}>
|
||||
{#if !activeRoom}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">#</span>
|
||||
<p>select a room to start chatting</p>
|
||||
</div>
|
||||
{:else if messages.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">#</span>
|
||||
<p>no messages yet — say hello</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each messages as msg, i (full(msg.id))}
|
||||
<div
|
||||
class="msg"
|
||||
class:grouped={isGrouped(i)}
|
||||
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) }])}
|
||||
>
|
||||
{#if !isGrouped(i)}
|
||||
<div class="msg-header">
|
||||
<span
|
||||
class="msg-author"
|
||||
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) }]); }}
|
||||
>{msg.author_username ?? sid(msg.author)}</span>
|
||||
<span class="msg-time">{fmt(msg.created)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="msg-body">{msg.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input bar -->
|
||||
<div class="input-bar">
|
||||
<textarea
|
||||
bind:this={inputEl}
|
||||
class="msg-input"
|
||||
placeholder={activeRoom ? `message #${activeRoom.name}` : 'select a room first'}
|
||||
bind:value={fMsg}
|
||||
onkeydown={onKey}
|
||||
oninput={autoResize}
|
||||
disabled={!activeRoom}
|
||||
rows="1"
|
||||
></textarea>
|
||||
<button title="" class="send-btn" onclick={onSendMessage} disabled={!activeRoom || !fMsg.trim()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<line x1="22" y1="2" x2="11" y2="13"/>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
overflow: hidden; background: var(--bg);
|
||||
}
|
||||
.channel-header {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 0 24px; height: 50px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ch-hash { font-size: 17px; color: var(--muted); }
|
||||
.ch-name { font-size: 14px; font-weight: 500; color: var(--text); }
|
||||
.header-err {
|
||||
margin-left: auto; font-size: 10px; color: #d98080;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 20px 24px 8px;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.messages::-webkit-scrollbar { width: 4px; }
|
||||
.messages::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 2px; }
|
||||
.messages::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.empty-state {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 12px; color: var(--muted);
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 32px; opacity: 0.2;
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
}
|
||||
.empty-state p { font-size: 11px; letter-spacing: 0.07em; }
|
||||
|
||||
.msg { padding: 1px 0; }
|
||||
.msg.grouped { padding-top: 1px; }
|
||||
|
||||
.msg-header {
|
||||
display: flex; align-items: baseline; gap: 9px;
|
||||
margin-top: 16px; margin-bottom: 3px;
|
||||
}
|
||||
.msg-author { font-size: 12px; font-weight: 500; color: var(--accent); }
|
||||
.msg-time { font-size: 9.5px; color: var(--muted); }
|
||||
|
||||
.msg-body {
|
||||
color: var(--text); font-size: 13px;
|
||||
line-height: 1.6; white-space: pre-wrap; word-break: break-word;
|
||||
animation: msgIn 0.14s ease;
|
||||
}
|
||||
.msg.grouped .msg-body { color: var(--text-2); }
|
||||
@keyframes msgIn {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex; align-items: flex-end; gap: 8px;
|
||||
padding: 12px 24px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.msg-input {
|
||||
flex: 1; resize: none;
|
||||
padding: 9px 13px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border); border-radius: var(--r);
|
||||
color: var(--text); font-family: inherit; font-size: 13px;
|
||||
line-height: 1.55; outline: none;
|
||||
transition: border-color 0.12s;
|
||||
max-height: 160px; overflow-y: auto;
|
||||
}
|
||||
.msg-input:focus { border-color: var(--accent); }
|
||||
.msg-input:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||
.msg-input::placeholder { color: var(--muted); }
|
||||
.msg-input::-webkit-scrollbar { width: 0; }
|
||||
|
||||
.send-btn {
|
||||
width: 34px; height: 34px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); border: none; border-radius: var(--r);
|
||||
color: #fff; cursor: pointer;
|
||||
transition: opacity 0.12s, transform 0.08s;
|
||||
}
|
||||
.send-btn:hover { opacity: 0.82; }
|
||||
.send-btn:active { transform: scale(0.93); }
|
||||
.send-btn:disabled { opacity: 0.25; cursor: not-allowed; transform: none; }
|
||||
</style>
|
||||
111
src/lib/components/ContextMenu.svelte
Normal file
111
src/lib/components/ContextMenu.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { ContextMenuItem } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { x, y, items, onclose }: Props = $props();
|
||||
|
||||
let menuEl: HTMLElement;
|
||||
let copiedIndex = $state<number | null>(null);
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Flip position if menu would overflow viewport
|
||||
onMount(() => {
|
||||
if (!menuEl) return;
|
||||
const rect = menuEl.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) menuEl.style.left = (x - rect.width) + 'px';
|
||||
if (rect.bottom > window.innerHeight) menuEl.style.top = (y - rect.height) + 'px';
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (closeTimer !== null) clearTimeout(closeTimer);
|
||||
});
|
||||
|
||||
function handleItem(item: ContextMenuItem, index: number) {
|
||||
item.action();
|
||||
copiedIndex = index;
|
||||
closeTimer = setTimeout(onclose, 1200);
|
||||
}
|
||||
|
||||
function onWindowClick() { onclose(); }
|
||||
function onWindowKey(e: KeyboardEvent) { if (e.key === 'Escape') onclose(); }
|
||||
function onWindowContext(e: MouseEvent) { e.preventDefault(); onclose(); }
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onclick={onWindowClick}
|
||||
onkeydown={onWindowKey}
|
||||
oncontextmenu={onWindowContext}
|
||||
/>
|
||||
|
||||
<ul
|
||||
class="ctx-menu"
|
||||
bind:this={menuEl}
|
||||
style="left:{x}px; top:{y}px"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
role="menu"
|
||||
>
|
||||
{#each items as item, i}
|
||||
<li role="menuitem">
|
||||
<button
|
||||
class="ctx-item"
|
||||
class:copied={copiedIndex === i}
|
||||
onclick={() => handleItem(item, i)}
|
||||
>
|
||||
{copiedIndex === i ? 'Copied!' : item.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.ctx-menu {
|
||||
margin: 0;
|
||||
position: fixed;
|
||||
list-style: none;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
z-index: 9999;
|
||||
animation: rise 0.15s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.ctx-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: var(--r);
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 11px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, color 0.1s, border-color 0.1s;
|
||||
}
|
||||
.ctx-item:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
.ctx-item.copied {
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
</style>
|
||||
29
src/lib/components/LoadingScreen.svelte
Normal file
29
src/lib/components/LoadingScreen.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<div class="loading">
|
||||
<span class="brand-mark">OXYDE</span>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.loading {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
height: 100vh; gap: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.brand-mark {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: 32px; font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.22em;
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
|
||||
.spinner {
|
||||
width: 20px; height: 20px;
|
||||
border: 1.5px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.75s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
216
src/lib/components/Sidebar.svelte
Normal file
216
src/lib/components/Sidebar.svelte
Normal file
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import type { User, Room, ContextMenuItem } from '$lib/types';
|
||||
import { full } from '$lib/helpers';
|
||||
|
||||
interface Props {
|
||||
user: User | null;
|
||||
rooms: Room[];
|
||||
contacts: User[];
|
||||
activeRoom: Room | null;
|
||||
showNewRoom: boolean;
|
||||
fRoom: string;
|
||||
onSelectRoom: (room: Room) => void;
|
||||
onCreateRoom: () => void;
|
||||
onSignout: () => void;
|
||||
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
user,
|
||||
rooms,
|
||||
contacts,
|
||||
activeRoom,
|
||||
showNewRoom = $bindable(),
|
||||
fRoom = $bindable(),
|
||||
onSelectRoom,
|
||||
onCreateRoom,
|
||||
onSignout,
|
||||
onShowMenu,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<aside class="sidebar">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="sidebar-head">
|
||||
<span class="sidebar-brand">OXYDE</span>
|
||||
<button class="icon-btn" title="New room"
|
||||
onclick={() => { showNewRoom = !showNewRoom; }}>
|
||||
{showNewRoom ? '×' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New room form -->
|
||||
{#if showNewRoom}
|
||||
<div class="new-room-form">
|
||||
<input class="field-sm" placeholder="room name" bind:value={fRoom}
|
||||
onkeydown={(e) => e.key === 'Enter' && onCreateRoom()} />
|
||||
<button class="btn-xs" onclick={onCreateRoom}>create</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rooms -->
|
||||
<div class="section-label">ROOMS</div>
|
||||
<nav class="room-list">
|
||||
{#each rooms as room (full(room.id))}
|
||||
<button
|
||||
class="room-item"
|
||||
class:active={activeRoom && full(room.id) === full(activeRoom.id)}
|
||||
onclick={() => onSelectRoom(room)}
|
||||
oncontextmenu={(e) => onShowMenu(e, [{ label: 'Copy room name', action: () => navigator.clipboard.writeText(room.name) }])}
|
||||
>
|
||||
<span class="hash">#</span>
|
||||
<span class="room-name">{room.name}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<p class="list-empty">no rooms — create one above</p>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Contacts -->
|
||||
{#if contacts.length > 0}
|
||||
<div class="section-label">CONTACTS</div>
|
||||
<div class="contact-list">
|
||||
{#each contacts as c}
|
||||
<div class="contact-item">
|
||||
<span class="presence online"></span>
|
||||
<span class="contact-name">{c.username}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- User footer -->
|
||||
<div class="user-footer">
|
||||
<div class="user-pill">
|
||||
<span class="avatar">{user?.username?.[0]?.toUpperCase() ?? '?'}</span>
|
||||
<span class="user-name">{user?.username ?? ''}</span>
|
||||
</div>
|
||||
<button class="icon-btn signout" title="Sign out" onclick={onSignout}>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 282px; min-width: 282px;
|
||||
background: var(--sidebar-bg);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 14px 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.sidebar-brand {
|
||||
font-family: 'Cormorant Garamond', Georgia, serif;
|
||||
font-size: 17px; font-weight: 700;
|
||||
color: var(--accent); letter-spacing: 0.2em;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 22px; height: 22px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: none; border: 1px solid var(--border);
|
||||
border-radius: var(--r); color: var(--muted);
|
||||
font-size: 15px; line-height: 1;
|
||||
cursor: pointer; transition: border-color 0.12s, color 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.icon-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.icon-btn.signout:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
.new-room-form {
|
||||
display: flex; gap: 6px; align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
animation: rise 0.15s ease;
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.field-sm {
|
||||
flex: 1; padding: 6px 10px;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--r); color: var(--text);
|
||||
font-family: inherit; font-size: 11px; outline: none;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
.field-sm:focus { border-color: var(--accent); }
|
||||
.field-sm::placeholder { color: var(--muted); }
|
||||
.btn-xs {
|
||||
padding: 6px 10px; flex-shrink: 0;
|
||||
background: var(--accent); border: none;
|
||||
border-radius: var(--r); color: #fff;
|
||||
font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.btn-xs:hover { opacity: 0.82; }
|
||||
|
||||
.section-label {
|
||||
padding: 14px 14px 5px;
|
||||
font-size: 9px; letter-spacing: 0.14em;
|
||||
color: var(--muted); font-weight: 500;
|
||||
}
|
||||
|
||||
.room-list { flex: 1; overflow-y: auto; padding: 3px 8px; }
|
||||
.room-list::-webkit-scrollbar { width: 0; }
|
||||
|
||||
.room-item {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
width: 100%; padding: 5px 7px; margin-bottom: 1px;
|
||||
background: none; border: none;
|
||||
border-left: 2px solid transparent;
|
||||
border-radius: 0 var(--r) var(--r) 0;
|
||||
color: var(--muted); font-family: inherit; font-size: 13px;
|
||||
cursor: pointer; text-align: left; transition: all 0.1s;
|
||||
}
|
||||
.room-item:hover { background: var(--surface); color: var(--text-2); }
|
||||
.room-item.active {
|
||||
background: var(--accent-soft);
|
||||
border-left-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.hash { color: var(--muted); font-size: 14px; flex-shrink: 0; }
|
||||
.room-item.active .hash { color: var(--accent); }
|
||||
.room-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.list-empty { padding: 8px 7px; color: var(--muted); font-size: 10.5px; }
|
||||
|
||||
.contact-list { padding: 3px 8px; }
|
||||
.contact-item {
|
||||
display: flex; align-items: center; gap: 7px;
|
||||
padding: 5px 7px; color: var(--muted); font-size: 12px;
|
||||
}
|
||||
.presence {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--muted); flex-shrink: 0;
|
||||
}
|
||||
.presence.online { background: var(--online); box-shadow: 0 0 5px var(--online); }
|
||||
.contact-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.user-footer {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface); margin-top: auto;
|
||||
}
|
||||
.user-pill { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.avatar {
|
||||
width: 26px; height: 26px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--accent); border-radius: var(--r);
|
||||
color: #fff; font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.user-name {
|
||||
font-size: 12px; color: var(--text-2);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
34
src/lib/helpers.ts
Normal file
34
src/lib/helpers.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Extract the ID part from a SurrealDB RecordId.
|
||||
// 3.x format: { table: "user", key: { String: "abc" } }
|
||||
// 2.x format: { tb: "user", id: { String: "abc" } } (kept for compat)
|
||||
export function sid(thing: any): string {
|
||||
if (!thing) return '';
|
||||
if (typeof thing === 'string') {
|
||||
const i = thing.indexOf(':');
|
||||
const id = i >= 0 ? thing.slice(i + 1) : thing;
|
||||
return id.replace(/[⟨⟩]/g, '');
|
||||
}
|
||||
// 3.x: key field (may be nested variant or plain string)
|
||||
const key = thing?.key ?? thing?.id;
|
||||
if (typeof key === 'string') return key.replace(/[⟨⟩]/g, '');
|
||||
if (key?.String) return key.String;
|
||||
if (key?.Uuid) return key.Uuid;
|
||||
if (key?.Number !== undefined) return String(key.Number);
|
||||
return JSON.stringify(thing);
|
||||
}
|
||||
|
||||
// Return canonical "table:id" string for equality checks
|
||||
export function full(thing: any): string {
|
||||
if (typeof thing === 'string') return thing;
|
||||
const table = thing?.table ?? thing?.tb ?? '';
|
||||
return `${table}:${sid(thing)}`;
|
||||
}
|
||||
|
||||
export function fmt(ts: string): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export async function cmd<T>(name: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
return invoke<T>(name, args);
|
||||
}
|
||||
5
src/lib/types.ts
Normal file
5
src/lib/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface User { id: any; username: string; email: string; avatar?: string; created: string; }
|
||||
export interface Room { id: any; name: string; created: string; }
|
||||
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; }
|
||||
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; }
|
||||
export interface ContextMenuItem { label: string; action: () => void; }
|
||||
Reference in New Issue
Block a user