small adjustments made by zed editor formatting

This commit is contained in:
2026-04-19 20:49:02 -04:00
parent c7cb73b360
commit 0ccb77be40
9 changed files with 1929 additions and 1153 deletions

View File

@@ -1,19 +1,23 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Oxyde</title> <title>Oxyde</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" /> <link
<link rel="preconnect"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap" href="https://fonts.gstatic.com"
rel="stylesheet" crossorigin=""
/> />
%sveltekit.head% <link
</head> href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap"
<body data-sveltekit-preload-data="hover"> rel="stylesheet"
<div style="display: contents">%sveltekit.body%</div> />
</body> %sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html> </html>

View File

@@ -1,119 +1,205 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
authMode: 'signin' | 'signup'; authMode: "signin" | "signup";
err: string; err: string;
fEmail: string; fEmail: string;
fPass: string; fPass: string;
fUser: string; fUser: string;
onSignin: () => void; onSignin: () => void;
onSignup: () => void; onSignup: () => void;
onToggleMode: () => void; onToggleMode: () => void;
} }
let { let {
authMode, authMode,
err, err,
fEmail = $bindable(), fEmail = $bindable(),
fPass = $bindable(), fPass = $bindable(),
fUser = $bindable(), fUser = $bindable(),
onSignin, onSignin,
onSignup, onSignup,
onToggleMode, onToggleMode,
}: Props = $props(); }: Props = $props();
</script> </script>
<div class="auth-wrap"> <div class="auth-wrap">
<div class="auth-card"> <div class="auth-card">
<h1 class="auth-brand">OXYDE</h1> <h1 class="auth-brand">OXYDE</h1>
<p class="auth-tagline">realtime · native · focused</p> <p class="auth-tagline">realtime · native · focused</p>
{#if err} {#if err}
<div class="err-banner">{err}</div> <div class="err-banner">{err}</div>
{/if} {/if}
{#if authMode === 'signin'} {#if authMode === "signin"}
<div class="field-stack"> <div class="field-stack">
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" /> <input
<input class="field" type="password" placeholder="password" bind:value={fPass} class="field"
onkeydown={(e) => e.key === 'Enter' && onSignin()} autocomplete="current-password" /> type="email"
<button class="btn-primary" onclick={onSignin}>sign in</button> placeholder="email"
</div> bind:value={fEmail}
<button class="btn-ghost" onclick={onToggleMode}> autocomplete="email"
no account? create one → />
</button> <input
{:else} class="field"
<div class="field-stack"> type="password"
<input class="field" type="text" placeholder="username" bind:value={fUser} autocomplete="username" /> placeholder="password"
<input class="field" type="email" placeholder="email" bind:value={fEmail} autocomplete="email" /> bind:value={fPass}
<input class="field" type="password" placeholder="password" bind:value={fPass} onkeydown={(e) => e.key === "Enter" && onSignin()}
onkeydown={(e) => e.key === 'Enter' && onSignup()} autocomplete="new-password" /> autocomplete="current-password"
<button class="btn-primary" onclick={onSignup}>create account</button> />
</div> <button class="btn-primary" onclick={onSignin}>sign in</button>
<button class="btn-ghost" onclick={onToggleMode}> </div>
← back to sign in <button class="btn-ghost" onclick={onToggleMode}>
</button> no account? create one →
{/if} </button>
</div> {: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> </div>
<style> <style>
.auth-wrap { .auth-wrap {
display: flex; align-items: center; justify-content: center; display: flex;
height: 100vh; background: var(--bg); align-items: center;
animation: rise 0.28s ease; justify-content: center;
} height: 100vh;
@keyframes rise { background: var(--bg);
from { opacity: 0; transform: translateY(10px); } animation: rise 0.28s ease;
to { opacity: 1; transform: translateY(0); } }
} @keyframes rise {
.auth-card { from {
width: 360px; padding: 52px 44px; opacity: 0;
background: var(--sidebar-bg); transform: translateY(10px);
border: 1px solid var(--border); }
border-radius: var(--r); to {
} opacity: 1;
.auth-brand { transform: translateY(0);
font-family: 'Cormorant Garamond', Georgia, serif; }
font-size: 52px; font-weight: 700; }
color: var(--accent); letter-spacing: 0.22em; .auth-card {
text-align: center; width: 360px;
} padding: 52px 44px;
.auth-tagline { background: var(--sidebar-bg);
text-align: center; color: var(--muted); border: 1px solid var(--border);
font-size: 9.5px; letter-spacing: 0.15em; border-radius: var(--r);
margin-top: 8px; margin-bottom: 36px; }
} .auth-brand {
.err-banner { font-family: "Cormorant Garamond", Georgia, serif;
padding: 10px 14px; margin-bottom: 18px; font-size: 52px;
background: rgba(184, 48, 48, 0.10); font-weight: 700;
border: 1px solid rgba(184, 48, 48, 0.28); color: var(--accent);
border-radius: var(--r); letter-spacing: 0.22em;
color: #d98080; font-size: 11px; line-height: 1.5; text-align: center;
} }
.field-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; } .auth-tagline {
.field { text-align: center;
width: 100%; padding: 10px 14px; color: var(--muted);
background: var(--bg); font-size: 9.5px;
border: 1px solid var(--border); border-radius: var(--r); letter-spacing: 0.15em;
color: var(--text); font-family: inherit; font-size: 12px; margin-top: 8px;
outline: none; transition: border-color 0.12s; margin-bottom: 36px;
} }
.field:focus { border-color: var(--accent); } .err-banner {
.field::placeholder { color: var(--muted); } padding: 10px 14px;
.btn-primary { margin-bottom: 18px;
width: 100%; padding: 11px; background: rgba(184, 48, 48, 0.1);
background: var(--accent); border: none; border-radius: var(--r); border: 1px solid rgba(184, 48, 48, 0.28);
color: #fff; font-family: inherit; font-size: 12px; border-radius: var(--r);
font-weight: 500; letter-spacing: 0.07em; color: #d98080;
cursor: pointer; transition: opacity 0.12s, transform 0.08s; font-size: 11px;
} line-height: 1.5;
.btn-primary:hover { opacity: 0.85; } }
.btn-primary:active { transform: scale(0.98); } .field-stack {
.btn-ghost { display: flex;
display: block; width: 100%; text-align: center; flex-direction: column;
padding: 9px; background: none; border: none; gap: 10px;
color: var(--muted); font-family: inherit; font-size: 11px; margin-bottom: 14px;
cursor: pointer; transition: color 0.12s; }
} .field {
.btn-ghost:hover { color: var(--text-2); } 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> </style>

View File

@@ -1,379 +1,627 @@
<script lang="ts"> <script lang="ts">
import { tick } from 'svelte'; import { tick } from "svelte";
import type { User, Room, Message, ContextMenuItem } from '$lib/types'; import type { User, Room, Message, ContextMenuItem } from "$lib/types";
import { full, sid, fmt } from '$lib/helpers'; import { full, sid, fmt } from "$lib/helpers";
interface Props { interface Props {
activeRoom: Room | null; activeRoom: Room | null;
messages: Message[]; messages: Message[];
user: User | null; user: User | null;
err: string; err: string;
hasOlderMessages: boolean; hasOlderMessages: boolean;
isLoadingOlder: boolean; isLoadingOlder: boolean;
fMsg: string; fMsg: string;
replyTo: Message | null; replyTo: Message | null;
onLoadOlderMessages: () => void; onLoadOlderMessages: () => void;
onSendMessage: () => void; onSendMessage: () => void;
onDeleteMessage: (msgId: string) => void; onDeleteMessage: (msgId: string) => void;
onEditMessage: (msgId: string, body: string) => void; onEditMessage: (msgId: string, body: string) => void;
onToggleReaction: (msgId: string, emoji: string) => void; onToggleReaction: (msgId: string, emoji: string) => void;
onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void; onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void;
} }
let { let {
activeRoom, activeRoom,
messages, messages,
user, user,
err, err,
hasOlderMessages, hasOlderMessages,
isLoadingOlder, isLoadingOlder,
fMsg = $bindable(), fMsg = $bindable(),
replyTo = $bindable(), replyTo = $bindable(),
onLoadOlderMessages, onLoadOlderMessages,
onSendMessage, onSendMessage,
onDeleteMessage, onDeleteMessage,
onEditMessage, onEditMessage,
onToggleReaction, onToggleReaction,
onShowMenu, onShowMenu,
}: Props = $props(); }: Props = $props();
let msgEl: HTMLElement; let msgEl: HTMLElement;
let inputEl: HTMLTextAreaElement; let inputEl: HTMLTextAreaElement;
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let editBody = $state(''); let editBody = $state("");
function scrollBottom() { function scrollBottom() {
tick().then(() => { if (msgEl) msgEl.scrollTop = msgEl.scrollHeight; }); tick().then(() => {
} if (msgEl) msgEl.scrollTop = msgEl.scrollHeight;
});
}
function autoResize() { function autoResize() {
if (!inputEl) return; if (!inputEl) return;
inputEl.style.height = 'auto'; inputEl.style.height = "auto";
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px'; inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + "px";
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSendMessage(); } if (e.key === "Enter" && !e.shiftKey) {
} e.preventDefault();
onSendMessage();
}
}
function roomLabel(room: Room | null): string { function roomLabel(room: Room | null): string {
if (!room) return 'select a room'; if (!room) return "select a room";
if (room.kind === 'direct') return room.other_user?.username ?? room.name ?? 'direct message'; if (room.kind === "direct")
return room.name ?? 'untitled'; return room.other_user?.username ?? room.name ?? "direct message";
} return room.name ?? "untitled";
}
function isGrouped(i: number): boolean { function isGrouped(i: number): boolean {
if (i === 0) return false; if (i === 0) return false;
if (messages[i].deleted || messages[i - 1].deleted) return false; if (messages[i].deleted || messages[i - 1].deleted) return false;
return full(messages[i].author) === full(messages[i - 1].author); return full(messages[i].author) === full(messages[i - 1].author);
} }
function beginEdit(msg: Message) { function beginEdit(msg: Message) {
editingId = full(msg.id); editingId = full(msg.id);
editBody = msg.body; editBody = msg.body;
} }
function submitEdit(msg: Message) { function submitEdit(msg: Message) {
if (!editBody.trim()) return; if (!editBody.trim()) return;
onEditMessage(full(msg.id), editBody.trim()); onEditMessage(full(msg.id), editBody.trim());
editingId = null; editingId = null;
editBody = ''; editBody = "";
} }
function quickReact(msg: Message) { function quickReact(msg: Message) {
onToggleReaction(full(msg.id), '+1'); onToggleReaction(full(msg.id), "+1");
} }
// Scroll to bottom when messages change // Scroll to bottom when messages change
$effect(() => { $effect(() => {
messages.length; // track length messages.length; // track length
scrollBottom(); scrollBottom();
}); });
// Reset textarea height after message is cleared // Reset textarea height after message is cleared
$effect(() => { $effect(() => {
if (fMsg === '') autoResize(); if (fMsg === "") autoResize();
}); });
</script> </script>
<main class="main"> <main class="main">
<!-- Channel header -->
<header class="channel-header">
<span class="ch-hash">{activeRoom?.kind === "direct" ? "@" : "#"}</span>
<span class="ch-name">{roomLabel(activeRoom)}</span>
{#if err}<span class="header-err">{err}</span>{/if}
</header>
<!-- Channel header --> <!-- Message list -->
<header class="channel-header"> <div class="messages" bind:this={msgEl}>
<span class="ch-hash">{activeRoom?.kind === 'direct' ? '@' : '#'}</span> {#if !activeRoom}
<span class="ch-name">{roomLabel(activeRoom)}</span> <div class="empty-state">
{#if err}<span class="header-err">{err}</span>{/if} <span class="empty-icon">#</span>
</header> <p>select a room to start chatting</p>
<!-- 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}
{#if hasOlderMessages}
<button class="load-older" onclick={onLoadOlderMessages} disabled={isLoadingOlder}>
{isLoadingOlder ? 'loading...' : 'load older messages'}
</button>
{/if}
{#each messages as msg, i (full(msg.id))}
<div
class="msg"
class:grouped={isGrouped(i)}
role="listitem"
oncontextmenu={(e) => {
const items: ContextMenuItem[] = [
{ label: 'Copy message', action: () => navigator.clipboard.writeText(msg.body) },
{ label: 'Reply', action: () => replyTo = msg },
{ label: 'React +1', action: () => onToggleReaction(full(msg.id), '+1') },
];
if (user && full(msg.author) === full(user.id) && !msg.deleted) {
items.push({ label: 'Edit message', action: () => beginEdit(msg) });
items.push({ label: 'Delete message', action: () => onDeleteMessage(full(msg.id)) });
}
onShowMenu(e, items);
}}
>
{#if !isGrouped(i)}
<div class="msg-header">
<span
class="msg-author"
role="button"
tabindex="0"
oncontextmenu={(e) => { e.stopPropagation(); onShowMenu(e, [
{ label: 'Copy username', action: () => navigator.clipboard.writeText(msg.author_username ?? sid(msg.author)) },
{ label: 'Copy user ID', action: () => navigator.clipboard.writeText(sid(msg.author)) },
]); }}
>{msg.author_username ?? sid(msg.author)}</span>
<span class="msg-time">{fmt(msg.created)}</span>
{#if msg.updated}<span class="msg-time">edited</span>{/if}
</div> </div>
{/if} {:else if messages.length === 0}
{#if msg.reply_to} <div class="empty-state">
<div class="reply-chip">replying to {sid(msg.reply_to)}</div> <span class="empty-icon">#</span>
{/if} <p>no messages yet — say hello</p>
{#if !msg.deleted}
<div class="msg-actions" aria-label="message actions">
<button title="Reply" onclick={() => replyTo = msg}>reply</button>
<button title="React" onclick={() => quickReact(msg)}>+1</button>
{#if user && full(msg.author) === full(user.id)}
<button title="Edit" onclick={() => beginEdit(msg)}>edit</button>
{/if}
</div> </div>
{/if} {:else}
{#if msg.deleted} {#if hasOlderMessages}
<p class="msg-body deleted">message deleted</p>
{:else if editingId === full(msg.id)}
<div class="edit-row">
<textarea class="edit-input" bind:value={editBody} rows="2"></textarea>
<button class="mini-btn" onclick={() => submitEdit(msg)}>save</button>
<button class="mini-btn ghost" onclick={() => editingId = null}>cancel</button>
</div>
{:else}
<p class="msg-body">{msg.body}</p>
{/if}
{#if msg.reactions?.length}
<div class="reactions">
{#each msg.reactions as reaction}
<button <button
class="reaction" class="load-older"
class:mine={reaction.reacted_by_me} onclick={onLoadOlderMessages}
onclick={() => onToggleReaction(full(msg.id), reaction.emoji)} disabled={isLoadingOlder}
> >
{reaction.emoji} {reaction.count} {isLoadingOlder ? "loading..." : "load older messages"}
</button> </button>
{/each} {/if}
</div> {#each messages as msg, i (full(msg.id))}
{/if} <div
</div> class="msg"
{/each} class:grouped={isGrouped(i)}
{/if} role="listitem"
</div> oncontextmenu={(e) => {
const items: ContextMenuItem[] = [
<!-- Input bar --> {
{#if replyTo} label: "Copy message",
<div class="reply-bar"> action: () =>
<span>replying to {replyTo.author_username ?? sid(replyTo.author)}</span> navigator.clipboard.writeText(msg.body),
<button class="mini-btn ghost" onclick={() => replyTo = null}>cancel</button> },
{ label: "Reply", action: () => (replyTo = msg) },
{
label: "React +1",
action: () =>
onToggleReaction(full(msg.id), "+1"),
},
];
if (
user &&
full(msg.author) === full(user.id) &&
!msg.deleted
) {
items.push({
label: "Edit message",
action: () => beginEdit(msg),
});
items.push({
label: "Delete message",
action: () => onDeleteMessage(full(msg.id)),
});
}
onShowMenu(e, items);
}}
>
{#if !isGrouped(i)}
<div class="msg-header">
<span
class="msg-author"
role="button"
tabindex="0"
oncontextmenu={(e) => {
e.stopPropagation();
onShowMenu(e, [
{
label: "Copy username",
action: () =>
navigator.clipboard.writeText(
msg.author_username ??
sid(msg.author),
),
},
{
label: "Copy user ID",
action: () =>
navigator.clipboard.writeText(
sid(msg.author),
),
},
]);
}}
>{msg.author_username ?? sid(msg.author)}</span
>
<span class="msg-time">{fmt(msg.created)}</span>
{#if msg.updated}<span class="msg-time">edited</span
>{/if}
</div>
{/if}
{#if msg.reply_to}
<div class="reply-chip">
replying to {sid(msg.reply_to)}
</div>
{/if}
{#if !msg.deleted}
<div class="msg-actions" aria-label="message actions">
<button
title="Reply"
onclick={() => (replyTo = msg)}>reply</button
>
<button
title="React"
onclick={() => quickReact(msg)}>+1</button
>
{#if user && full(msg.author) === full(user.id)}
<button
title="Edit"
onclick={() => beginEdit(msg)}>edit</button
>
{/if}
</div>
{/if}
{#if msg.deleted}
<p class="msg-body deleted">message deleted</p>
{:else if editingId === full(msg.id)}
<div class="edit-row">
<textarea
class="edit-input"
bind:value={editBody}
rows="2"
></textarea>
<button
class="mini-btn"
onclick={() => submitEdit(msg)}>save</button
>
<button
class="mini-btn ghost"
onclick={() => (editingId = null)}
>cancel</button
>
</div>
{:else}
<p class="msg-body">{msg.body}</p>
{/if}
{#if msg.reactions?.length}
<div class="reactions">
{#each msg.reactions as reaction}
<button
class="reaction"
class:mine={reaction.reacted_by_me}
onclick={() =>
onToggleReaction(
full(msg.id),
reaction.emoji,
)}
>
{reaction.emoji}
{reaction.count}
</button>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</div> </div>
{/if}
<div class="input-bar">
<textarea
bind:this={inputEl}
class="msg-input"
placeholder={activeRoom ? `message ${activeRoom.kind === 'direct' ? '@' : '#'}${roomLabel(activeRoom)}` : '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>
<!-- Input bar -->
{#if replyTo}
<div class="reply-bar">
<span
>replying to {replyTo.author_username ??
sid(replyTo.author)}</span
>
<button class="mini-btn ghost" onclick={() => (replyTo = null)}
>cancel</button
>
</div>
{/if}
<div class="input-bar">
<textarea
bind:this={inputEl}
class="msg-input"
placeholder={activeRoom
? `message ${activeRoom.kind === "direct" ? "@" : "#"}${roomLabel(activeRoom)}`
: "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> </main>
<style> <style>
.main { .main {
flex: 1; display: flex; flex-direction: column; flex: 1;
overflow: hidden; background: var(--bg); display: flex;
} flex-direction: column;
.channel-header { overflow: hidden;
display: flex; align-items: center; gap: 9px; background: var(--bg);
padding: 0 24px; height: 50px; }
border-bottom: 1px solid var(--border); .channel-header {
flex-shrink: 0; display: flex;
} align-items: center;
.ch-hash { font-size: 17px; color: var(--muted); } gap: 9px;
.ch-name { font-size: 14px; font-weight: 500; color: var(--text); } padding: 0 24px;
.header-err { height: 50px;
margin-left: auto; font-size: 10px; color: #d98080; border-bottom: 1px solid var(--border);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0;
max-width: 280px; }
} .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 { .messages {
flex: 1; overflow-y: auto; flex: 1;
padding: 20px 24px 8px; overflow-y: auto;
display: flex; flex-direction: column; padding: 20px 24px 8px;
} display: flex;
.messages::-webkit-scrollbar { width: 4px; } flex-direction: column;
.messages::-webkit-scrollbar-thumb { background: var(--surface-2); border-radius: 2px; } }
.messages::-webkit-scrollbar-track { background: transparent; } .messages::-webkit-scrollbar {
.load-older { width: 4px;
align-self: center; margin-bottom: 12px; padding: 6px 10px; }
background: var(--surface); border: 1px solid var(--border); .messages::-webkit-scrollbar-thumb {
border-radius: var(--r); color: var(--text-2); background: var(--surface-2);
font-family: inherit; font-size: 11px; cursor: pointer; border-radius: 2px;
} }
.load-older:hover { border-color: var(--accent); color: var(--text); } .messages::-webkit-scrollbar-track {
.load-older:disabled { opacity: 0.5; cursor: wait; } background: transparent;
}
.load-older {
align-self: center;
margin-bottom: 12px;
padding: 6px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--text-2);
font-family: inherit;
font-size: 11px;
cursor: pointer;
}
.load-older:hover {
border-color: var(--accent);
color: var(--text);
}
.load-older:disabled {
opacity: 0.5;
cursor: wait;
}
.empty-state { .empty-state {
flex: 1; display: flex; flex-direction: column; flex: 1;
align-items: center; justify-content: center; display: flex;
gap: 12px; color: var(--muted); flex-direction: column;
} align-items: center;
.empty-icon { justify-content: center;
font-size: 32px; opacity: 0.2; gap: 12px;
font-family: 'Cormorant Garamond', Georgia, serif; color: var(--muted);
} }
.empty-state p { font-size: 11px; letter-spacing: 0.07em; } .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 {
.msg:hover .msg-actions, padding: 1px 0;
.msg:focus-within .msg-actions { opacity: 1; pointer-events: auto; } }
.msg.grouped { padding-top: 1px; } .msg:hover .msg-actions,
.msg:focus-within .msg-actions {
opacity: 1;
pointer-events: auto;
}
.msg.grouped {
padding-top: 1px;
}
.msg-header { .msg-header {
display: flex; align-items: baseline; gap: 9px; display: flex;
margin-top: 16px; margin-bottom: 3px; align-items: baseline;
} gap: 9px;
.msg-author { font-size: 12px; font-weight: 500; color: var(--accent); } margin-top: 16px;
.msg-time { font-size: 9.5px; color: var(--muted); } 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 { .msg-body {
color: var(--text); font-size: 13px; color: var(--text);
line-height: 1.6; white-space: pre-wrap; word-break: break-word; font-size: 13px;
animation: msgIn 0.14s ease; line-height: 1.6;
} white-space: pre-wrap;
.msg.grouped .msg-body { color: var(--text-2); } word-break: break-word;
.msg-body.deleted { color: var(--muted); font-style: italic; } animation: msgIn 0.14s ease;
.msg-actions { }
float: right; display: flex; gap: 4px; margin-left: 8px; .msg.grouped .msg-body {
opacity: 0; pointer-events: none; transition: opacity 0.1s; color: var(--text-2);
} }
.msg-actions button { .msg-body.deleted {
padding: 2px 5px; background: var(--surface); color: var(--muted);
border: 1px solid var(--border); border-radius: var(--r); font-style: italic;
color: var(--muted); font-family: inherit; font-size: 9.5px; }
cursor: pointer; .msg-actions {
} float: right;
.msg-actions button:hover { border-color: var(--accent); color: var(--accent); } display: flex;
.reply-chip { gap: 4px;
display: inline-flex; margin: 2px 0 3px; padding: 3px 6px; margin-left: 8px;
border-left: 2px solid var(--accent); background: var(--surface); opacity: 0;
color: var(--muted); font-size: 10px; pointer-events: none;
} transition: opacity 0.1s;
.edit-row { }
display: flex; align-items: flex-end; gap: 6px; .msg-actions button {
margin-top: 3px; padding: 2px 5px;
} background: var(--surface);
.edit-input { border: 1px solid var(--border);
flex: 1; resize: vertical; min-height: 44px; max-height: 120px; border-radius: var(--r);
padding: 7px 9px; background: var(--surface); color: var(--muted);
border: 1px solid var(--border); border-radius: var(--r); font-family: inherit;
color: var(--text); font-family: inherit; font-size: 12px; font-size: 9.5px;
} cursor: pointer;
.mini-btn { }
padding: 6px 8px; background: var(--accent); border: none; .msg-actions button:hover {
border-radius: var(--r); color: #fff; font-family: inherit; border-color: var(--accent);
font-size: 10px; cursor: pointer; color: var(--accent);
} }
.mini-btn.ghost { .reply-chip {
background: transparent; border: 1px solid var(--border); color: var(--muted); display: inline-flex;
} margin: 2px 0 3px;
.reactions { padding: 3px 6px;
display: flex; gap: 5px; margin-top: 4px; flex-wrap: wrap; border-left: 2px solid var(--accent);
} background: var(--surface);
.reaction { color: var(--muted);
padding: 2px 6px; background: var(--surface); font-size: 10px;
border: 1px solid var(--border); border-radius: var(--r); }
color: var(--text-2); font-family: inherit; font-size: 10px; .edit-row {
cursor: pointer; display: flex;
} align-items: flex-end;
.reaction.mine { border-color: var(--accent); color: var(--accent); } gap: 6px;
@keyframes msgIn { margin-top: 3px;
from { opacity: 0; transform: translateY(3px); } }
to { opacity: 1; transform: translateY(0); } .edit-input {
} flex: 1;
resize: vertical;
min-height: 44px;
max-height: 120px;
padding: 7px 9px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--text);
font-family: inherit;
font-size: 12px;
}
.mini-btn {
padding: 6px 8px;
background: var(--accent);
border: none;
border-radius: var(--r);
color: #fff;
font-family: inherit;
font-size: 10px;
cursor: pointer;
}
.mini-btn.ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
}
.reactions {
display: flex;
gap: 5px;
margin-top: 4px;
flex-wrap: wrap;
}
.reaction {
padding: 2px 6px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
color: var(--text-2);
font-family: inherit;
font-size: 10px;
cursor: pointer;
}
.reaction.mine {
border-color: var(--accent);
color: var(--accent);
}
@keyframes msgIn {
from {
opacity: 0;
transform: translateY(3px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.input-bar { .input-bar {
display: flex; align-items: flex-end; gap: 8px; display: flex;
padding: 12px 24px 16px; align-items: flex-end;
border-top: 1px solid var(--border); gap: 8px;
flex-shrink: 0; padding: 12px 24px 16px;
} border-top: 1px solid var(--border);
.reply-bar { flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between; }
padding: 8px 24px; border-top: 1px solid var(--border); .reply-bar {
color: var(--text-2); font-size: 11px; background: var(--surface); display: flex;
} align-items: center;
.msg-input { justify-content: space-between;
flex: 1; resize: none; padding: 8px 24px;
padding: 9px 13px; border-top: 1px solid var(--border);
background: var(--surface); color: var(--text-2);
border: 1px solid var(--border); border-radius: var(--r); font-size: 11px;
color: var(--text); font-family: inherit; font-size: 13px; background: var(--surface);
line-height: 1.55; outline: none; }
transition: border-color 0.12s; .msg-input {
max-height: 160px; overflow-y: auto; flex: 1;
} resize: none;
.msg-input:focus { border-color: var(--accent); } padding: 9px 13px;
.msg-input:disabled { opacity: 0.35; cursor: not-allowed; } background: var(--surface);
.msg-input::placeholder { color: var(--muted); } border: 1px solid var(--border);
.msg-input::-webkit-scrollbar { width: 0; } 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 { .send-btn {
width: 34px; height: 34px; flex-shrink: 0; width: 34px;
display: flex; align-items: center; justify-content: center; height: 34px;
background: var(--accent); border: none; border-radius: var(--r); flex-shrink: 0;
color: #fff; cursor: pointer; display: flex;
transition: opacity 0.12s, transform 0.08s; align-items: center;
} justify-content: center;
.send-btn:hover { opacity: 0.82; } background: var(--accent);
.send-btn:active { transform: scale(0.93); } border: none;
.send-btn:disabled { opacity: 0.25; cursor: not-allowed; transform: 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> </style>

View File

@@ -1,112 +1,133 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from "svelte";
import type { ContextMenuItem } from '$lib/types'; import type { ContextMenuItem } from "$lib/types";
interface Props { interface Props {
x: number; x: number;
y: number; y: number;
items: ContextMenuItem[]; items: ContextMenuItem[];
onclose: () => void; onclose: () => void;
} }
let { x, y, items, onclose }: Props = $props(); let { x, y, items, onclose }: Props = $props();
let menuEl: HTMLElement; let menuEl: HTMLElement;
let copiedIndex = $state<number | null>(null); let copiedIndex = $state<number | null>(null);
let closeTimer: ReturnType<typeof setTimeout> | null = null; let closeTimer: ReturnType<typeof setTimeout> | null = null;
// Flip position if menu would overflow viewport // Flip position if menu would overflow viewport
onMount(() => { onMount(() => {
if (!menuEl) return; if (!menuEl) return;
const rect = menuEl.getBoundingClientRect(); const rect = menuEl.getBoundingClientRect();
if (rect.right > window.innerWidth) menuEl.style.left = (x - rect.width) + 'px'; if (rect.right > window.innerWidth)
if (rect.bottom > window.innerHeight) menuEl.style.top = (y - rect.height) + 'px'; menuEl.style.left = x - rect.width + "px";
}); if (rect.bottom > window.innerHeight)
menuEl.style.top = y - rect.height + "px";
});
onDestroy(() => { onDestroy(() => {
if (closeTimer !== null) clearTimeout(closeTimer); if (closeTimer !== null) clearTimeout(closeTimer);
}); });
function handleItem(item: ContextMenuItem, index: number) { function handleItem(item: ContextMenuItem, index: number) {
item.action(); item.action();
copiedIndex = index; copiedIndex = index;
closeTimer = setTimeout(onclose, 1200); closeTimer = setTimeout(onclose, 1200);
} }
function onWindowClick() { onclose(); } function onWindowClick() {
function onWindowKey(e: KeyboardEvent) { if (e.key === 'Escape') onclose(); } onclose();
function onWindowContext(e: MouseEvent) { e.preventDefault(); onclose(); } }
function onWindowKey(e: KeyboardEvent) {
if (e.key === "Escape") onclose();
}
function onWindowContext(e: MouseEvent) {
e.preventDefault();
onclose();
}
</script> </script>
<svelte:window <svelte:window
onclick={onWindowClick} onclick={onWindowClick}
onkeydown={onWindowKey} onkeydown={onWindowKey}
oncontextmenu={onWindowContext} oncontextmenu={onWindowContext}
/> />
<ul <ul
class="ctx-menu" class="ctx-menu"
bind:this={menuEl} bind:this={menuEl}
style="left:{x}px; top:{y}px" style="left:{x}px; top:{y}px"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); }} oncontextmenu={(e) => {
role="menu" e.preventDefault();
e.stopPropagation();
}}
role="menu"
> >
{#each items as item, i} {#each items as item, i}
<li role="menuitem"> <li role="menuitem">
<button <button
class="ctx-item" class="ctx-item"
class:copied={copiedIndex === i} class:copied={copiedIndex === i}
onclick={() => handleItem(item, i)} onclick={() => handleItem(item, i)}
> >
{copiedIndex === i ? 'Copied!' : item.label} {copiedIndex === i ? "Copied!" : item.label}
</button> </button>
</li> </li>
{/each} {/each}
</ul> </ul>
<style> <style>
.ctx-menu { .ctx-menu {
margin: 0; margin: 0;
position: fixed; position: fixed;
list-style: none; list-style: none;
min-width: 160px; min-width: 160px;
padding: 4px; padding: 4px;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--r); border-radius: var(--r);
box-shadow: 0 4px 16px rgba(0,0,0,0.4); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 9999; z-index: 9999;
animation: rise 0.15s ease; animation: rise 0.15s ease;
} }
@keyframes rise { @keyframes rise {
from { opacity: 0; transform: translateY(4px); } from {
to { opacity: 1; transform: translateY(0); } opacity: 0;
} transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ctx-item { .ctx-item {
display: block; display: block;
width: 100%; width: 100%;
padding: 7px 12px; padding: 7px 12px;
background: none; background: none;
border: none; border: none;
border-left: 2px solid transparent; border-left: 2px solid transparent;
border-radius: var(--r); border-radius: var(--r);
color: var(--text-2); color: var(--text-2);
font-family: inherit; font-family: inherit;
font-size: 11px; font-size: 11px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: background 0.1s, color 0.1s, border-color 0.1s; transition:
} background 0.1s,
.ctx-item:hover { color 0.1s,
background: var(--surface-2); border-color 0.1s;
color: var(--text); }
border-left-color: var(--accent); .ctx-item:hover {
} background: var(--surface-2);
.ctx-item.copied { color: var(--text);
color: var(--accent); border-left-color: var(--accent);
background: var(--accent-soft); }
} .ctx-item.copied {
color: var(--accent);
background: var(--accent-soft);
}
</style> </style>

View File

@@ -1,29 +1,46 @@
<div class="loading"> <div class="loading">
<span class="brand-mark">OXYDE</span> <span class="brand-mark">OXYDE</span>
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
<style> <style>
.loading { .loading {
display: flex; flex-direction: column; display: flex;
align-items: center; justify-content: center; flex-direction: column;
height: 100vh; gap: 20px; align-items: center;
background: var(--bg); justify-content: center;
} height: 100vh;
.brand-mark { gap: 20px;
font-family: 'Cormorant Garamond', Georgia, serif; background: var(--bg);
font-size: 32px; font-weight: 700; }
color: var(--accent); .brand-mark {
letter-spacing: 0.22em; font-family: "Cormorant Garamond", Georgia, serif;
animation: pulse 1.6s ease-in-out infinite; font-size: 32px;
} font-weight: 700;
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } color: var(--accent);
.spinner { letter-spacing: 0.22em;
width: 20px; height: 20px; animation: pulse 1.6s ease-in-out infinite;
border: 1.5px solid var(--border); }
border-top-color: var(--accent); @keyframes pulse {
border-radius: 50%; 0%,
animation: spin 0.75s linear infinite; 100% {
} opacity: 1;
@keyframes spin { to { transform: rotate(360deg); } } }
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> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -2,33 +2,39 @@
// 3.x format: { table: "user", key: { String: "abc" } } // 3.x format: { table: "user", key: { String: "abc" } }
// 2.x format: { tb: "user", id: { String: "abc" } } (kept for compat) // 2.x format: { tb: "user", id: { String: "abc" } } (kept for compat)
export function sid(thing: any): string { export function sid(thing: any): string {
if (!thing) return ''; if (!thing) return "";
if (typeof thing === 'string') { if (typeof thing === "string") {
const i = thing.indexOf(':'); const i = thing.indexOf(":");
const id = i >= 0 ? thing.slice(i + 1) : thing; const id = i >= 0 ? thing.slice(i + 1) : thing;
return id.replace(/[⟨⟩]/g, ''); return id.replace(/[⟨⟩]/g, "");
} }
// 3.x: key field (may be nested variant or plain string) // 3.x: key field (may be nested variant or plain string)
const key = thing?.key ?? thing?.id; const key = thing?.key ?? thing?.id;
if (typeof key === 'string') return key.replace(/[⟨⟩]/g, ''); if (typeof key === "string") return key.replace(/[⟨⟩]/g, "");
if (key?.String) return key.String; if (key?.String) return key.String;
if (key?.Uuid) return key.Uuid; if (key?.Uuid) return key.Uuid;
if (key?.Number !== undefined) return String(key.Number); if (key?.Number !== undefined) return String(key.Number);
return JSON.stringify(thing); return JSON.stringify(thing);
} }
// Return canonical "table:id" string for equality checks // Return canonical "table:id" string for equality checks
export function full(thing: any): string { export function full(thing: any): string {
if (typeof thing === 'string') return thing; if (typeof thing === "string") return thing;
const table = thing?.table ?? thing?.tb ?? ''; const table = thing?.table ?? thing?.tb ?? "";
return `${table}:${sid(thing)}`; return `${table}:${sid(thing)}`;
} }
export function fmt(ts: string): string { export function fmt(ts: string): string {
return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); return new Date(ts).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
} }
export async function cmd<T>(name: string, args?: Record<string, unknown>): Promise<T> { export async function cmd<T>(
const { invoke } = await import('@tauri-apps/api/core'); name: string,
args?: Record<string, unknown>,
): Promise<T> {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<T>(name, args); return invoke<T>(name, args);
} }

View File

@@ -1,8 +1,65 @@
export interface User { id: any; username: string; email?: string; avatar?: string; created?: string; } export interface User {
export interface Room { id: any; name?: string; kind?: 'public' | 'private' | 'direct'; direct_key?: string; created: string; updated?: string; created_by?: any; last_message?: Message; unread_count?: number; other_user?: User; } id: any;
export interface RoomMember { id: any; room: any; user: any; role: 'owner' | 'member'; joined: string; last_read_at?: string; muted?: boolean; } username: string;
export interface MessageReactionSummary { emoji: string; count: number; reacted_by_me: boolean; } email?: string;
export interface Message { id: any; room: any; author: any; author_username?: string; body: string; created: string; updated?: string; deleted?: boolean; reply_to?: any; reactions?: MessageReactionSummary[]; } avatar?: string;
export interface UserSearchResult { id: any; username: string; avatar?: string; } created?: string;
export interface LiveEvent { action: 'Create' | 'Update' | 'Delete'; data: Message; } }
export interface ContextMenuItem { label: string; action: () => void; } export interface Room {
id: any;
name?: string;
kind?: "public" | "private" | "direct";
direct_key?: string;
created: string;
updated?: string;
created_by?: any;
last_message?: Message;
unread_count?: number;
other_user?: User;
}
export interface RoomMember {
id: any;
room: any;
user: any;
role: "owner" | "member";
joined: string;
last_read_at?: string;
muted?: boolean;
}
export interface MessageReactionSummary {
emoji: string;
count: number;
reacted_by_me: boolean;
}
export interface Message {
id: any;
room: any;
author: any;
author_username?: string;
body: string;
created: string;
updated?: string;
deleted?: boolean;
reply_to?: any;
replied_to_message?: MessageSnippet;
reactions?: MessageReactionSummary[];
}
export interface MessageSnippet {
id: any;
author_username?: string;
body: string;
}
export interface UserSearchResult {
id: any;
username: string;
avatar?: string;
}
export interface LiveEvent {
action: "Create" | "Update" | "Delete";
data: Message;
}
export interface ContextMenuItem {
label: string;
action: () => void;
}

View File

@@ -154,7 +154,9 @@
replyTo = null; replyTo = null;
if (previousSubId) { if (previousSubId) {
await cmd("unsubscribe_room", { subId: previousSubId }).catch(() => {}); await cmd("unsubscribe_room", { subId: previousSubId }).catch(
() => {},
);
} }
if (previousUnlisten) { if (previousUnlisten) {
previousUnlisten(); previousUnlisten();
@@ -186,41 +188,53 @@
} }
subId = nextSubId; subId = nextSubId;
const { listen } = await import("@tauri-apps/api/event"); const { listen } = await import("@tauri-apps/api/event");
const nextUnlisten = await listen<LiveEvent>("chat:message", ({ payload }) => { const nextUnlisten = await listen<LiveEvent>(
const { action, data } = payload; "chat:message",
const eventRoomId = sid(data.room); ({ payload }) => {
const currentRoomId = activeRoom ? sid(activeRoom.id) : ""; const { action, data } = payload;
if (eventRoomId !== currentRoomId) { const eventRoomId = sid(data.room);
unreadCounts = { const currentRoomId = activeRoom ? sid(activeRoom.id) : "";
...unreadCounts, if (eventRoomId !== currentRoomId) {
[eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1, unreadCounts = {
}; ...unreadCounts,
if ( [eventRoomId]: (unreadCounts[eventRoomId] ?? 0) + 1,
"Notification" in window && };
Notification.permission === "granted" && if (
document.hidden "Notification" in window &&
) { Notification.permission === "granted" &&
new Notification(data.author_username ?? "New message", { document.hidden
body: data.body || "New message", ) {
}); new Notification(
data.author_username ?? "New message",
{
body: data.body || "New message",
},
);
}
return;
} }
return; if (action === "Create") {
} messages = [...messages, data];
if (action === "Create") { } else if (action === "Delete") {
messages = [...messages, data]; messages = messages.filter(
} else if (action === "Delete") { (m) => full(m.id) !== full(data.id),
messages = messages.filter((m) => full(m.id) !== full(data.id)); );
} else if (action === "Update") { } else if (action === "Update") {
messages = messages.map((m) => messages = messages.map((m) =>
full(m.id) === full(data.id) ? data : m, full(m.id) === full(data.id) ? data : m,
);
}
cmd("mark_room_read", { roomId: currentRoomId }).catch(
() => {},
); );
} },
cmd("mark_room_read", { roomId: currentRoomId }).catch(() => {}); );
});
if (!isCurrentRoomSelection(token, roomId)) { if (!isCurrentRoomSelection(token, roomId)) {
nextUnlisten(); nextUnlisten();
if (subId === nextSubId) { if (subId === nextSubId) {
await cmd("unsubscribe_room", { subId: nextSubId }).catch(() => {}); await cmd("unsubscribe_room", { subId: nextSubId }).catch(
() => {},
);
subId = null; subId = null;
} }
return; return;