Initial commit
Some checks failed
Release / release (macos-latest) (push) Has been cancelled
Release / release (ubuntu-22.04) (push) Has been cancelled
Release / release (windows-latest) (push) Has been cancelled

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:
2026-04-15 23:11:48 -04:00
commit faaea6c729
95 changed files with 13165 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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; }