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.
41
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
platform: [macos-latest, ubuntu-22.04, windows-latest]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.platform }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: setup node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
- name: install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: install dependencies (ubuntu only)
|
||||||
|
if: matrix.platform == 'ubuntu-22.04'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
- name: build Oxyde
|
||||||
|
uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
SURREAL_URL: ${{ secrets.SURREAL_URL }}
|
||||||
|
SURREAL_NS: ${{ secrets.SURREAL_NS }}
|
||||||
|
SURREAL_DB: ${{ secrets.SURREAL_DB }}
|
||||||
|
SURREAL_ACCESS: ${{ secrets.SURREAL_ACCESS }}
|
||||||
|
with:
|
||||||
|
tagName: v__VERSION__
|
||||||
|
releaseName: "Oxyde v__VERSION__"
|
||||||
|
releaseBody: "See the assets below to download."
|
||||||
|
releaseDraft: true
|
||||||
|
prerelease: false
|
||||||
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules
|
||||||
|
/build
|
||||||
|
/.svelte-kit
|
||||||
|
/package
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
7
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"tauri-apps.tauri-vscode",
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
|
}
|
||||||
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"svelte.enable-ts-plugin": true,
|
||||||
|
"json.schemaDownload.trustedDomains": {
|
||||||
|
"https://schema.tauri.app": true
|
||||||
|
}
|
||||||
|
}
|
||||||
144
README.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Oxyde
|
||||||
|
|
||||||
|
Tauri 2 Desktop Application built with SvelteKit 5 + TypeScript + SurrealDB
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
This is a native desktop application featuring:
|
||||||
|
- ✅ Modern SvelteKit 5 frontend with TypeScript
|
||||||
|
- ✅ Tauri 2 runtime for native desktop performance
|
||||||
|
- ✅ Embedded SurrealDB database for local storage
|
||||||
|
- ✅ Authentication system
|
||||||
|
- ✅ Chat interface
|
||||||
|
- ✅ Native system capabilities through Tauri plugins
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
First install required dependencies:
|
||||||
|
|
||||||
|
| Tool | Required Version |
|
||||||
|
|------|------------------|
|
||||||
|
| Rust | 1.75+ |
|
||||||
|
| Node.js | 20+ |
|
||||||
|
| pnpm | 9+ |
|
||||||
|
| System Dependencies | See Tauri requirements for your OS |
|
||||||
|
|
||||||
|
#### System Specific Setup:
|
||||||
|
|
||||||
|
**Linux (Debian/Ubuntu):**
|
||||||
|
```bash
|
||||||
|
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
- Install Visual Studio Build Tools with "Desktop development with C++" workload
|
||||||
|
- Install WebView2 Runtime (included in Windows 11+)
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
xcode-select --install
|
||||||
|
brew install gtk+3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone and install dependencies:**
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd oxyde
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify Rust setup:**
|
||||||
|
```bash
|
||||||
|
rustc --version
|
||||||
|
cargo --version
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **First run will compile all Rust dependencies:**
|
||||||
|
```bash
|
||||||
|
# Full native development mode
|
||||||
|
./run-tauri-dev.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Available Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pnpm dev` | Run web-only dev server (browser, no Tauri) |
|
||||||
|
| `pnpm tauri dev` | Run full native Tauri application |
|
||||||
|
| `./run-tauri-dev.sh` | Run Tauri dev with Linux GPU fix |
|
||||||
|
| `pnpm build` | Build production web assets |
|
||||||
|
| `pnpm tauri build` | Create native installers/bundles |
|
||||||
|
| `pnpm check` | Run TypeScript + Svelte type checking |
|
||||||
|
| `pnpm check:watch` | Watch mode for type checking |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
oxyde/
|
||||||
|
├── src/ # SvelteKit Frontend
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── components/ # Reusable Svelte components
|
||||||
|
│ │ ├── helpers.ts # Utility functions
|
||||||
|
│ │ └── types.ts # TypeScript type definitions
|
||||||
|
│ ├── routes/ # Application routes
|
||||||
|
│ └── app.html
|
||||||
|
├── src-tauri/ # Rust Backend
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── commands/ # Tauri command handlers (chat, user)
|
||||||
|
│ │ ├── db.rs # SurrealDB integration
|
||||||
|
│ │ ├── error.rs # Error handling
|
||||||
|
│ │ ├── models.rs # Data models
|
||||||
|
│ │ ├── lib.rs
|
||||||
|
│ │ └── main.rs # App entry point
|
||||||
|
│ ├── Cargo.toml
|
||||||
|
│ └── tauri.conf.json
|
||||||
|
├── surreal/ # Database schemas
|
||||||
|
├── static/ # Static assets
|
||||||
|
└── docs/ # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) / VSCodium with extensions:
|
||||||
|
- [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode)
|
||||||
|
- [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode)
|
||||||
|
- [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||||
|
- [Even Better TOML](https://marketplace.visualstudio.com/items?itemName=tamasfe.even-better-toml)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
**Linux GPU Rendering Issues:**
|
||||||
|
Use the provided `./run-tauri-dev.sh` script which disables DMA-BUF renderer.
|
||||||
|
|
||||||
|
**Slow first build:**
|
||||||
|
First run will compile all Rust crates, this is normal. Subsequent builds will be incremental and much faster.
|
||||||
|
|
||||||
|
**Rust dependency issues:**
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo clean
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Frontend | SvelteKit 5, TypeScript, Vite |
|
||||||
|
| Runtime | Tauri 2 |
|
||||||
|
| Backend | Rust |
|
||||||
|
| Database | SurrealDB 3 |
|
||||||
|
| Package Manager | pnpm |
|
||||||
907
docs/superpowers/plans/2026-04-14-scaffold.md
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
# Oxyde Scaffold Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Scaffold the full Rust backend structure for the Oxyde Tauri app — SurrealDB state, error handling, models, and command stubs for user auth and chat with LIVE query support.
|
||||||
|
|
||||||
|
**Architecture:** Single `Arc<Surreal<Client>>` connection held in Tauri managed state (`AppState`). Commands are thin wrappers over internal async fns that return `Result<T, AppError>`, converted to `Result<T, String>` at the command boundary. LIVE query subscriptions are spawned as `tokio` tasks, tracked in `AppState.subscriptions`, and cancelled on demand.
|
||||||
|
|
||||||
|
**Tech Stack:** Rust, Tauri v2, SurrealDB 2.x (WebSocket), `thiserror`, `tokio`, `uuid`, SvelteKit (Svelte 5), pnpm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `src-tauri/Cargo.toml` | Modify | Add surrealdb, tokio, thiserror, uuid deps |
|
||||||
|
| `src-tauri/src/error.rs` | Create | `AppError` enum + `into_err` helper |
|
||||||
|
| `src-tauri/src/models.rs` | Create | `User`, `Room`, `Message`, `Contact` structs |
|
||||||
|
| `src-tauri/src/db.rs` | Create | `AppState` struct + `init_db()` |
|
||||||
|
| `src-tauri/src/commands/mod.rs` | Create | `pub mod user; pub mod chat;` |
|
||||||
|
| `src-tauri/src/commands/user.rs` | Create | Auth + profile + contacts command stubs |
|
||||||
|
| `src-tauri/src/commands/chat.rs` | Create | Chat + room + LIVE subscription command stubs |
|
||||||
|
| `src-tauri/src/lib.rs` | Modify | Wire `AppState`, register all commands, remove `greet` |
|
||||||
|
| `surreal/schema.surql` | Create | `DEFINE TABLE` + `DEFINE FIELD` for all tables |
|
||||||
|
| `surreal/auth.surql` | Create | `DEFINE ACCESS account` Record Auth |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add Cargo dependencies
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/Cargo.toml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add dependencies**
|
||||||
|
|
||||||
|
Replace the `[dependencies]` section in `src-tauri/Cargo.toml` with:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
surrealdb = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
thiserror = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors (warnings about unused imports are fine at this stage).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||||||
|
git commit -m "chore: add surrealdb, tokio, thiserror, uuid deps"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create `error.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/error.rs`
|
||||||
|
- Test: `src-tauri/src/error.rs` (inline `#[cfg(test)]` module)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test first**
|
||||||
|
|
||||||
|
Create `src-tauri/src/error.rs` with the test only:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_error_converts_to_string() {
|
||||||
|
let e = AppError::Auth("bad credentials".to_string());
|
||||||
|
let s: String = into_err(e);
|
||||||
|
assert_eq!(s, "Auth error: bad credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_found_error_converts_to_string() {
|
||||||
|
let e = AppError::NotFound("room".to_string());
|
||||||
|
let s: String = into_err(e);
|
||||||
|
assert_eq!(s, "Not found: room");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to confirm it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo test error
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `AppError` and `into_err` not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `error.rs`**
|
||||||
|
|
||||||
|
Add the full implementation above the test module:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Db(#[from] surrealdb::Error),
|
||||||
|
|
||||||
|
#[error("Auth error: {0}")]
|
||||||
|
Auth(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Subscription error: {0}")]
|
||||||
|
Subscription(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for String {
|
||||||
|
fn from(e: AppError) -> Self {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert any error that's Into<AppError> into the String Tauri commands require.
|
||||||
|
/// Usage: `.map_err(into_err)`
|
||||||
|
pub fn into_err<E: Into<AppError>>(e: E) -> String {
|
||||||
|
e.into().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_error_converts_to_string() {
|
||||||
|
let e = AppError::Auth("bad credentials".to_string());
|
||||||
|
let s: String = into_err(e);
|
||||||
|
assert_eq!(s, "Auth error: bad credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_found_error_converts_to_string() {
|
||||||
|
let e = AppError::NotFound("room".to_string());
|
||||||
|
let s: String = into_err(e);
|
||||||
|
assert_eq!(s, "Not found: room");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo test error
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 2 tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/error.rs
|
||||||
|
git commit -m "feat: add AppError with thiserror and into_err helper"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Create `models.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/models.rs`
|
||||||
|
- Test: `src-tauri/src/models.rs` (inline `#[cfg(test)]` module)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing test first**
|
||||||
|
|
||||||
|
Create `src-tauri/src/models.rs` with only the test:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_serializes_body() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": { "tb": "message", "id": { "String": "abc" } },
|
||||||
|
"room": { "tb": "room", "id": { "String": "r1" } },
|
||||||
|
"author": { "tb": "user", "id": { "String": "u1" } },
|
||||||
|
"body": "hello",
|
||||||
|
"created": "2026-01-01T00:00:00Z"
|
||||||
|
});
|
||||||
|
let msg: Result<Message, _> = serde_json::from_value(json);
|
||||||
|
// We only check it can round-trip; SurrealDB Thing format may vary.
|
||||||
|
// This test confirms the struct compiles and has expected fields.
|
||||||
|
assert!(msg.is_ok() || msg.is_err()); // structural compile check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to confirm it fails**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo test models
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL — `Message` not defined.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `models.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use surrealdb::sql::{Datetime, Thing};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Thing,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Room {
|
||||||
|
pub id: Thing,
|
||||||
|
pub name: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: Thing,
|
||||||
|
pub room: Thing,
|
||||||
|
pub author: Thing,
|
||||||
|
pub body: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub id: Thing,
|
||||||
|
pub owner: Thing,
|
||||||
|
pub target: Thing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_serializes_body() {
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": { "tb": "message", "id": { "String": "abc" } },
|
||||||
|
"room": { "tb": "room", "id": { "String": "r1" } },
|
||||||
|
"author": { "tb": "user", "id": { "String": "u1" } },
|
||||||
|
"body": "hello",
|
||||||
|
"created": "2026-01-01T00:00:00Z"
|
||||||
|
});
|
||||||
|
let msg: Result<Message, _> = serde_json::from_value(json);
|
||||||
|
assert!(msg.is_ok() || msg.is_err()); // structural compile check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to confirm it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo test models
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 1 test passes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/models.rs
|
||||||
|
git commit -m "feat: add User, Room, Message, Contact models"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Create `db.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/db.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `db.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use surrealdb::engine::remote::ws::{Client, Ws};
|
||||||
|
use surrealdb::Surreal;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
/// Long-lived authenticated WebSocket connection to SurrealDB.
|
||||||
|
pub db: Arc<Surreal<Client>>,
|
||||||
|
/// JWT token from Record Auth signin. Used to re-authenticate on reconnect.
|
||||||
|
pub token: Mutex<Option<String>>,
|
||||||
|
/// Active LIVE query tasks keyed by their SurrealDB LIVE query UUID.
|
||||||
|
/// Abort a handle + KILL the query to clean up.
|
||||||
|
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to SurrealDB over WebSocket and select namespace/database.
|
||||||
|
/// Call once at app startup before managing state.
|
||||||
|
pub async fn init_db(url: &str, ns: &str, db: &str) -> Result<Surreal<Client>, AppError> {
|
||||||
|
let client = Surreal::new::<Ws>(url).await?;
|
||||||
|
client.use_ns(ns).use_db(db).await?;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors. (lib.rs will have errors about missing modules until Task 8 — that is fine.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/db.rs
|
||||||
|
git commit -m "feat: add AppState and init_db for SurrealDB WS connection"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Create `commands/mod.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/commands/mod.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the commands directory and `mod.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub mod chat;
|
||||||
|
pub mod user;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: errors about `chat` and `user` modules not found — that is expected until Tasks 6 and 7.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/commands/mod.rs
|
||||||
|
git commit -m "chore: add commands module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Create `commands/user.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/commands/user.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `commands/user.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::AppState;
|
||||||
|
use crate::error::{into_err, AppError};
|
||||||
|
use crate::models::{Contact, User};
|
||||||
|
|
||||||
|
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
||||||
|
/// Returns the created User record.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signup(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let credentials = surrealdb::opt::auth::Record {
|
||||||
|
access: "account",
|
||||||
|
namespace: "oxyde",
|
||||||
|
database: "oxyde",
|
||||||
|
params: serde_json::json!({
|
||||||
|
"email": email,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let token: surrealdb::opt::auth::Jwt = state.db.signup(credentials).await.map_err(into_err)?;
|
||||||
|
*state.token.lock().unwrap() = Some(token.as_insecure_token().to_string());
|
||||||
|
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM user WHERE email = $email")
|
||||||
|
.bind(("email", email))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("user after signup".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
||||||
|
/// Returns the JWT token string.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signin(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let credentials = surrealdb::opt::auth::Record {
|
||||||
|
access: "account",
|
||||||
|
namespace: "oxyde",
|
||||||
|
database: "oxyde",
|
||||||
|
params: serde_json::json!({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let token: surrealdb::opt::auth::Jwt = state.db.signin(credentials).await.map_err(into_err)?;
|
||||||
|
let token_str = token.as_insecure_token().to_string();
|
||||||
|
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||||
|
Ok(token_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current session. Invalidates the token in state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signout(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
state.db.invalidate().await.map_err(into_err)?;
|
||||||
|
*state.token.lock().unwrap() = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the currently authenticated user record.
|
||||||
|
/// Relies on the DB connection being authenticated (token set via signin/signup).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM $auth")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update mutable profile fields. Only provided fields are changed.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_profile(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
username: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"UPDATE $auth SET
|
||||||
|
username = $username ?? username,
|
||||||
|
avatar = $avatar ?? avatar
|
||||||
|
RETURN AFTER",
|
||||||
|
)
|
||||||
|
.bind(("username", username))
|
||||||
|
.bind(("avatar", avatar))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the contacts list for the current user.
|
||||||
|
/// Contacts are `contact` records where `owner = $auth`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
||||||
|
let result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT target.* FROM contact WHERE owner = $auth FETCH target")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a user to the current user's contact list. Stub — returns the Contact record.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_contact(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Contact, String> {
|
||||||
|
let mut result: Vec<Contact> = state
|
||||||
|
.db
|
||||||
|
.query("CREATE contact SET owner = $auth, target = type::thing('user', $uid)")
|
||||||
|
.bind(("uid", user_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify it compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no new errors from `user.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/commands/user.rs
|
||||||
|
git commit -m "feat: add user commands (signup, signin, signout, get_me, update_profile, contacts)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Create `commands/chat.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src-tauri/src/commands/chat.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `commands/chat.rs`**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
use crate::db::AppState;
|
||||||
|
use crate::error::{into_err, AppError};
|
||||||
|
use crate::models::{Message, Room};
|
||||||
|
|
||||||
|
/// Create a new chat room.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
name: String,
|
||||||
|
) -> Result<Room, String> {
|
||||||
|
let mut result: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query("CREATE room SET name = $name, created = time::now()")
|
||||||
|
.bind(("name", name))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all rooms.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
||||||
|
let result: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM room ORDER BY created DESC")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a room.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_message(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
room_id: String,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Message, String> {
|
||||||
|
let mut result: Vec<Message> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"CREATE message SET
|
||||||
|
room = type::thing('room', $room_id),
|
||||||
|
author = $auth,
|
||||||
|
body = $body,
|
||||||
|
created = time::now()",
|
||||||
|
)
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.bind(("body", body))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all messages in a room, oldest first.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_messages(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<Vec<Message>, String> {
|
||||||
|
let result: Vec<Message> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM message WHERE room = type::thing('room', $room_id) ORDER BY created ASC")
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a message by its ID string (e.g. "message:abc123").
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_message(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
message_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query("DELETE type::thing($id)")
|
||||||
|
.bind(("id", message_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a LIVE query for new messages in a room.
|
||||||
|
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||||
|
///
|
||||||
|
/// Returns a local subscription UUID — pass it to `unsubscribe_room` on cleanup.
|
||||||
|
/// Aborting the JoinHandle drops the stream, which closes the LIVE query automatically.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn subscribe_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
|
||||||
|
let mut stream = db
|
||||||
|
.query("LIVE SELECT * FROM message WHERE room = type::thing('room', $room_id)")
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.stream::<surrealdb::Notification<Message>>(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
let sub_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(notification)) = stream.next().await {
|
||||||
|
let _ = app_handle.emit("chat:message", ¬ification.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.subscriptions.lock().unwrap().insert(sub_id, handle);
|
||||||
|
|
||||||
|
Ok(sub_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a LIVE query subscription.
|
||||||
|
/// Aborts the background task — dropping the stream closes the LIVE query.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unsubscribe_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
sub_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let uuid = sub_id
|
||||||
|
.parse::<Uuid>()
|
||||||
|
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
||||||
|
|
||||||
|
if let Some(handle) = state.subscriptions.lock().unwrap().remove(&uuid) {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `futures-util` dependency** (needed for `StreamExt` in subscribe_room)
|
||||||
|
|
||||||
|
Add to `src-tauri/Cargo.toml` `[dependencies]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
futures-util = "0.3"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify it compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no new errors from `chat.rs`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/commands/chat.rs src-tauri/Cargo.toml src-tauri/Cargo.lock
|
||||||
|
git commit -m "feat: add chat commands with LIVE query subscription support"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update `lib.rs`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src-tauri/src/lib.rs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `lib.rs` entirely**
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use db::{init_db, AppState};
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::block_on(async move {
|
||||||
|
let surreal = init_db("localhost:8000", "oxyde", "oxyde")
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to SurrealDB");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: Arc::new(surreal),
|
||||||
|
token: Mutex::new(None),
|
||||||
|
subscriptions: Mutex::new(HashMap::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
app_handle.manage(state);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::user::signup,
|
||||||
|
commands::user::signin,
|
||||||
|
commands::user::signout,
|
||||||
|
commands::user::get_me,
|
||||||
|
commands::user::update_profile,
|
||||||
|
commands::user::get_contacts,
|
||||||
|
commands::user::add_contact,
|
||||||
|
commands::chat::create_room,
|
||||||
|
commands::chat::get_rooms,
|
||||||
|
commands::chat::send_message,
|
||||||
|
commands::chat::get_messages,
|
||||||
|
commands::chat::delete_message,
|
||||||
|
commands::chat::subscribe_room,
|
||||||
|
commands::chat::unsubscribe_room,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify full project compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean — no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src-tauri/src/lib.rs
|
||||||
|
git commit -m "feat: wire AppState and all commands into Tauri builder"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Create SurrealQL schema files
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `surreal/schema.surql`
|
||||||
|
- Create: `surreal/auth.surql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `surreal/` directory and `schema.surql`**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- surreal/schema.surql
|
||||||
|
-- Run once against your SurrealDB instance to define all tables and fields.
|
||||||
|
|
||||||
|
DEFINE TABLE user SCHEMAFULL;
|
||||||
|
DEFINE FIELD username ON user TYPE string;
|
||||||
|
DEFINE FIELD email ON user TYPE string;
|
||||||
|
DEFINE FIELD password ON user TYPE string;
|
||||||
|
DEFINE FIELD avatar ON user TYPE option<string>;
|
||||||
|
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
|
||||||
|
|
||||||
|
DEFINE TABLE room SCHEMAFULL;
|
||||||
|
DEFINE FIELD name ON room TYPE string;
|
||||||
|
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
|
||||||
|
|
||||||
|
DEFINE TABLE message SCHEMAFULL;
|
||||||
|
DEFINE FIELD room ON message TYPE record<room>;
|
||||||
|
DEFINE FIELD author ON message TYPE record<user>;
|
||||||
|
DEFINE FIELD body ON message TYPE string;
|
||||||
|
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
|
||||||
|
|
||||||
|
DEFINE TABLE contact SCHEMAFULL;
|
||||||
|
DEFINE FIELD owner ON contact TYPE record<user>;
|
||||||
|
DEFINE FIELD target ON contact TYPE record<user>;
|
||||||
|
DEFINE INDEX unique_contact ON contact FIELDS owner, target UNIQUE;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `surreal/auth.surql`**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- surreal/auth.surql
|
||||||
|
-- Run after schema.surql.
|
||||||
|
-- Set SURREAL_JWT_SECRET env var before running SurrealDB,
|
||||||
|
-- then substitute the value here. Never hardcode in source control.
|
||||||
|
|
||||||
|
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
||||||
|
SIGNUP (
|
||||||
|
CREATE user SET
|
||||||
|
email = $email,
|
||||||
|
username = $username,
|
||||||
|
password = crypto::argon2::generate($password)
|
||||||
|
)
|
||||||
|
SIGNIN (
|
||||||
|
SELECT * FROM user
|
||||||
|
WHERE email = $email
|
||||||
|
AND crypto::argon2::compare(password, $password)
|
||||||
|
)
|
||||||
|
WITH JWT ALGORITHM HS512 KEY env::get("SURREAL_JWT_SECRET");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add surreal/
|
||||||
|
git commit -m "feat: add SurrealQL schema and Record Auth definitions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Final compile + run check
|
||||||
|
|
||||||
|
**Files:** none new
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run all Rust tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src-tauri && cargo test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass (error.rs and models.rs unit tests).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Check Tauri dev build starts** (requires SurrealDB running locally)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# In a separate terminal, start SurrealDB:
|
||||||
|
surreal start --user root --pass root file://oxyde.db
|
||||||
|
|
||||||
|
# Then:
|
||||||
|
pnpm tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: app window opens without a panic. SurrealDB connection errors will surface in the terminal if the server isn't running — that is expected in CI without a DB. The build itself must succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -p # review any remaining unstaged changes
|
||||||
|
git commit -m "chore: verify scaffold compiles and boots"
|
||||||
|
```
|
||||||
481
docs/superpowers/plans/2026-04-15-context-menu.md
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
# Context Menu Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a custom right-click context menu to the Oxyde chat app that replaces the browser default and offers context-aware copy actions on room names, message authors, and message bodies.
|
||||||
|
|
||||||
|
**Architecture:** A single shared `ContextMenu` Svelte component receives position + items as props and is rendered once in `+page.svelte`. State (`contextMenu`) lives in the page; a `showMenu` helper is passed down to `Sidebar` and `ChatMain` as a prop. Each trigger calls `showMenu` with the right items.
|
||||||
|
|
||||||
|
**Tech Stack:** Svelte 5 runes (`$state`, `$props`), `navigator.clipboard`, CSS custom properties already defined in `+page.svelte`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/types.ts` | Add `ContextMenuItem` interface |
|
||||||
|
| `src/lib/components/ContextMenu.svelte` | New component — positioning, dismiss, copy + confirmation |
|
||||||
|
| `src/routes/+page.svelte` | Add `contextMenu` state, `showMenu` helper, render `<ContextMenu>`, pass prop to children |
|
||||||
|
| `src/lib/components/Sidebar.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on `.room-item` buttons |
|
||||||
|
| `src/lib/components/ChatMain.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on `.msg` div and `.msg-author` span |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `ContextMenuItem` type
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/types.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the interface**
|
||||||
|
|
||||||
|
Open `src/lib/types.ts`. Append one line:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
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; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify TypeScript accepts it**
|
||||||
|
|
||||||
|
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
|
||||||
|
Expected: no errors (or only pre-existing errors unrelated to this file)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/types.ts
|
||||||
|
git commit -m "feat: add ContextMenuItem interface to types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Create `ContextMenu.svelte` component
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/lib/components/ContextMenu.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the component**
|
||||||
|
|
||||||
|
Create `src/lib/components/ContextMenu.svelte` with the following content:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } 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);
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleItem(item: ContextMenuItem, index: number) {
|
||||||
|
await navigator.clipboard.writeText(''); // reset
|
||||||
|
item.action();
|
||||||
|
copiedIndex = index;
|
||||||
|
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.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 {
|
||||||
|
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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify no TypeScript errors**
|
||||||
|
|
||||||
|
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
|
||||||
|
Expected: no new errors
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/ContextMenu.svelte
|
||||||
|
git commit -m "feat: add ContextMenu component with copy confirmation and viewport overflow guard"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wire context menu state into `+page.svelte`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/routes/+page.svelte`
|
||||||
|
|
||||||
|
The page needs:
|
||||||
|
1. `contextMenu` state (nullable position + items object)
|
||||||
|
2. `showMenu` helper called by children
|
||||||
|
3. `<ContextMenu>` rendered inside the `{:else}` (app) block
|
||||||
|
4. `onShowMenu` prop passed to `Sidebar` and `ChatMain`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add import and state**
|
||||||
|
|
||||||
|
In `src/routes/+page.svelte`, add `ContextMenu` to the imports and `ContextMenuItem` to the type import, then add the state variable. Edit the `<script>` block:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
||||||
|
import AuthCard from '$lib/components/AuthCard.svelte';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import ChatMain from '$lib/components/ChatMain.svelte';
|
||||||
|
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||||
|
import type { User, Room, Message, LiveEvent, ContextMenuItem } from '$lib/types';
|
||||||
|
import { sid, full, cmd } from '$lib/helpers';
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────
|
||||||
|
let user = $state<User | null>(null);
|
||||||
|
let rooms = $state<Room[]>([]);
|
||||||
|
let activeRoom = $state<Room | null>(null);
|
||||||
|
let messages = $state<Message[]>([]);
|
||||||
|
let contacts = $state<User[]>([]);
|
||||||
|
let subId = $state<string | null>(null);
|
||||||
|
let unlisten = $state<(() => void) | null>(null);
|
||||||
|
|
||||||
|
let view = $state<'loading' | 'auth' | 'app'>('loading');
|
||||||
|
let authMode = $state<'signin' | 'signup'>('signin');
|
||||||
|
let showNewRoom= $state(false);
|
||||||
|
let err = $state('');
|
||||||
|
|
||||||
|
let fEmail = $state(''); let fPass = $state('');
|
||||||
|
let fUser = $state(''); let fMsg = $state('');
|
||||||
|
let fRoom = $state('');
|
||||||
|
|
||||||
|
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
|
||||||
|
|
||||||
|
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu = { x: e.clientX, y: e.clientY, items };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep all existing functions — `init`, `signin`, `signup`, `signout`, `loadRooms`, `selectRoom`, `createRoom`, `sendMessage`, `onMount`, `onDestroy` — unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Pass `onShowMenu` to children and render `<ContextMenu>`**
|
||||||
|
|
||||||
|
Replace the `{:else}` block template (the `.app` div and its children) with:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{:else}
|
||||||
|
<div class="app">
|
||||||
|
<Sidebar
|
||||||
|
{user}
|
||||||
|
{rooms}
|
||||||
|
{contacts}
|
||||||
|
{activeRoom}
|
||||||
|
bind:showNewRoom
|
||||||
|
bind:fRoom
|
||||||
|
onSelectRoom={selectRoom}
|
||||||
|
onCreateRoom={createRoom}
|
||||||
|
onSignout={signout}
|
||||||
|
onShowMenu={showMenu}
|
||||||
|
/>
|
||||||
|
<ChatMain
|
||||||
|
{activeRoom}
|
||||||
|
{messages}
|
||||||
|
{err}
|
||||||
|
bind:fMsg
|
||||||
|
onSendMessage={sendMessage}
|
||||||
|
onShowMenu={showMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if contextMenu}
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={contextMenu.items}
|
||||||
|
onclose={() => contextMenu = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no TypeScript errors**
|
||||||
|
|
||||||
|
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
|
||||||
|
Expected: errors about `onShowMenu` being unknown on `Sidebar` and `ChatMain` — these will be fixed in tasks 4 and 5.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/routes/+page.svelte
|
||||||
|
git commit -m "feat: wire contextMenu state and showMenu helper in page, render ContextMenu"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Add `onShowMenu` to `Sidebar` and wire room item right-click
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/Sidebar.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add prop to interface and destructuring**
|
||||||
|
|
||||||
|
In `Sidebar.svelte`, replace the `<script>` section:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<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>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire `oncontextmenu` on `.room-item` buttons**
|
||||||
|
|
||||||
|
In the template, replace the `.room-item` button:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<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) }])}
|
||||||
|
>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no TypeScript errors**
|
||||||
|
|
||||||
|
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
|
||||||
|
Expected: no errors from Sidebar. The `ChatMain` error may remain until task 5.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/Sidebar.svelte
|
||||||
|
git commit -m "feat: add onShowMenu prop to Sidebar, wire room item right-click"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Add `onShowMenu` to `ChatMain` and wire message/author right-click
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/lib/components/ChatMain.svelte`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add prop to interface and destructuring**
|
||||||
|
|
||||||
|
In `ChatMain.svelte`, replace the `<script>` section top (props only):
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<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();
|
||||||
|
```
|
||||||
|
|
||||||
|
(Keep `msgEl`, `inputEl`, `scrollBottom`, `autoResize`, `onKey`, `isGrouped`, and both `$effect` calls unchanged.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wire `oncontextmenu` on `.msg` div and `.msg-author` span**
|
||||||
|
|
||||||
|
Replace the message loop in the template. The relevant section currently reads:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#each messages as msg, i (full(msg.id))}
|
||||||
|
<div class="msg" class:grouped={isGrouped(i)}>
|
||||||
|
{#if !isGrouped(i)}
|
||||||
|
<div class="msg-header">
|
||||||
|
<span class="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}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#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}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no TypeScript errors**
|
||||||
|
|
||||||
|
Run: `cd /home/qdust41/Oxyde && npx tsc --noEmit 2>&1 | head -20`
|
||||||
|
Expected: no errors
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/lib/components/ChatMain.svelte
|
||||||
|
git commit -m "feat: add onShowMenu prop to ChatMain, wire message and author right-click"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage check:**
|
||||||
|
|
||||||
|
| Spec requirement | Covered by |
|
||||||
|
|---|---|
|
||||||
|
| `ContextMenuItem` type in `types.ts` | Task 1 |
|
||||||
|
| `ContextMenu` component with props `x, y, items, onclose` | Task 2 |
|
||||||
|
| `position: fixed` at `(x, y)` from `clientX/Y` | Task 2 — `style="left:{x}px; top:{y}px"` |
|
||||||
|
| Viewport overflow flip on mount | Task 2 — `onMount` checks `rect.right > window.innerWidth` and `rect.bottom > window.innerHeight` |
|
||||||
|
| Global `onclick` closes menu | Task 2 — `svelte:window onclick={onWindowClick}` |
|
||||||
|
| Global `onkeydown Escape` closes | Task 2 — `onWindowKey` checks `e.key === 'Escape'` |
|
||||||
|
| Global `oncontextmenu` closes + prevents default | Task 2 — `onWindowContext` |
|
||||||
|
| Confirmation "Copied!" for 1200ms then close | Task 2 — `copiedIndex` state + `setTimeout(onclose, 1200)` |
|
||||||
|
| `var(--accent)` + `var(--accent-soft)` copied state | Task 2 — `.ctx-item.copied` CSS |
|
||||||
|
| `rise` keyframe entrance animation | Task 2 — reused from page (defined in component) |
|
||||||
|
| Visual style: surface bg, border, shadow, min-width, padding, font | Task 2 — all present in CSS |
|
||||||
|
| State in `+page.svelte`, `showMenu` helper | Task 3 |
|
||||||
|
| `ContextMenu` rendered in app block gated on `contextMenu !== null` | Task 3 |
|
||||||
|
| `onShowMenu` passed to `Sidebar` | Tasks 3 + 4 |
|
||||||
|
| `onShowMenu` passed to `ChatMain` | Tasks 3 + 5 |
|
||||||
|
| Sidebar `.room-item` right-click → "Copy room name" → `room.name` | Task 4 |
|
||||||
|
| ChatMain `.msg-author` right-click → "Copy username" → `author_username ?? sid(author)` | Task 5 |
|
||||||
|
| ChatMain `.msg` right-click → "Copy message" → `msg.body` | Task 5 |
|
||||||
|
| Author `stopPropagation` prevents `.msg` handler | Task 5 — `e.stopPropagation()` on author handler |
|
||||||
|
|
||||||
|
All spec requirements covered. No placeholders. Type names consistent across all tasks (`ContextMenuItem`, `onShowMenu`, `contextMenu`).
|
||||||
253
docs/superpowers/specs/2026-04-14-scaffold-design.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Oxyde — Tauri + SurrealDB Scaffold Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-14
|
||||||
|
**Stack:** Tauri v2, SvelteKit (Svelte 5), SurrealDB 2.x (remote WebSocket), pnpm
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture & File Layout
|
||||||
|
|
||||||
|
### Rust (`src-tauri/src/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
src-tauri/src/
|
||||||
|
├── main.rs # #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
│ # fn main() → oxyde_lib::run()
|
||||||
|
├── lib.rs # Tauri Builder: managed state, register all commands, plugin init
|
||||||
|
├── db.rs # AppState struct + init_db()
|
||||||
|
├── models.rs # User, Room, Message, Contact — serde + SurrealDB derives
|
||||||
|
├── error.rs # AppError enum → impl From<AppError> for String
|
||||||
|
└── commands/
|
||||||
|
├── mod.rs # pub mod user; pub mod chat;
|
||||||
|
├── user.rs # signup, signin, signout, get_me, update_profile,
|
||||||
|
│ # get_contacts, add_contact
|
||||||
|
└── chat.rs # send_message, get_messages, delete_message,
|
||||||
|
# get_rooms, create_room, subscribe_room, unsubscribe_room
|
||||||
|
```
|
||||||
|
|
||||||
|
### `db.rs` — AppState
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct AppState {
|
||||||
|
pub db: Arc<Surreal<Client>>,
|
||||||
|
pub token: Mutex<Option<String>>,
|
||||||
|
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `db`: single long-lived WebSocket connection to SurrealDB
|
||||||
|
- `token`: JWT returned by SurrealDB Record Auth on signin, cleared on signout
|
||||||
|
- `subscriptions`: tracks spawned LIVE query tasks by UUID for clean cancellation
|
||||||
|
|
||||||
|
`init_db()` connects to `ws://localhost:8000`, selects namespace and database.
|
||||||
|
|
||||||
|
### `lib.rs`
|
||||||
|
|
||||||
|
Wires `AppState` into `tauri::Builder::manage()`, registers all commands via `invoke_handler`, initialises plugins.
|
||||||
|
|
||||||
|
### SurrealDB files (`surreal/`)
|
||||||
|
|
||||||
|
```
|
||||||
|
surreal/
|
||||||
|
├── schema.surql # DEFINE TABLE + DEFINE FIELD for all tables
|
||||||
|
└── auth.surql # DEFINE ACCESS account (Record Auth, JWT HS512)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `Cargo.toml` additions
|
||||||
|
|
||||||
|
```toml
|
||||||
|
surrealdb = "2"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
thiserror = "1"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data Flow — LIVE Queries → Frontend Events
|
||||||
|
|
||||||
|
```
|
||||||
|
Frontend Tauri Command SurrealDB
|
||||||
|
│ │ │
|
||||||
|
│──invoke("subscribe_room")───▶│ │
|
||||||
|
│ │──LIVE SELECT * FROM─────▶│
|
||||||
|
│ │ message WHERE │
|
||||||
|
│ │ room = $room_id │
|
||||||
|
│ │ │
|
||||||
|
│ │ spawn tokio::task │
|
||||||
|
│ │ (holds LIVE stream) │
|
||||||
|
│ │◀────────────────────────│
|
||||||
|
│◀──Ok(live_query_id: String)──│ │
|
||||||
|
│ │ │
|
||||||
|
│ [new message inserted] │ │
|
||||||
|
│ │◀──LIVE notification─────│
|
||||||
|
│ │ │
|
||||||
|
│ │ app_handle.emit( │
|
||||||
|
│ │ "chat:message", │
|
||||||
|
│ │ MessagePayload) │
|
||||||
|
│◀──Tauri event───────────────│ │
|
||||||
|
│ listen("chat:message", cb) │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key details:**
|
||||||
|
- `subscribe_room` returns a `String` (LIVE query UUID) to the frontend
|
||||||
|
- Frontend stores UUID and calls `unsubscribe_room(uuid)` on component unmount
|
||||||
|
- Spawned task holds `AppHandle` clone — required to emit events from background
|
||||||
|
- `unsubscribe_room` aborts the `JoinHandle` and sends `KILL <uuid>` to SurrealDB
|
||||||
|
- One task per room; map lives in `AppState.subscriptions`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. SurrealQL Schema & Auth
|
||||||
|
|
||||||
|
### `surreal/schema.surql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DEFINE TABLE user SCHEMAFULL;
|
||||||
|
DEFINE FIELD username ON user TYPE string;
|
||||||
|
DEFINE FIELD email ON user TYPE string;
|
||||||
|
DEFINE FIELD password ON user TYPE string;
|
||||||
|
DEFINE FIELD avatar ON user TYPE option<string>;
|
||||||
|
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
|
||||||
|
|
||||||
|
DEFINE TABLE room SCHEMAFULL;
|
||||||
|
DEFINE FIELD name ON room TYPE string;
|
||||||
|
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
|
||||||
|
|
||||||
|
DEFINE TABLE message SCHEMAFULL;
|
||||||
|
DEFINE FIELD room ON message TYPE record<room>;
|
||||||
|
DEFINE FIELD author ON message TYPE record<user>;
|
||||||
|
DEFINE FIELD body ON message TYPE string;
|
||||||
|
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
|
||||||
|
|
||||||
|
DEFINE TABLE contact SCHEMAFULL;
|
||||||
|
DEFINE FIELD owner ON contact TYPE record<user>;
|
||||||
|
DEFINE FIELD target ON contact TYPE record<user>;
|
||||||
|
DEFINE INDEX unique_contact ON contact FIELDS owner, target UNIQUE;
|
||||||
|
```
|
||||||
|
|
||||||
|
### `surreal/auth.surql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
||||||
|
SIGNUP (
|
||||||
|
CREATE user SET
|
||||||
|
email = $email,
|
||||||
|
username = $username,
|
||||||
|
password = crypto::argon2::generate($password)
|
||||||
|
)
|
||||||
|
SIGNIN (
|
||||||
|
SELECT * FROM user
|
||||||
|
WHERE email = $email
|
||||||
|
AND crypto::argon2::compare(password, $password)
|
||||||
|
)
|
||||||
|
WITH JWT ALGORITHM HS512 KEY $jwt_secret;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `$jwt_secret` must be injected via env var or Tauri's secure store — never hardcoded.
|
||||||
|
|
||||||
|
**Schema decisions:**
|
||||||
|
- `password` is an explicit field (required in schemafull mode)
|
||||||
|
- `contact` uses a separate table with `owner`/`target` record links — supports bidirectional queries without array fields on `user`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Error Handling
|
||||||
|
|
||||||
|
### `error.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Db(#[from] surrealdb::Error),
|
||||||
|
|
||||||
|
#[error("Auth error: {0}")]
|
||||||
|
Auth(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Subscription error: {0}")]
|
||||||
|
Subscription(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for String {
|
||||||
|
fn from(e: AppError) -> Self {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_err<E: Into<AppError>>(e: E) -> String {
|
||||||
|
e.into().to_string()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
All Tauri commands return `Result<T, String>`. Use `.map_err(into_err)` at command boundaries. Internal functions use `Result<T, AppError>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Command Surface
|
||||||
|
|
||||||
|
### `commands/user.rs`
|
||||||
|
| Command | Args | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `signup` | email, username, password | `User` |
|
||||||
|
| `signin` | email, password | `String` (JWT) |
|
||||||
|
| `signout` | — | `()` |
|
||||||
|
| `get_me` | — | `User` |
|
||||||
|
| `update_profile` | username?, avatar? | `User` |
|
||||||
|
| `get_contacts` | — | `Vec<User>` |
|
||||||
|
| `add_contact` | user_id | `Contact` (stub) |
|
||||||
|
|
||||||
|
### `commands/chat.rs`
|
||||||
|
| Command | Args | Returns |
|
||||||
|
|---|---|---|
|
||||||
|
| `create_room` | name | `Room` |
|
||||||
|
| `get_rooms` | — | `Vec<Room>` |
|
||||||
|
| `send_message` | room_id, body | `Message` |
|
||||||
|
| `get_messages` | room_id | `Vec<Message>` |
|
||||||
|
| `delete_message` | message_id | `()` |
|
||||||
|
| `subscribe_room` | room_id | `String` (UUID) — `AppHandle` injected by Tauri |
|
||||||
|
| `unsubscribe_room` | uuid | `()` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Models (`models.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: Thing,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Room {
|
||||||
|
pub id: Thing,
|
||||||
|
pub name: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: Thing,
|
||||||
|
pub room: Thing,
|
||||||
|
pub author: Thing,
|
||||||
|
pub body: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub id: Thing,
|
||||||
|
pub owner: Thing,
|
||||||
|
pub target: Thing,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Thing` and `Datetime` from `surrealdb::sql`.
|
||||||
120
docs/superpowers/specs/2026-04-15-context-menu-design.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Context Menu — Design Spec
|
||||||
|
**Date:** 2026-04-15
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Custom right-click context menu for the Oxyde chat app. Replaces the browser default. Context-aware: menu items differ based on the element right-clicked. Copy-only for now, with a "Copied!" confirmation. Built with Approach A — shared component, state lifted to `+page.svelte`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. New Type
|
||||||
|
|
||||||
|
Add to `src/lib/types.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ContextMenuItem { label: string; action: () => void }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. New Component
|
||||||
|
|
||||||
|
**File:** `src/lib/components/ContextMenu.svelte`
|
||||||
|
|
||||||
|
### Props
|
||||||
|
```ts
|
||||||
|
{ x: number; y: number; items: ContextMenuItem[]; onclose: () => void }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Positioning
|
||||||
|
- `position: fixed` at `(x, y)` from `MouseEvent.clientX/Y`
|
||||||
|
- On mount: check if menu overflows viewport right or bottom edge; if so, flip left/upward
|
||||||
|
- Immune to scroll
|
||||||
|
|
||||||
|
### Dismiss
|
||||||
|
- Global `onclick` on `svelte:window` closes menu (menu container stops propagation)
|
||||||
|
- Global `onkeydown` closes on `Escape`
|
||||||
|
- Global `oncontextmenu` on `svelte:window` closes and prevents default (stops stale menu persisting on second right-click)
|
||||||
|
- Selecting an item closes after 1200ms (post-confirmation)
|
||||||
|
|
||||||
|
### Copy & Confirmation
|
||||||
|
- Copy via `navigator.clipboard.writeText()`
|
||||||
|
- On click: item label changes to `"Copied!"`, color shifts to `var(--accent)` with `var(--accent-soft)` background
|
||||||
|
- After 1200ms: menu closes
|
||||||
|
- Uses per-item `copiedIndex` state (index of last-copied item)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. State in `+page.svelte`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
|
||||||
|
|
||||||
|
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu = { x: e.clientX, y: e.clientY, items };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ContextMenu` renders at the bottom of the `{:else}` (app) block, gated on `contextMenu !== null`:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#if contextMenu}
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={contextMenu.items}
|
||||||
|
onclose={() => contextMenu = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
`showMenu` is passed as a prop to both `Sidebar` and `ChatMain`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Trigger Targets
|
||||||
|
|
||||||
|
| Component | Element | Right-click handler | Menu item | Copies |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `Sidebar` | `.room-item` button | `oncontextmenu` | "Copy room name" | `room.name` |
|
||||||
|
| `ChatMain` | `.msg-author` span | `oncontextmenu` + `stopPropagation` | "Copy username" | `msg.author_username ?? sid(msg.author)` |
|
||||||
|
| `ChatMain` | `.msg` div | `oncontextmenu` | "Copy message" | `msg.body` |
|
||||||
|
|
||||||
|
Author `stopPropagation` prevents the parent `.msg` handler from also firing.
|
||||||
|
|
||||||
|
### Prop additions
|
||||||
|
- `Sidebar`: `onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void`
|
||||||
|
- `ChatMain`: `onShowMenu: (e: MouseEvent, items: ContextMenuItem[]) => void`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Visual Style
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| Background | `var(--surface)` |
|
||||||
|
| Border | `1px solid var(--border)` |
|
||||||
|
| Border radius | `var(--r)` (2px) |
|
||||||
|
| Box shadow | `0 4px 16px rgba(0,0,0,0.4)` |
|
||||||
|
| Min width | 160px |
|
||||||
|
| List padding | 4px |
|
||||||
|
| Item padding | `7px 12px` |
|
||||||
|
| Font | `inherit` (Martian Mono), 11px |
|
||||||
|
| Item color | `var(--text-2)` |
|
||||||
|
| Item hover | bg `var(--surface-2)`, color `var(--text)`, left border `2px solid var(--accent)` |
|
||||||
|
| Copied state | color `var(--accent)`, bg `var(--accent-soft)` |
|
||||||
|
| Entrance animation | Reuse existing `rise` keyframe (opacity + translateY, 0.15s) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/lib/types.ts` | Add `ContextMenuItem` interface |
|
||||||
|
| `src/lib/components/ContextMenu.svelte` | New component |
|
||||||
|
| `src/routes/+page.svelte` | Add state, `showMenu` helper, render `ContextMenu`, pass prop to children |
|
||||||
|
| `src/lib/components/Sidebar.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on room items |
|
||||||
|
| `src/lib/components/ChatMain.svelte` | Add `onShowMenu` prop, wire `oncontextmenu` on `.msg` and `.msg-author` |
|
||||||
5
example.env
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
SURREAL_URL=http://localhost:8000
|
||||||
|
SURREAL_NS=namespace
|
||||||
|
SURREAL_DB=database
|
||||||
|
# This defines the access method for the users
|
||||||
|
SURREAL_ACCESS=account
|
||||||
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "oxyde",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-opener": "^2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
|
"@sveltejs/kit": "^2.9.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"vite": "^6.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
1205
pnpm-lock.yaml
generated
Normal file
2
run-tauri-dev.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/usr/bin/bash
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER=1 pnpm tauri dev
|
||||||
7
src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
8242
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "oxyde"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A Tauri App"
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
# The `_lib` suffix may seem redundant but it is necessary
|
||||||
|
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||||
|
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||||
|
name = "oxyde_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-opener = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
surrealdb = { version = "3.0.5" }
|
||||||
|
surrealdb-types = { version = "3.0.5" }
|
||||||
|
tokio = { version = "1.52.0", features = ["full"] }
|
||||||
|
thiserror = "2.0.18"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
|
||||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 170 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#fff</color>
|
||||||
|
</resources>
|
||||||
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 608 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
163
src-tauri/src/commands/chat.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use surrealdb::Notification;
|
||||||
|
|
||||||
|
use crate::db::AppState;
|
||||||
|
use crate::error::{into_err, AppError};
|
||||||
|
use crate::models::{Message, Room};
|
||||||
|
|
||||||
|
/// Wrapper emitted to the frontend for each LIVE query notification.
|
||||||
|
/// Includes the action type so the frontend can distinguish create/update/delete.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct LiveMessageEvent<'a> {
|
||||||
|
action: String,
|
||||||
|
data: &'a Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new chat room.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
name: String,
|
||||||
|
) -> Result<Room, String> {
|
||||||
|
let mut result: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query("CREATE room SET name = $name, created = time::now()")
|
||||||
|
.bind(("name", name))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("room after create".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all rooms.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_rooms(state: State<'_, AppState>) -> Result<Vec<Room>, String> {
|
||||||
|
let result: Vec<Room> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM room ORDER BY created DESC")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message to a room.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn send_message(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
room_id: String,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Message, String> {
|
||||||
|
let mut result: Vec<Message> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"CREATE message SET
|
||||||
|
room = type::record('room', $room_id),
|
||||||
|
author = $auth,
|
||||||
|
body = $body,
|
||||||
|
created = time::now()",
|
||||||
|
)
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.bind(("body", body))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("message after create".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all messages in a room, oldest first.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_messages(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<Vec<Message>, String> {
|
||||||
|
let result: Vec<Message> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT *, author.username AS author_username FROM message WHERE room = type::record('room', $room_id) ORDER BY created ASC")
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a message by its ID string (e.g. "message:abc123").
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_message(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
message_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.query("DELETE type::record($id) WHERE author = $auth")
|
||||||
|
.bind(("id", message_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a LIVE query for new messages in a room.
|
||||||
|
/// Spawns a background tokio task that emits "chat:message" Tauri events.
|
||||||
|
///
|
||||||
|
/// Returns a local subscription UUID — pass it to `unsubscribe_room` on cleanup.
|
||||||
|
/// Aborting the JoinHandle drops the stream, which closes the LIVE query automatically.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn subscribe_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
app_handle: AppHandle,
|
||||||
|
room_id: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let db = state.db.clone();
|
||||||
|
|
||||||
|
let mut stream = db
|
||||||
|
.query("LIVE SELECT *, author.username AS author_username FROM message WHERE room = type::record('room', $room_id)")
|
||||||
|
.bind(("room_id", room_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.stream::<Notification<Message>>(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
let sub_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
while let Some(Ok(notification)) = stream.next().await {
|
||||||
|
let _ = app_handle.emit("chat:message", &LiveMessageEvent {
|
||||||
|
action: format!("{:?}", notification.action),
|
||||||
|
data: ¬ification.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
state.subscriptions.lock().unwrap().insert(sub_id, handle);
|
||||||
|
|
||||||
|
Ok(sub_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop a LIVE query subscription.
|
||||||
|
/// Aborts the background task — dropping the stream closes the LIVE query.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unsubscribe_room(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
sub_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let uuid = sub_id
|
||||||
|
.parse::<Uuid>()
|
||||||
|
.map_err(|e| into_err(AppError::Subscription(e.to_string())))?;
|
||||||
|
|
||||||
|
if let Some(handle) = state.subscriptions.lock().unwrap().remove(&uuid) {
|
||||||
|
handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod chat;
|
||||||
|
pub mod user;
|
||||||
142
src-tauri/src/commands/user.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::db::{AppState, SURREAL_ACCESS, SURREAL_DB, SURREAL_NS};
|
||||||
|
use crate::error::{into_err, AppError};
|
||||||
|
use crate::models::{Contact, User};
|
||||||
|
|
||||||
|
/// Create a new user account via SurrealDB Record Auth SIGNUP.
|
||||||
|
/// Returns the created User record.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signup(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
email: String,
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let credentials = surrealdb::opt::auth::Record {
|
||||||
|
access: SURREAL_ACCESS.to_string(),
|
||||||
|
namespace: SURREAL_NS.to_string(),
|
||||||
|
database: SURREAL_DB.to_string(),
|
||||||
|
params: serde_json::json!({
|
||||||
|
"email": email,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
// into_insecure_token() returns the raw JWT String directly (3.x API).
|
||||||
|
let token = state.db.signup(credentials).await.map_err(into_err)?;
|
||||||
|
*state.token.lock().unwrap() = Some(token.access.into_insecure_token());
|
||||||
|
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM $auth")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::Auth("signup succeeded but $auth not set".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticate an existing user via SurrealDB Record Auth SIGNIN.
|
||||||
|
/// Returns the JWT token string.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signin(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
email: String,
|
||||||
|
password: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let credentials = surrealdb::opt::auth::Record {
|
||||||
|
access: SURREAL_ACCESS.to_string(),
|
||||||
|
namespace: SURREAL_NS.to_string(),
|
||||||
|
database: SURREAL_DB.to_string(),
|
||||||
|
params: serde_json::json!({
|
||||||
|
"email": email,
|
||||||
|
"password": password,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let token_str = state.db.signin(credentials).await.map_err(into_err)?.access.into_insecure_token();
|
||||||
|
*state.token.lock().unwrap() = Some(token_str.clone());
|
||||||
|
Ok(token_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the current session. Invalidates the token in state.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn signout(state: State<'_, AppState>) -> Result<(), String> {
|
||||||
|
state.db.invalidate().await.map_err(into_err)?;
|
||||||
|
*state.token.lock().unwrap() = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the currently authenticated user record.
|
||||||
|
/// Relies on the DB connection being authenticated (token set via signin/signup).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_me(state: State<'_, AppState>) -> Result<User, String> {
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT * FROM $auth")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::Auth("not authenticated".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update mutable profile fields. Only provided fields are changed.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_profile(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
username: Option<String>,
|
||||||
|
avatar: Option<String>,
|
||||||
|
) -> Result<User, String> {
|
||||||
|
let mut result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query(
|
||||||
|
"UPDATE $auth SET
|
||||||
|
username = $username ?? username,
|
||||||
|
avatar = $avatar ?? avatar
|
||||||
|
RETURN AFTER",
|
||||||
|
)
|
||||||
|
.bind(("username", username))
|
||||||
|
.bind(("avatar", avatar))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("user".into())))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the contacts list for the current user.
|
||||||
|
/// Contacts are `contact` records where `owner = $auth`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_contacts(state: State<'_, AppState>) -> Result<Vec<User>, String> {
|
||||||
|
let result: Vec<User> = state
|
||||||
|
.db
|
||||||
|
.query("SELECT target.* FROM contact WHERE owner = $auth")
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a user to the current user's contact list. Stub — returns the Contact record.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_contact(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
user_id: String,
|
||||||
|
) -> Result<Contact, String> {
|
||||||
|
let mut result: Vec<Contact> = state
|
||||||
|
.db
|
||||||
|
.query("CREATE contact SET owner = $auth, target = type::record('user', $uid)")
|
||||||
|
.bind(("uid", user_id))
|
||||||
|
.await
|
||||||
|
.map_err(into_err)?
|
||||||
|
.take(0)
|
||||||
|
.map_err(into_err)?;
|
||||||
|
|
||||||
|
result.pop().ok_or_else(|| into_err(AppError::NotFound("contact after create".into())))
|
||||||
|
}
|
||||||
44
src-tauri/src/db.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex, LazyLock};
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
use surrealdb::engine::remote::ws::{Client, Ws};
|
||||||
|
use surrealdb::Surreal;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub static SURREAL_URL: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
env::var("SURREAL_URL").unwrap_or_else(|_| "localhost:8000".to_string())
|
||||||
|
});
|
||||||
|
pub static SURREAL_NS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
env::var("SURREAL_NS").unwrap_or_else(|_| "dev".to_string())
|
||||||
|
});
|
||||||
|
pub static SURREAL_DB: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
env::var("SURREAL_DB").unwrap_or_else(|_| "oxyde".to_string())
|
||||||
|
});
|
||||||
|
pub static SURREAL_ACCESS: LazyLock<String> = LazyLock::new(|| {
|
||||||
|
env::var("SURREAL_ACCESS").unwrap_or_else(|_| "account".to_string())
|
||||||
|
});
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
/// Long-lived authenticated WebSocket connection to SurrealDB.
|
||||||
|
pub db: Arc<Surreal<Client>>,
|
||||||
|
/// JWT token from Record Auth signin. Used to re-authenticate on reconnect.
|
||||||
|
/// std::sync::Mutex is intentional: lock is acquired and released before any .await.
|
||||||
|
pub token: Mutex<Option<String>>,
|
||||||
|
/// Active LIVE query tasks keyed by their SurrealDB LIVE query UUID.
|
||||||
|
/// Abort a handle + KILL the query to clean up.
|
||||||
|
/// std::sync::Mutex is intentional: guards are never held across .await points.
|
||||||
|
/// If a future command needs to lock across .await, switch to tokio::sync::Mutex.
|
||||||
|
pub subscriptions: Mutex<HashMap<Uuid, JoinHandle<()>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connect to SurrealDB over WebSocket and select namespace/database.
|
||||||
|
/// Call once at app startup before managing state.
|
||||||
|
pub async fn init_db(url: &str, ns: &str, db: &str) -> Result<Surreal<Client>, AppError> {
|
||||||
|
let client = Surreal::new::<Ws>(url).await?;
|
||||||
|
client.use_ns(ns).use_db(db).await?;
|
||||||
|
Ok(client)
|
||||||
|
}
|
||||||
47
src-tauri/src/error.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Error, Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
#[error("Database error: {0}")]
|
||||||
|
Db(#[from] surrealdb::Error),
|
||||||
|
|
||||||
|
#[error("Auth error: {0}")]
|
||||||
|
Auth(String),
|
||||||
|
|
||||||
|
#[error("Not found: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("Subscription error: {0}")]
|
||||||
|
Subscription(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppError> for String {
|
||||||
|
fn from(e: AppError) -> Self {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert any error that's Into<AppError> into the String Tauri commands require.
|
||||||
|
/// Usage: `.map_err(into_err)`
|
||||||
|
pub fn into_err<E: Into<AppError>>(e: E) -> String {
|
||||||
|
e.into().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn app_error_converts_to_string() {
|
||||||
|
let e = AppError::Auth("bad credentials".to_string());
|
||||||
|
let s: String = into_err(e);
|
||||||
|
assert_eq!(s, "Auth error: bad credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_found_error_converts_to_string() {
|
||||||
|
let e = AppError::NotFound("room".to_string());
|
||||||
|
let s: String = into_err(e);
|
||||||
|
assert_eq!(s, "Not found: room");
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
mod db;
|
||||||
|
mod error;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
use db::{init_db, AppState, SURREAL_DB, SURREAL_NS, SURREAL_URL};
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_opener::init())
|
||||||
|
.setup(|app| {
|
||||||
|
let app_handle = app.handle().clone();
|
||||||
|
tauri::async_runtime::block_on(async move {
|
||||||
|
let surreal = init_db(SURREAL_URL.as_str(), SURREAL_NS.as_str(), SURREAL_DB.as_str())
|
||||||
|
.await
|
||||||
|
.expect("Failed to connect to SurrealDB");
|
||||||
|
|
||||||
|
let state = AppState {
|
||||||
|
db: Arc::new(surreal),
|
||||||
|
token: Mutex::new(None),
|
||||||
|
subscriptions: Mutex::new(HashMap::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
app_handle.manage(state);
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::user::signup,
|
||||||
|
commands::user::signin,
|
||||||
|
commands::user::signout,
|
||||||
|
commands::user::get_me,
|
||||||
|
commands::user::update_profile,
|
||||||
|
commands::user::get_contacts,
|
||||||
|
commands::user::add_contact,
|
||||||
|
commands::chat::create_room,
|
||||||
|
commands::chat::get_rooms,
|
||||||
|
commands::chat::send_message,
|
||||||
|
commands::chat::get_messages,
|
||||||
|
commands::chat::delete_message,
|
||||||
|
commands::chat::subscribe_room,
|
||||||
|
commands::chat::unsubscribe_room,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
oxyde_lib::run()
|
||||||
|
}
|
||||||
51
src-tauri/src/models.rs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use surrealdb::types::{Datetime, RecordId};
|
||||||
|
use surrealdb_types::SurrealValue;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
pub struct User {
|
||||||
|
pub id: RecordId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: String,
|
||||||
|
pub avatar: Option<String>,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
pub struct Room {
|
||||||
|
pub id: RecordId,
|
||||||
|
pub name: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: RecordId,
|
||||||
|
pub room: RecordId,
|
||||||
|
pub author: RecordId,
|
||||||
|
pub author_username: Option<String>,
|
||||||
|
pub body: String,
|
||||||
|
pub created: Datetime,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, SurrealValue)]
|
||||||
|
pub struct Contact {
|
||||||
|
pub id: RecordId,
|
||||||
|
pub owner: RecordId,
|
||||||
|
pub target: RecordId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn models_compile() {
|
||||||
|
// Structural compile check — verifies field types resolve correctly.
|
||||||
|
fn _assert_serialize<T: Serialize + for<'de> Deserialize<'de>>() {}
|
||||||
|
_assert_serialize::<User>();
|
||||||
|
_assert_serialize::<Room>();
|
||||||
|
_assert_serialize::<Message>();
|
||||||
|
_assert_serialize::<Contact>();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "oxyde",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.jimweaver.oxyde",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "pnpm build",
|
||||||
|
"frontendDist": "../build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Oxyde",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 720,
|
||||||
|
"resizable": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["deb", "nsis", "app"],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Oxyde</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600;700&family=Martian+Mono:wght@300;400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||||
5
src/routes/+layout.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// Tauri doesn't have a Node.js server to do proper SSR
|
||||||
|
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||||
|
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||||
|
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||||
|
export const ssr = false;
|
||||||
214
src/routes/+page.svelte
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import LoadingScreen from '$lib/components/LoadingScreen.svelte';
|
||||||
|
import AuthCard from '$lib/components/AuthCard.svelte';
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import ChatMain from '$lib/components/ChatMain.svelte';
|
||||||
|
import ContextMenu from '$lib/components/ContextMenu.svelte';
|
||||||
|
import type { User, Room, Message, LiveEvent, ContextMenuItem } from '$lib/types';
|
||||||
|
import { sid, full, cmd } from '$lib/helpers';
|
||||||
|
|
||||||
|
// ─── State ────────────────────────────────────────────────────────────────
|
||||||
|
let user = $state<User | null>(null);
|
||||||
|
let rooms = $state<Room[]>([]);
|
||||||
|
let activeRoom = $state<Room | null>(null);
|
||||||
|
let messages = $state<Message[]>([]);
|
||||||
|
let contacts = $state<User[]>([]);
|
||||||
|
let subId = $state<string | null>(null);
|
||||||
|
let unlisten = $state<(() => void) | null>(null);
|
||||||
|
|
||||||
|
let view = $state<'loading' | 'auth' | 'app'>('loading');
|
||||||
|
let authMode = $state<'signin' | 'signup'>('signin');
|
||||||
|
let showNewRoom= $state(false);
|
||||||
|
let err = $state('');
|
||||||
|
|
||||||
|
let fEmail = $state(''); let fPass = $state('');
|
||||||
|
let fUser = $state(''); let fMsg = $state('');
|
||||||
|
let fRoom = $state('');
|
||||||
|
|
||||||
|
let contextMenu = $state<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
|
||||||
|
|
||||||
|
function showMenu(e: MouseEvent, items: ContextMenuItem[]) {
|
||||||
|
e.preventDefault();
|
||||||
|
contextMenu = { x: e.clientX, y: e.clientY, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth ─────────────────────────────────────────────────────────────────
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
user = await cmd<User>('get_me');
|
||||||
|
view = 'app';
|
||||||
|
await loadRooms();
|
||||||
|
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
||||||
|
} catch {
|
||||||
|
view = 'auth';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signin() {
|
||||||
|
err = '';
|
||||||
|
try {
|
||||||
|
await cmd('signin', { email: fEmail, password: fPass });
|
||||||
|
user = await cmd<User>('get_me');
|
||||||
|
view = 'app';
|
||||||
|
await loadRooms();
|
||||||
|
contacts = await cmd<User[]>('get_contacts').catch(() => []);
|
||||||
|
} catch (e) { err = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signup() {
|
||||||
|
err = '';
|
||||||
|
try {
|
||||||
|
user = await cmd<User>('signup', { email: fEmail, username: fUser, password: fPass });
|
||||||
|
view = 'app';
|
||||||
|
await loadRooms();
|
||||||
|
} catch (e) { err = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signout() {
|
||||||
|
await cmd('signout').catch(() => {});
|
||||||
|
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; }
|
||||||
|
if (unlisten){ unlisten(); unlisten = null; }
|
||||||
|
user = null; rooms = []; messages = []; activeRoom = null;
|
||||||
|
view = 'auth';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Rooms ────────────────────────────────────────────────────────────────
|
||||||
|
async function loadRooms() {
|
||||||
|
rooms = await cmd<Room[]>('get_rooms');
|
||||||
|
if (rooms.length && !activeRoom) await selectRoom(rooms[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRoom(room: Room) {
|
||||||
|
if (subId) { await cmd('unsubscribe_room', { subId }).catch(() => {}); subId = null; }
|
||||||
|
if (unlisten){ unlisten(); unlisten = null; }
|
||||||
|
|
||||||
|
activeRoom = room;
|
||||||
|
messages = await cmd<Message[]>('get_messages', { roomId: sid(room.id) });
|
||||||
|
|
||||||
|
subId = await cmd<string>('subscribe_room', { roomId: sid(room.id) });
|
||||||
|
const { listen } = await import('@tauri-apps/api/event');
|
||||||
|
unlisten = await listen<LiveEvent>('chat:message', ({ payload }) => {
|
||||||
|
const { action, data } = payload;
|
||||||
|
if (action === 'Create') { messages = [...messages, data]; }
|
||||||
|
else if (action === 'Delete') { messages = messages.filter(m => full(m.id) !== full(data.id)); }
|
||||||
|
else if (action === 'Update') { messages = messages.map(m => full(m.id) === full(data.id) ? data : m); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRoom() {
|
||||||
|
if (!fRoom.trim()) return;
|
||||||
|
err = '';
|
||||||
|
try {
|
||||||
|
const r = await cmd<Room>('create_room', { name: fRoom.trim() });
|
||||||
|
rooms = [r, ...rooms];
|
||||||
|
fRoom = ''; showNewRoom = false;
|
||||||
|
await selectRoom(r);
|
||||||
|
} catch (e) { err = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Messages ─────────────────────────────────────────────────────────────
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!fMsg.trim() || !activeRoom) return;
|
||||||
|
err = '';
|
||||||
|
try {
|
||||||
|
await cmd('send_message', { roomId: sid(activeRoom.id), body: fMsg.trim() });
|
||||||
|
fMsg = '';
|
||||||
|
} catch (e) { err = String(e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(init);
|
||||||
|
onDestroy(async () => {
|
||||||
|
if (subId) await cmd('unsubscribe_room', { subId }).catch(() => {});
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view === 'loading'}
|
||||||
|
<LoadingScreen />
|
||||||
|
|
||||||
|
{:else if view === 'auth'}
|
||||||
|
<AuthCard
|
||||||
|
{authMode}
|
||||||
|
{err}
|
||||||
|
bind:fEmail
|
||||||
|
bind:fPass
|
||||||
|
bind:fUser
|
||||||
|
onSignin={signin}
|
||||||
|
onSignup={signup}
|
||||||
|
onToggleMode={() => { authMode = authMode === 'signin' ? 'signup' : 'signin'; err = ''; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<div class="app">
|
||||||
|
<Sidebar
|
||||||
|
{user}
|
||||||
|
{rooms}
|
||||||
|
{contacts}
|
||||||
|
{activeRoom}
|
||||||
|
bind:showNewRoom
|
||||||
|
bind:fRoom
|
||||||
|
onSelectRoom={selectRoom}
|
||||||
|
onCreateRoom={createRoom}
|
||||||
|
onSignout={signout}
|
||||||
|
onShowMenu={showMenu}
|
||||||
|
/>
|
||||||
|
<ChatMain
|
||||||
|
{activeRoom}
|
||||||
|
{messages}
|
||||||
|
{err}
|
||||||
|
bind:fMsg
|
||||||
|
onSendMessage={sendMessage}
|
||||||
|
onShowMenu={showMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if contextMenu}
|
||||||
|
<ContextMenu
|
||||||
|
x={contextMenu.x}
|
||||||
|
y={contextMenu.y}
|
||||||
|
items={contextMenu.items}
|
||||||
|
onclose={() => contextMenu = null}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ─── Reset & base ──────────────────────────────────────────────────────── */
|
||||||
|
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:global(html, body) {
|
||||||
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
|
background: #09090b;
|
||||||
|
font-family: 'Martian Mono', 'Courier New', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ddd8d0;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Design tokens ─────────────────────────────────────────────────────── */
|
||||||
|
:global(:root) {
|
||||||
|
--bg: #09090b;
|
||||||
|
--sidebar-bg: #0d0d10;
|
||||||
|
--surface: #111115;
|
||||||
|
--surface-2: #161619;
|
||||||
|
--border: #1c1c22;
|
||||||
|
--border-subtle: #161619;
|
||||||
|
--accent: #b5621a;
|
||||||
|
--accent-glow: rgba(181, 98, 26, 0.14);
|
||||||
|
--accent-soft: rgba(181, 98, 26, 0.08);
|
||||||
|
--text: #ddd8d0;
|
||||||
|
--text-2: #9994a0;
|
||||||
|
--muted: #46464f;
|
||||||
|
--online: #3cb870;
|
||||||
|
--danger: #b83030;
|
||||||
|
--r: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex; height: 100vh; width: 100%;
|
||||||
|
animation: rise 0.2s ease;
|
||||||
|
}
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
BIN
static/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
18
surreal/auth.surql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- surreal/auth.surql
|
||||||
|
-- Run after schema.surql.
|
||||||
|
-- SURREAL_JWT_SECRET must be set as an env var when starting the SurrealDB process.
|
||||||
|
-- The key is read at runtime via env::get() — nothing needs to be changed in this file.
|
||||||
|
|
||||||
|
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
||||||
|
SIGNUP (
|
||||||
|
CREATE user SET
|
||||||
|
email = $email,
|
||||||
|
username = $username,
|
||||||
|
password = crypto::argon2::generate($password)
|
||||||
|
)
|
||||||
|
SIGNIN (
|
||||||
|
SELECT * FROM user
|
||||||
|
WHERE email = $email
|
||||||
|
AND crypto::argon2::compare(password, $password)
|
||||||
|
)
|
||||||
|
WITH JWT ALGORITHM HS512 KEY env::get("SURREAL_JWT_SECRET");
|
||||||
56
surreal/schema.surql
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
OPTION IMPORT;
|
||||||
|
|
||||||
|
DEFINE ACCESS account ON DATABASE TYPE RECORD
|
||||||
|
SIGNUP (
|
||||||
|
CREATE user SET
|
||||||
|
email = $email,
|
||||||
|
username = $username,
|
||||||
|
password = crypto::argon2::generate($password),
|
||||||
|
created = time::now()
|
||||||
|
)
|
||||||
|
SIGNIN (
|
||||||
|
SELECT * FROM user
|
||||||
|
WHERE email = $email
|
||||||
|
AND crypto::argon2::compare(password, $password)
|
||||||
|
);
|
||||||
|
|
||||||
|
DEFINE TABLE user SCHEMAFULL
|
||||||
|
PERMISSIONS
|
||||||
|
FOR select WHERE id = $auth OR $auth IN (SELECT owner FROM contact WHERE target = id)
|
||||||
|
FOR update WHERE id = $auth
|
||||||
|
FOR create NONE
|
||||||
|
FOR delete NONE;
|
||||||
|
DEFINE FIELD username ON user TYPE string;
|
||||||
|
DEFINE FIELD email ON user TYPE string;
|
||||||
|
DEFINE FIELD password ON user TYPE string;
|
||||||
|
DEFINE FIELD avatar ON user TYPE option<string>;
|
||||||
|
DEFINE FIELD created ON user TYPE datetime DEFAULT time::now();
|
||||||
|
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
|
||||||
|
|
||||||
|
DEFINE TABLE room SCHEMAFULL
|
||||||
|
PERMISSIONS
|
||||||
|
FOR select, create FULL
|
||||||
|
FOR update, delete NONE;
|
||||||
|
DEFINE FIELD name ON room TYPE string;
|
||||||
|
DEFINE FIELD created ON room TYPE datetime DEFAULT time::now();
|
||||||
|
|
||||||
|
DEFINE TABLE message SCHEMAFULL
|
||||||
|
PERMISSIONS
|
||||||
|
FOR select FULL
|
||||||
|
FOR create WHERE author = $auth
|
||||||
|
FOR update WHERE author = $auth
|
||||||
|
FOR delete WHERE author = $auth;
|
||||||
|
DEFINE FIELD room ON message TYPE record<room>;
|
||||||
|
DEFINE FIELD author ON message TYPE record<user>;
|
||||||
|
DEFINE FIELD body ON message TYPE string;
|
||||||
|
DEFINE FIELD created ON message TYPE datetime DEFAULT time::now();
|
||||||
|
|
||||||
|
DEFINE TABLE contact SCHEMAFULL
|
||||||
|
PERMISSIONS
|
||||||
|
FOR select WHERE owner = $auth
|
||||||
|
FOR create WHERE owner = $auth
|
||||||
|
FOR delete WHERE owner = $auth
|
||||||
|
FOR update NONE;
|
||||||
|
DEFINE FIELD owner ON contact TYPE record<user>;
|
||||||
|
DEFINE FIELD target ON contact TYPE record<user>;
|
||||||
|
DEFINE INDEX unique_contact ON contact FIELDS owner, target UNIQUE;
|
||||||
18
svelte.config.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Tauri doesn't have a Node.js server to do proper SSR
|
||||||
|
// so we use adapter-static with a fallback to index.html to put the site in SPA mode
|
||||||
|
// See: https://svelte.dev/docs/kit/single-page-apps
|
||||||
|
// See: https://v2.tauri.app/start/frontend/sveltekit/ for more info
|
||||||
|
import adapter from "@sveltejs/adapter-static";
|
||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
fallback: "index.html",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
||||||
32
vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
|
||||||
|
// @ts-expect-error process is a nodejs global
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig(async () => ({
|
||||||
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
|
//
|
||||||
|
// 1. prevent Vite from obscuring rust errors
|
||||||
|
clearScreen: false,
|
||||||
|
// 2. tauri expects a fixed port, fail if that port is not available
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? {
|
||||||
|
protocol: "ws",
|
||||||
|
host,
|
||||||
|
port: 1421,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
watch: {
|
||||||
|
// 3. tell Vite to ignore watching `src-tauri`
|
||||||
|
ignored: ["**/src-tauri/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||