This commit is contained in:
2025-09-07 03:48:48 +10:00
parent 2f3f853134
commit 8d03316b1b
63 changed files with 1941 additions and 420 deletions

20
backend/Cargo.lock generated
View File

@@ -331,6 +331,8 @@ dependencies = [
"env_logger",
"futures-util",
"log",
"serde",
"serde_json",
"tokio",
"tokio-tungstenite",
]
@@ -512,6 +514,12 @@ version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "serde"
version = "1.0.219"
@@ -532,6 +540,18 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "sha1"
version = "0.10.6"

View File

@@ -7,5 +7,7 @@ edition = "2024"
env_logger = "0.11.8"
futures-util = "0.3.31"
log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.143"
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros", "net", "time"] }
tokio-tungstenite = "0.27.0"

View File

@@ -1,5 +1,7 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::evaluator::*;
#[derive(Clone)]
@@ -49,23 +51,24 @@ impl Cell {
}
}
#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
pub struct CellRef {
pub row: i64,
pub col: i64,
pub row: usize,
pub col: usize,
}
impl CellRef {
// Zero indexed
pub fn new(s: String) -> Result<CellRef, String> {
let s = s.trim();
let mut col: i64 = 0;
let mut col: usize = 0;
let mut i = 0;
// consume leading letters for the column
for (idx, ch) in s.char_indices() {
if ch.is_ascii_alphabetic() {
let u = ch.to_ascii_uppercase() as u8;
let val = (u - b'A' + 1) as i64; // A->1 ... Z->26
let val = (u - b'A' + 1) as usize; // A->1 ... Z->26
col = col * 26 + val;
i = idx + ch.len_utf8();
} else {
@@ -90,8 +93,11 @@ impl CellRef {
));
}
if let Ok(row) = row_part.parse::<i64>() {
Ok(CellRef { row, col })
if let Ok(row) = row_part.parse::<usize>() {
Ok(CellRef {
row: row - 1,
col: col - 1,
})
} else {
Err(format!("Parse error: invalid row number."))
}

0
backend/src/common.rs Normal file
View File

View File

@@ -1,7 +1,8 @@
use crate::cell::{Cell, CellRef};
use crate::cell::CellRef;
use crate::grid::Grid;
use crate::parser::*;
use crate::tokenizer::Literal;
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, PartialEq, Clone)]
@@ -17,84 +18,28 @@ impl fmt::Display for Eval {
}
}
pub struct Evaluator {
cells: HashMap<CellRef, Cell>,
}
impl Evaluator {
pub fn new() -> Evaluator {
return Evaluator {
cells: HashMap::new(),
};
}
pub fn set_cell(&mut self, cell_ref: CellRef, raw_val: String) -> Result<Eval, String> {
if self.cells.contains_key(&cell_ref) && self.cells[&cell_ref].raw() == raw_val {
return self.get_cell(cell_ref);
}
let eval: Eval;
let deps: HashSet<CellRef>;
if let Some(c) = raw_val.chars().nth(0)
&& c == '='
{
(eval, deps) = self.evaluate(raw_val[1..].to_owned())?;
// for dep in deps {}
} else {
match self.evaluate(raw_val.to_owned()) {
Ok(e) => {
(eval, deps) = e;
}
Err(_) => eval = Eval::Literal(Literal::String(raw_val.to_owned())),
}
}
self.cells
.insert(cell_ref, Cell::new(eval.clone(), raw_val));
Ok(eval)
}
// pub fn get_cell(&mut self, cell_ref: CellRef) -> Result<(String, Eval), String> {
pub fn get_cell(&mut self, cell_ref: CellRef) -> Result<Eval, String> {
if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref));
}
let cell = &self.cells[&cell_ref];
// Ok((cell.raw(), cell.eval()))
Ok(cell.eval())
}
pub fn add_cell_dep(&mut self, cell_ref: CellRef, dep_ref: CellRef) -> Result<(), String> {
if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref));
}
if let Some(cell) = self.cells.get_mut(&cell_ref) {
cell.add_i_dep(dep_ref);
}
Ok(())
}
pub fn evaluate(&mut self, str: String) -> Result<(Eval, HashSet<CellRef>), String> {
pub fn evaluate(str: String, grid: Option<&Grid>) -> Result<(Eval, HashSet<CellRef>), String> {
let (expr, deps) = parse(&str)?;
match self.evaluate_expr(&expr) {
match evaluate_expr(&expr, grid) {
Ok(it) => Ok((it, deps)),
Err(it) => Err(it),
}
}
}
fn evaluate_expr(&mut self, expr: &Expr) -> Result<Eval, String> {
fn evaluate_expr(expr: &Expr, grid: Option<&Grid>) -> Result<Eval, String> {
let res = match expr {
Expr::Literal(lit) => Eval::Literal(lit.clone()),
Expr::CellRef(re) => self.get_cell(re.to_owned())?,
Expr::CellRef(re) => {
if let Some(g) = grid {
g.get_cell(re.to_owned())?
} else {
return Err("Evaluation error: Found cell reference but no grid.".into());
}
}
Expr::Infix { op, lhs, rhs } => {
let lval = self.evaluate_expr(lhs)?;
let rval = self.evaluate_expr(rhs)?;
let lval = evaluate_expr(lhs, grid)?;
let rval = evaluate_expr(rhs, grid)?;
match op {
InfixOp::ADD => eval_add(&lval, &rval)?,
@@ -105,27 +50,26 @@ impl Evaluator {
}
}
Expr::Prefix { op, expr } => {
let val = self.evaluate_expr(expr)?;
let val = evaluate_expr(expr, grid)?;
match op {
PrefixOp::POS => eval_pos(&val)?,
PrefixOp::NEG => eval_neg(&val)?,
PrefixOp::NOT => eval_not(&val)?,
_ => return Err(format!("Evaluation error: Unsupported operator {:?}", op)),
// _ => return Err(format!("Evaluation error: Unsupported operator {:?}", op)),
}
}
Expr::Group(g) => self.evaluate_expr(g)?,
Expr::Group(g) => evaluate_expr(g, grid)?,
it => return Err(format!("Evaluation error: Unsupported expression {:?}", it)),
};
Ok(res)
}
}
fn eval_add(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x + y, |x, y| x + y) {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x + y) {
return Ok(Eval::Literal(res));
}
@@ -144,7 +88,7 @@ fn eval_add(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
fn eval_sub(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x - y, |x, y| x - y) {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x - y) {
return Ok(Eval::Literal(res));
}
@@ -155,7 +99,7 @@ fn eval_sub(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
fn eval_mul(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x * y, |x, y| x * y) {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x * y) {
return Ok(Eval::Literal(res));
}
@@ -166,15 +110,15 @@ fn eval_mul(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
fn eval_div(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
match (lval, rval) {
(Eval::Literal(a), Eval::Literal(b)) => {
if let (Literal::Integer(_), Literal::Integer(y)) = (a, b) {
if *y == 0 {
if let (Literal::Number(_), Literal::Number(y)) = (a, b) {
if *y == 0f64 {
return Err(
"Evaluation error: integers attempted to divide by zero.".to_string()
);
}
}
if let Some(res) = eval_numeric_infix(a, b, |x, y| x / y, |x, y| x / y) {
if let Some(res) = eval_numeric_infix(a, b, |x, y| x / y) {
return Ok(Eval::Literal(res));
}
@@ -183,41 +127,23 @@ fn eval_div(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
}
}
fn eval_numeric_infix<FInt, FDouble>(
lhs: &Literal,
rhs: &Literal,
int_op: FInt,
double_op: FDouble,
) -> Option<Literal>
where
FInt: Fn(i64, i64) -> i64,
FDouble: Fn(f64, f64) -> f64,
{
fn eval_numeric_infix(lhs: &Literal, rhs: &Literal, op: fn(f64, f64) -> f64) -> Option<Literal> {
match (lhs, rhs) {
(Literal::Integer(a), Literal::Integer(b)) => Some(Literal::Integer(int_op(*a, *b))),
(Literal::Double(a), Literal::Double(b)) => Some(Literal::Double(double_op(*a, *b))),
(Literal::Integer(a), Literal::Double(b)) => {
Some(Literal::Double(double_op(*a as f64, *b)))
}
(Literal::Double(a), Literal::Integer(b)) => {
Some(Literal::Double(double_op(*a, *b as f64)))
}
(Literal::Number(a), Literal::Number(b)) => Some(Literal::Number(op(*a, *b))),
_ => None,
}
}
fn eval_pos(val: &Eval) -> Result<Eval, String> {
match val {
Eval::Literal(Literal::Integer(it)) => Ok(Eval::Literal(Literal::Integer(*it))),
Eval::Literal(Literal::Double(it)) => Ok(Eval::Literal(Literal::Double(*it))),
Eval::Literal(Literal::Number(it)) => Ok(Eval::Literal(Literal::Number(*it))),
_ => Err("Evaluation error: expected numeric type for POS function.".to_string()),
}
}
fn eval_neg(val: &Eval) -> Result<Eval, String> {
match val {
Eval::Literal(Literal::Integer(it)) => Ok(Eval::Literal(Literal::Integer(-it))),
Eval::Literal(Literal::Double(it)) => Ok(Eval::Literal(Literal::Double(-it))),
Eval::Literal(Literal::Number(it)) => Ok(Eval::Literal(Literal::Number(-it))),
_ => Err("Evaluation error: expected numeric type for NEG function.".to_string()),
}
}

72
backend/src/grid.rs Normal file
View File

@@ -0,0 +1,72 @@
use std::collections::{HashMap, HashSet};
use crate::{
cell::{Cell, CellRef},
evaluator::{Eval, evaluate},
tokenizer::Literal,
};
pub struct Grid {
cells: HashMap<CellRef, Cell>,
}
impl Grid {
pub fn new() -> Grid {
return Grid {
cells: HashMap::new(),
};
}
}
impl Grid {
pub fn set_cell(&mut self, cell_ref: CellRef, raw_val: String) -> Result<Eval, String> {
if self.cells.contains_key(&cell_ref) && self.cells[&cell_ref].raw() == raw_val {
return self.get_cell(cell_ref);
}
let eval: Eval;
let deps: HashSet<CellRef>;
if let Some(c) = raw_val.chars().nth(0)
&& c == '='
{
(eval, deps) = evaluate(raw_val[1..].to_owned(), Some(&self))?;
// for dep in deps {}
} else {
match evaluate(raw_val.to_owned(), Some(&self)) {
Ok(e) => {
(eval, deps) = e;
}
Err(_) => eval = Eval::Literal(Literal::String(raw_val.to_owned())),
}
}
self.cells
.insert(cell_ref, Cell::new(eval.clone(), raw_val));
Ok(eval)
}
// pub fn get_cell(&mut self, cell_ref: CellRef) -> Result<(String, Eval), String> {
pub fn get_cell(&self, cell_ref: CellRef) -> Result<Eval, String> {
if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref));
}
let cell = &self.cells[&cell_ref];
// Ok((cell.raw(), cell.eval()))
Ok(cell.eval())
}
pub fn add_cell_dep(&mut self, cell_ref: CellRef, dep_ref: CellRef) -> Result<(), String> {
if !self.cells.contains_key(&cell_ref) {
return Err(format!("Cell at {:?} not found.", cell_ref));
}
if let Some(cell) = self.cells.get_mut(&cell_ref) {
cell.add_i_dep(dep_ref);
}
Ok(())
}
}

View File

@@ -1,28 +1,24 @@
mod cell;
mod evaluator;
mod grid;
mod messages;
mod parser;
mod tokenizer;
use futures_util::{SinkExt, StreamExt, TryStreamExt, future};
use futures_util::{SinkExt, StreamExt, TryStreamExt};
use log::info;
use std::{env, io::Error};
use tokio::net::{TcpListener, TcpStream};
use crate::{cell::CellRef, evaluator::Evaluator};
use crate::{
evaluator::Eval,
grid::Grid,
messages::{LeadMsg, MsgType},
};
#[tokio::main]
async fn main() -> Result<(), Error> {
env_logger::init();
// let mut input = String::new();
// io::stdin().read_line(&mut input).expect("Expected input.");
// let mut ast = parser::parse(&input).unwrap();
// println!("{}", ast.pretty());
let mut evaluator = Evaluator::new();
// // println!("{}", evaluator.evaluate(input).unwrap());
// let a1 = CellRef { row: 1, col: 2 };
// evaluator.set_cell(a1, input).unwrap();
// println!("{:?}", evaluator.get_cell(a1).unwrap());
let addr = env::args()
.nth(1)
@@ -38,78 +34,8 @@ async fn main() -> Result<(), Error> {
}
Ok(())
// println!("CMDS : set <cell_ref>, get <cell_ref>");
// loop {
// let mut input = String::new();
// io::stdin().read_line(&mut input).expect("Expected input.");
//
// let cmds = ["set", "get"];
// let cmd = &input[0..3];
// if !cmds.iter().any(|c| c == &cmd) {
// println!("{} is an invalid command!", cmd);
// println!("CMDS : set <cell_ref>, get <cell_ref>");
// continue;
// }
//
// let rest = &input[4..];
// let mut parts = rest.splitn(2, char::is_whitespace);
//
// let raw_ref = parts.next().unwrap_or("").trim(); // cell reference
// let raw_str = parts.next().unwrap_or("").trim(); // rest of the string (value)
// // println!("{} {}", raw_ref, raw_str);
//
// if let Ok(cell_ref) = CellRef::new(raw_ref.to_owned()) {
// match cmd {
// "set" => match evaluator.set_cell(cell_ref, raw_str.to_owned()) {
// Ok(_) => println!("Successfully set cell {} to {}.", raw_ref, raw_str),
// Err(e) => println!("{}", e),
// },
// "get" => match evaluator.get_cell(cell_ref) {
// Ok(res) => println!("{:?}", res),
// Err(e) => println!("{}", e),
// },
// _ => {
// panic!("Impossible.");
// }
// }
// } else {
// println!("{} is an invalid cell reference!", raw_ref);
// continue;
// }
// }
}
// async fn accept_connection(stream: TcpStream) {
// let addr = stream
// .peer_addr()
// .expect("connected streams should have a peer address");
// info!("Peer address: {}", addr);
//
// let ws_stream = tokio_tungstenite::accept_async(stream)
// .await
// .expect("Error during the websocket handshake occurred");
//
// info!("New WebSocket connection: {}", addr);
//
// let (mut write, mut read) = ws_stream.split();
//
// // We should not forward messages other than text or binary.
// while let Some(msg) = read.try_next().await.unwrap_or(None) {
// if msg.is_text() || msg.is_binary() {
// if let Err(e) = write
// .send(format!("This is a message {}!", msg.to_text().unwrap_or("")).into())
// .await
// {
// eprintln!("send error: {}", e);
// break;
// }
// }
// }
//
// info!("Disconnected from {}", addr);
// }
async fn accept_connection(stream: TcpStream) {
let addr = stream
.peer_addr()
@@ -125,56 +51,51 @@ async fn accept_connection(stream: TcpStream) {
let (mut write, mut read) = ws_stream.split();
// Each connection gets its own evaluator
let mut evaluator = Evaluator::new();
let mut grid = Grid::new();
while let Some(msg) = read.try_next().await.unwrap_or(None) {
if msg.is_text() {
let input = msg.to_text().unwrap_or("").trim().to_string();
let input = msg.to_text().unwrap();
let cmds = ["set", "get"];
let cmd = &input[0..3.min(input.len())]; // avoid panic on short strings
if let Ok(req) = serde_json::from_str::<LeadMsg>(&input) {
match req.msg_type {
MsgType::Set => {
let Some(cell_ref) = req.cell else { continue };
let Some(raw) = req.raw else { continue };
if !cmds.iter().any(|c| c == &cmd) {
match grid.set_cell(cell_ref.clone(), raw.to_owned()) {
Ok(eval) => match eval {
Eval::Literal(lit) => {
let res = LeadMsg {
msg_type: MsgType::Set,
cell: Some(cell_ref),
raw: Some(raw.to_string()),
eval: Some(lit),
};
let _ = write
.send(format!("ERR invalid command: {}", input).into())
.await;
continue;
}
let rest = input[4..].trim();
let mut parts = rest.splitn(2, char::is_whitespace);
let raw_ref = parts.next().unwrap_or("").trim(); // cell reference
let raw_str = parts.next().unwrap_or("").trim(); // rest (value)
if let Ok(cell_ref) = CellRef::new(raw_ref.to_owned()) {
match cmd {
"set" => match evaluator.set_cell(cell_ref.clone(), raw_str.to_owned()) {
Ok(eval) => {
let _ = write.send(format!("{} {}", raw_ref, eval).into()).await;
}
Err(e) => {
let _ = write.send(format!("ERR {}", e).into()).await;
}
},
"get" => match evaluator.get_cell(cell_ref.clone()) {
Ok(res) => {
let _ = write
.send(format!("{} {}", raw_ref, res.to_string()).into())
.send(serde_json::to_string(&res).unwrap().into())
.await;
}
Err(e) => {
let _ = write.send(format!("ERR {}", e).into()).await;
}
},
Err(e) => {
let res = LeadMsg {
msg_type: MsgType::Error,
cell: Some(cell_ref),
raw: Some(e.to_string()),
eval: None,
};
let _ = write
.send(serde_json::to_string(&res).unwrap().into())
.await;
}
}
}
_ => {
let _ = write.send("ERR impossible".into()).await;
continue; // handle other cases
}
}
} else {
let _ = write
.send(format!("ERR invalid cell reference: {}", raw_ref).into())
.await;
continue;
}
}
}

19
backend/src/messages.rs Normal file
View File

@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use crate::{cell::CellRef, tokenizer::Literal};
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MsgType {
Set,
Get,
Error,
}
#[derive(Serialize, Deserialize)]
pub struct LeadMsg {
pub msg_type: MsgType,
pub cell: Option<CellRef>,
pub raw: Option<String>,
pub eval: Option<Literal>,
}

View File

@@ -169,11 +169,9 @@ pub fn _parse(
) -> Result<Expr, String> {
let mut lhs = match input.next() {
Token::Literal(it) => Expr::Literal(it),
Token::Identifier(id) if id == "true" => Expr::Literal(Literal::Boolean(true)),
Token::Identifier(id) if id == "false" => Expr::Literal(Literal::Boolean(false)),
Token::Paren('(') => {
Token::OpenParen => {
let lhs = _parse(input, 0, deps)?;
if input.next() != Token::Paren(')') {
if input.next() != Token::CloseParen {
return Err(format!("Parse error: expected closing paren."));
}
Expr::Group(Box::new(lhs))
@@ -194,14 +192,14 @@ pub fn _parse(
}
}
Token::Identifier(id) => match input.peek() {
Token::Paren('(') => {
Token::OpenParen => {
input.next();
let mut args: Vec<Expr> = Vec::new();
loop {
let nxt = input.peek();
if nxt == Token::Paren(')') {
if nxt == Token::CloseParen {
input.next();
break;
} else if nxt != Token::Comma && args.len() != 0 {
@@ -286,6 +284,5 @@ pub fn _parse(
}
}
Ok(lhs)
}

View File

@@ -1,7 +1,9 @@
#[derive(Debug, Clone, PartialEq)]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", content = "value")]
pub enum Literal {
Integer(i64),
Double(f64),
Number(f64),
Boolean(bool),
String(String),
}
@@ -11,7 +13,8 @@ pub enum Token {
Identifier(String), // Could be a function
Literal(Literal),
Operator(char),
Paren(char),
OpenParen,
CloseParen,
Comma,
Eof,
}
@@ -39,29 +42,38 @@ impl Tokenizer {
break;
}
}
tokens.push(Token::Identifier(ident));
let res = match ident.as_str() {
"true" => Token::Literal(Literal::Boolean(true)),
"false" => Token::Literal(Literal::Boolean(false)),
it => Token::Identifier(it.into()),
};
tokens.push(res);
} else if c.is_ascii_digit() {
// parse number
let mut number = String::new();
let mut is_decimal = false;
let mut is_exp = false;
while let Some(&ch) = chars.peek() {
if ch.is_ascii_digit() {
number.push(ch);
chars.next();
} else if ch == '.' && !is_decimal {
} else if ch == '.' && !is_decimal && !is_exp {
is_decimal = true;
number.push(ch);
chars.next();
} else if ch == 'e' && !is_decimal && !is_exp {
is_exp = true;
number.push(ch);
chars.next();
} else {
break;
}
}
if is_decimal {
tokens.push(Token::Literal(Literal::Double(number.parse().unwrap())))
} else {
tokens.push(Token::Literal(Literal::Integer(number.parse().unwrap())))
};
// TODO: REMOVE UNWRAP
tokens.push(Token::Literal(Literal::Number(number.parse().unwrap())));
} else if c == '"' || c == '\'' {
// parse string literal
let mut string = String::new();
@@ -89,7 +101,11 @@ impl Tokenizer {
tokens.push(Token::Operator(c));
chars.next();
} else if "()".contains(c) {
tokens.push(Token::Paren(c));
if c == '(' {
tokens.push(Token::OpenParen);
} else {
tokens.push(Token::CloseParen);
}
chars.next();
} else if c == ',' {
tokens.push(Token::Comma);
@@ -116,20 +132,140 @@ mod tests {
use super::*;
#[test]
fn test_tokenizer() {
let raw = "hello hello 1.23 this 5 (1+2)";
let expected: Vec<Token> = vec![
Token::Identifier("hello".to_string()),
Token::Identifier("hello".to_string()),
Token::Literal(Literal::Double(1.23)),
Token::Identifier("this".to_string()),
Token::Literal(Literal::Integer(5)),
Token::Paren('('),
Token::Literal(Literal::Integer(1)),
fn test_single_token() {
assert_eq!(
Tokenizer::new("1").unwrap().tokens,
Vec::from([Token::Literal(Literal::Number(1.0))])
);
assert_eq!(
Tokenizer::new("2.0").unwrap().tokens,
Vec::from([Token::Literal(Literal::Number(2.0))])
);
assert_eq!(
Tokenizer::new("\"hello\"").unwrap().tokens,
Vec::from([Token::Literal(Literal::String("hello".into()))])
);
assert_eq!(
Tokenizer::new("\'hello\'").unwrap().tokens,
Vec::from([Token::Literal(Literal::String("hello".into()))])
);
assert_eq!(
Tokenizer::new("hello").unwrap().tokens,
Vec::from([Token::Identifier("hello".into())])
);
assert_eq!(
Tokenizer::new("+").unwrap().tokens,
Vec::from([Token::Operator('+')])
);
assert_eq!(
Tokenizer::new(",").unwrap().tokens,
Vec::from([Token::Comma])
);
assert_eq!(
Tokenizer::new(")").unwrap().tokens,
Vec::from([Token::CloseParen])
);
assert_eq!(
Tokenizer::new("(").unwrap().tokens,
Vec::from([Token::OpenParen])
);
}
#[test]
fn test_token_punctuation() {
let mut exp = Vec::from([
Token::Comma,
Token::CloseParen,
Token::Comma,
Token::OpenParen,
]);
exp.reverse();
assert_eq!(Tokenizer::new(", ) , (").unwrap().tokens, exp);
}
#[test]
fn test_token_operators() {
let mut exp = Vec::from([
Token::Operator('+'),
Token::Literal(Literal::Integer(2)),
Token::Paren(')'),
Token::Operator('-'),
Token::Operator('*'),
Token::Operator('/'),
Token::Operator('^'),
Token::Operator('!'),
Token::Operator('%'),
Token::Operator('&'),
Token::Operator('|'),
]);
exp.reverse();
assert_eq!(Tokenizer::new("+-*/^!%&|").unwrap().tokens, exp);
}
#[test]
fn test_token_string() {
let raw = "\"hello\" \'world\'";
let mut expected: Vec<Token> = vec![
Token::Literal(Literal::String("hello".into())),
Token::Literal(Literal::String("world".into())),
];
expected.reverse();
let t = Tokenizer::new(&raw).unwrap();
assert_eq!(t.tokens, expected);
}
#[test]
fn test_token_number() {
let raw = "123 4.56";
let mut expected: Vec<Token> = vec![
Token::Literal(Literal::Number(123.0)),
Token::Literal(Literal::Number(4.56)),
];
expected.reverse();
let t = Tokenizer::new(&raw).unwrap();
assert_eq!(t.tokens, expected);
}
#[test]
fn test_token_boolean() {
let raw = "false true";
let mut expected: Vec<Token> = vec![
Token::Literal(Literal::Boolean(false)),
Token::Literal(Literal::Boolean(true)),
];
expected.reverse();
let t = Tokenizer::new(&raw).unwrap();
assert_eq!(t.tokens, expected);
}
#[test]
fn test_token_identifier() {
let raw = "hello test";
let mut expected: Vec<Token> = vec![
Token::Identifier("hello".to_string()),
Token::Identifier("test".to_string()),
];
expected.reverse();
let t = Tokenizer::new(&raw).unwrap();
assert_eq!(t.tokens, expected);
}
#[test]
fn test_token_mix() {
let raw = "hello test 1.23 this 5 (1+2)";
let mut expected: Vec<Token> = vec![
Token::Identifier("hello".to_string()),
Token::Identifier("test".to_string()),
Token::Literal(Literal::Number(1.23)),
Token::Identifier("this".to_string()),
Token::Literal(Literal::Number(5.0)),
Token::OpenParen,
Token::Literal(Literal::Number(1.0)),
Token::Operator('+'),
Token::Literal(Literal::Number(2.0)),
Token::CloseParen,
];
expected.reverse();
let t = Tokenizer::new(&raw).unwrap();
assert_eq!(t.tokens, expected);
}

View File

@@ -111,6 +111,28 @@
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;

View File

@@ -18,7 +18,7 @@
val: string;
active: boolean;
resizeable?: boolean;
direction?: 'col' | 'row';
direction?: 'col' | 'row' | 'blank';
} = $props();
// --- Drag Logic ---
@@ -62,7 +62,12 @@
<div
style:width
style:height
class={clsx('placeholder group relative bg-background p-1 dark:bg-input/30', { active })}
class={clsx('placeholder group relative bg-background p-1', {
active,
col: direction === 'col',
row: direction === 'row',
blank: direction === 'blank'
})}
>
<span class="pointer-events-none flex h-full w-full items-center justify-center select-none">
{val}
@@ -88,20 +93,34 @@
background-color: var(--color-background);
}
.placeholder.blank {
border: 1px solid var(--input);
}
.placeholder.blank,
.placeholder.row {
border-left: none;
}
.placeholder.blank,
.placeholder.col {
border-top: none;
}
.active {
border: 1px solid var(--color-primary);
background-color: color-mix(in oklab, var(--color-primary) 80%, var(--color-background) 80%);
font-weight: bold;
/* border: 1px solid var(--color-primary); */
}
/* --- Resizer Styles --- */
.resizer {
position: absolute;
/* Make it easier to grab */
z-index: 10;
/* Subtle visual cue, becomes more visible on hover */
background-color: var(--color-primary);
opacity: 0;
transition: opacity 0.1s ease-in-out;
z-index: 60;
}
/* Style for vertical (column) resizing */
@@ -113,7 +132,6 @@
height: 100%;
}
/* Style for horizontal (row) resizing */
.resizer-row {
cursor: row-resize;
bottom: -5px;

View File

@@ -3,6 +3,7 @@
import clsx from 'clsx';
let {
cla = '',
width = '80px',
height = '30px',
raw_val = $bindable(''),
@@ -13,10 +14,11 @@
active = false,
editing = false
}: {
cla?: string;
width?: string;
height?: string;
raw_val?: string;
val?: number | string | undefined;
val?: LiteralValue | undefined;
onmousedown?: (e: MouseEvent) => void;
startediting?: () => void;
stopediting?: () => void;
@@ -51,9 +53,8 @@
<div use:autofocusWithin onkeydown={handleKeydown}>
<Input
style="width: {width}; height: {height}"
class="relative rounded-none p-1
!transition-none delay-0 duration-0
focus:z-20"
class="relative rounded-none p-1 !transition-none delay-0 duration-0
focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
onblur={(e) => {
raw_val = (e.target as HTMLInputElement).value;
stopediting();
@@ -66,7 +67,7 @@
{onmousedown}
style:width
style:height
class={clsx('placeholder bg-background p-1 dark:bg-input/30', { active, 'z-20': active })}
class={clsx('placeholder bg-background p-1', { active }, cla)}
>
{#if raw_val !== '' || val !== ''}
<span class="pointer-events-none select-none">
@@ -89,6 +90,7 @@
}
.active {
z-index: 20;
border: 1px solid var(--color-primary);
outline: 1px solid var(--color-primary);
}

View File

@@ -3,84 +3,57 @@
import Cell from '$lib/components/grid/cell.svelte';
import { onDestroy, onMount } from 'svelte';
import CellHeader from './cell-header.svelte';
import {
refFromStr,
splitErrorString,
colToStr,
refFromPos,
type CellData,
type CellValue
} from './utils';
import { colToStr, refToStr, type CellT } from './utils';
import clsx from 'clsx';
let {
socket
socket,
class: className = ''
}: {
class?: string;
socket: WebSocket;
} = $props();
socket.onmessage = (msg: MessageEvent) => {
const input = msg.data.toString().trim();
let res: LeadMsg;
if (input.startsWith('ERR')) {
let split = splitErrorString(input);
toast.error(split[0], {
description: split[1]
try {
res = JSON.parse(msg.data);
console.log(res);
} catch (err) {
console.error('Failed to parse LeadMsg:', err);
return;
}
switch (res.msg_type) {
case 'error': {
toast.error('Error', {
description: res.raw
});
break;
}
case 'set': {
if (res.cell === undefined) {
console.error('Expected cell ref for SET response from server.');
return;
} else if (res.eval === undefined) {
console.error('Expected cell value for SET response from server.');
return;
}
let strRef: string | undefined;
let evalStr: string | undefined;
// Case 1: "Cell D4 = Integer(4)"
let match = input.match(/^Cell\s+([A-Z]+\d+)\s*=\s*(.+)$/);
if (match) {
[, strRef, evalStr] = match;
setCellVal(res.cell.row, res.cell.col, res.eval.value);
break;
}
// Case 2: "D6 String("hello")" or "E9 Double(4.0)"
if (!match) {
match = input.match(/^([A-Z]+\d+)\s+(.+)$/);
if (match) {
[, strRef, evalStr] = match;
}
}
if (!strRef || !evalStr) {
console.warn('Unrecognized message:', input);
return;
}
console.log(`Cell: ${strRef}`);
console.log(`Eval: ${evalStr}`);
let { row, col } = refFromStr(strRef);
// Parse eval types
if (evalStr.startsWith('Integer(')) {
const num = parseInt(evalStr.match(/^Integer\(([-\d]+)\)$/)?.[1] ?? 'NaN', 10);
console.log(`Parsed integer:`, num);
setCellVal(row, col, num);
} else if (evalStr.startsWith('Double(')) {
const num = parseFloat(evalStr.match(/^Double\(([-\d.]+)\)$/)?.[1] ?? 'NaN');
console.log(`Parsed double:`, num);
setCellVal(row, col, num);
} else if (evalStr.startsWith('String(')) {
const str = evalStr.match(/^String\("(.+)"\)$/)?.[1];
console.log(`Parsed string:`, str);
setCellVal(row, col, str);
}
};
let rows = 100;
let cols = 60;
let rows = 50;
let cols = 40;
let default_row_height = '30px';
let default_col_width = '80px';
// Only store touched cells
let grid_vals: Record<string, CellData> = $state({});
let grid_vals: Record<string, CellT> = $state({});
let row_heights: Record<number, string> = $state({});
let col_widths: Record<number, string> = $state({});
@@ -136,7 +109,7 @@
}
};
const getCellVal = (i: number, j: number) => getCell(i, j)?.val ?? undefined;
const setCellVal = (i: number, j: number, val: CellValue) => {
const setCellVal = (i: number, j: number, val: LiteralValue) => {
if (grid_vals[key(i, j)] === undefined) {
console.warn('Cell raw value was undefined but recieved eval.');
} else {
@@ -144,19 +117,21 @@
}
};
const setCell = (i: number, j: number, v: string | undefined) => {
const setCell = (row: number, col: number, v: string | undefined) => {
// ignore “no value” so we dont create keys on mount
if (v == null || v === '') delete grid_vals[key(i, j)];
if (v == null || v === '') delete grid_vals[key(row, col)];
else {
setCellRaw(i, j, v);
console.log(i, j);
socket.send(`set ${refFromPos(i, j).str} ${v}`);
}
setCellRaw(row, col, v);
let msg: LeadMsg = {
msg_type: 'set',
cell: { row, col },
raw: v
};
// $effect(() => {
// $inspect(grid_vals);
// });
socket.send(JSON.stringify(msg));
}
};
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
if (editing_cell) {
@@ -170,7 +145,7 @@
e.preventDefault();
// --- This is the same reference-inserting logic as before ---
const ref = refFromPos(i, j).str;
const ref = refToStr(i, j);
if (el) {
const { selectionStart, selectionEnd } = el;
const before = el.value.slice(0, selectionStart ?? 0);
@@ -202,15 +177,18 @@
});
</script>
<div class="grid-wrapper relative max-h-[100vh] max-w-full overflow-auto">
<div class="sticky top-0 z-40 flex w-fit">
<div class="sticky top-0 left-0 z-50">
<div
class={clsx('grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)}
>
<div class="sticky top-0 flex w-fit" style="z-index: {rows + 70}">
<div class="sticky top-0 left-0" style="z-index: {rows + 70}">
<CellHeader
resizeable={false}
height={default_row_height}
width={default_col_width}
val=""
active={false}
direction="blank"
/>
</div>
@@ -226,8 +204,8 @@
{/each}
</div>
{#each Array(rows) as _, i}
<div class="flex w-fit">
<div class="sticky left-0 z-30 flex w-fit">
<div class="relative flex w-fit">
<div class="sticky left-0 flex w-fit" style="z-index: {rows - i + 40}">
<CellHeader
direction="row"
width={default_col_width}

View File

@@ -0,0 +1,19 @@
interface LeadMsg {
msg_type: 'set' | 'get' | 'error';
cell?: CellRef;
raw?: string;
eval?: Literal;
}
interface CellRef {
row: number;
col: number;
}
type LiteralType = 'Number' | 'Boolean' | 'String';
type LiteralValue = number | string | boolean;
interface Literal {
type: LiteralType;
value: LiteralValue;
}

View File

@@ -1,14 +1,6 @@
export type CellValue = number | string | undefined;
export interface CellData {
export interface CellT {
raw_val: string;
val: CellValue;
}
export interface CellRef {
row: number;
col: number;
str: string;
val: LiteralValue | undefined;
}
/**
@@ -26,7 +18,7 @@ export function refFromStr(ref: string): CellRef {
}
const row = parseInt(rowStr, 10);
return { row: row - 1, col: col - 1, str: ref };
return { row: row - 1, col: col - 1 };
}
/**
@@ -47,27 +39,6 @@ export function colToStr(col: number): string {
/**
* Zero indexed | A1 == {row: 0, col: 0};
*/
export function refFromPos(row: number, col: number): CellRef {
return { row, col, str: colToStr(col) + (row + 1).toString() };
}
export function splitErrorString(errorString: string) {
// Remove the "ERR " prefix.
const content = errorString.substring(4);
// Find the index of the first colon.
const colonIndex = content.indexOf(':');
// If no colon is found, return the whole content as the first element.
if (colonIndex === -1) {
return [content.trim(), ''];
}
// Extract the part before the colon (the error type).
const errorType = content.substring(0, colonIndex).trim();
// Extract the part after the colon (the error message).
const errorMessage = content.substring(colonIndex + 1).trim();
return [errorType, errorMessage];
export function refToStr(row: number, col: number): string {
return colToStr(col) + (row + 1).toString();
}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import CalendarIcon from '@lucide/svelte/icons/calendar';
import HouseIcon from '@lucide/svelte/icons/house';
import InboxIcon from '@lucide/svelte/icons/inbox';
import SearchIcon from '@lucide/svelte/icons/search';
import SettingsIcon from '@lucide/svelte/icons/settings';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import Button from '../button/button.svelte';
import { toggleMode } from 'mode-watcher';
// Menu items.
const items = [
{
title: 'Home',
url: '#',
icon: HouseIcon
},
{
title: 'Inbox',
url: '#',
icon: InboxIcon
},
{
title: 'Calendar',
url: '#',
icon: CalendarIcon
},
{
title: 'Search',
url: '#',
icon: SearchIcon
},
{
title: 'Settings',
url: '#',
icon: SettingsIcon
}
];
</script>
<Sidebar.Root variant="floating">
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>
<div class="flex w-full items-center justify-between">
<div>Lead</div>
<Sidebar.Trigger />
</div>
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a href={item.url} {...props}>
<item.icon />
<span>{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Button onclick={toggleMode} variant="ghost" size="icon">
<SunIcon
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<MoonIcon
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>
</Sidebar.Footer>
</Sidebar.Root>

View File

@@ -0,0 +1,7 @@
import Root from "./separator.svelte";
export {
Root,
//
Root as Separator,
};

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot="separator"
class={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,36 @@
import { Dialog as SheetPrimitive } from "bits-ui";
import Trigger from "./sheet-trigger.svelte";
import Close from "./sheet-close.svelte";
import Overlay from "./sheet-overlay.svelte";
import Content from "./sheet-content.svelte";
import Header from "./sheet-header.svelte";
import Footer from "./sheet-footer.svelte";
import Title from "./sheet-title.svelte";
import Description from "./sheet-description.svelte";
const Root = SheetPrimitive.Root;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.CloseProps = $props();
</script>
<SheetPrimitive.Close bind:ref data-slot="sheet-close" {...restProps} />

View File

@@ -0,0 +1,58 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sheetVariants = tv({
base: "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
variants: {
side: {
top: "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
bottom: "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
left: "data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
right: "data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
});
export type Side = VariantProps<typeof sheetVariants>["side"];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import XIcon from "@lucide/svelte/icons/x";
import type { Snippet } from "svelte";
import SheetOverlay from "./sheet-overlay.svelte";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
side = "right",
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content
bind:ref
data-slot="sheet-content"
class={cn(sheetVariants({ side }), className)}
{...restProps}
>
{@render children?.()}
<SheetPrimitive.Close
class="ring-offset-background focus-visible:ring-ring rounded-xs focus-visible:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none"
>
<XIcon class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.DescriptionProps = $props();
</script>
<SheetPrimitive.Description
bind:ref
data-slot="sheet-description"
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-footer"
class={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sheet-header"
class={cn("flex flex-col gap-1.5 p-4", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
data-slot="sheet-overlay"
class={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
data-slot="sheet-title"
class={cn("text-foreground font-semibold", className)}
{...restProps}
/>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: SheetPrimitive.TriggerProps = $props();
</script>
<SheetPrimitive.Trigger bind:ref data-slot="sheet-trigger" {...restProps} />

View File

@@ -0,0 +1,6 @@
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";

View File

@@ -0,0 +1,81 @@
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
import { getContext, setContext } from "svelte";
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
type Getter<T> = () => T;
export type SidebarStateProps = {
/**
* A getter function that returns the current open state of the sidebar.
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
* component.
*/
open: Getter<boolean>;
/**
* A function that sets the open state of the sidebar. To support `bind:open`, we need
* a source of truth for changing the open state to ensure it will be synced throughout
* the sub-components and any `bind:` references.
*/
setOpen: (open: boolean) => void;
};
class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen: SidebarStateProps["setOpen"];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
constructor(props: SidebarStateProps) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e: KeyboardEvent) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value: boolean) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current
? (this.openMobile = !this.openMobile)
: this.setOpen(!this.open);
};
}
const SYMBOL_KEY = "scn-sidebar";
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props: SidebarStateProps): SidebarState {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar(): SidebarState {
return getContext(Symbol.for(SYMBOL_KEY));
}

View File

@@ -0,0 +1,75 @@
import { useSidebar } from "./context.svelte.js";
import Content from "./sidebar-content.svelte";
import Footer from "./sidebar-footer.svelte";
import GroupAction from "./sidebar-group-action.svelte";
import GroupContent from "./sidebar-group-content.svelte";
import GroupLabel from "./sidebar-group-label.svelte";
import Group from "./sidebar-group.svelte";
import Header from "./sidebar-header.svelte";
import Input from "./sidebar-input.svelte";
import Inset from "./sidebar-inset.svelte";
import MenuAction from "./sidebar-menu-action.svelte";
import MenuBadge from "./sidebar-menu-badge.svelte";
import MenuButton from "./sidebar-menu-button.svelte";
import MenuItem from "./sidebar-menu-item.svelte";
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
import MenuSub from "./sidebar-menu-sub.svelte";
import Menu from "./sidebar-menu.svelte";
import Provider from "./sidebar-provider.svelte";
import Rail from "./sidebar-rail.svelte";
import Separator from "./sidebar-separator.svelte";
import Trigger from "./sidebar-trigger.svelte";
import Root from "./sidebar.svelte";
export {
Content,
Footer,
Group,
GroupAction,
GroupContent,
GroupLabel,
Header,
Input,
Inset,
Menu,
MenuAction,
MenuBadge,
MenuButton,
MenuItem,
MenuSkeleton,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
Separator,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Input as SidebarInput,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
Separator as SidebarSeparator,
Trigger as SidebarTrigger,
Trigger,
useSidebar,
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-content"
data-sidebar="content"
class={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-footer"
data-sidebar="footer"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground outline-hidden absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-group-action",
"data-sidebar": "group-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group-content"
data-sidebar="group-content"
class={cn("w-full text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground/70 ring-sidebar-ring outline-hidden flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
),
"data-slot": "sidebar-group-label",
"data-sidebar": "group-label",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-group"
data-sidebar="group"
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-header"
data-sidebar="header"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
bind:value
data-slot="sidebar-input"
data-sidebar="input"
class={cn("bg-background h-8 w-full shadow-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<main
bind:this={ref}
data-slot="sidebar-inset"
class={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
className
)}
{...restProps}
>
{@render children?.()}
</main>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showOnHover = false,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
showOnHover?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground outline-hidden absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
),
"data-slot": "sidebar-menu-action",
"data-sidebar": "menu-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
class={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,103 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground group-has-data-[sidebar=menu-action]/menu-item:pr-8 data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_var(--sidebar-border)] hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "group-data-[collapsible=icon]:p-0! h-12 text-sm",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type SidebarMenuButtonVariant = VariantProps<
typeof sidebarMenuButtonVariants
>["variant"];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
</script>
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn, type WithElementRef, type WithoutChildrenOrChild } from "$lib/utils.js";
import { mergeProps } from "bits-ui";
import type { ComponentProps, Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
child,
variant = "default",
size = "default",
isActive = false,
tooltipContent,
tooltipContentProps,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
isActive?: boolean;
variant?: SidebarMenuButtonVariant;
size?: SidebarMenuButtonSize;
tooltipContent?: Snippet | string;
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const sidebar = useSidebar();
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-slot": "sidebar-menu-button",
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#snippet Button({ props }: { props?: Record<string, unknown> })}
{@const mergedProps = mergeProps(buttonProps, props)}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
{/snippet}
{#if !tooltipContent}
{@render Button({})}
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render Button({ props })}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
{...tooltipContentProps}
>
{#if typeof tooltipContent === "string"}
{tooltipContent}
{:else if tooltipContent}
{@render tooltipContent()}
{/if}
</Tooltip.Content>
</Tooltip.Root>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
class={cn("group/menu-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showIcon = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
showIcon?: boolean;
} = $props();
// Random width between 50% and 90%
const width = `${Math.floor(Math.random() * 40) + 50}%`;
</script>
<div
bind:this={ref}
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
class={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...restProps}
>
{#if showIcon}
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
{/if}
<Skeleton
class="max-w-(--skeleton-width) h-4 flex-1"
data-sidebar="menu-skeleton-text"
style="--skeleton-width: {width};"
/>
{@render children?.()}
</div>

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAnchorAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
size = "md",
isActive = false,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
size?: "sm" | "md";
isActive?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground outline-hidden flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
),
"data-slot": "sidebar-menu-sub-button",
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a bind:this={ref} {...mergedProps}>
{@render children?.()}
</a>
{/if}

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
</script>
<li
bind:this={ref}
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
class={cn("group/menu-sub-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
class={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
</script>
<ul
bind:this={ref}
data-slot="sidebar-menu"
data-sidebar="menu"
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...restProps}
>
{@render children?.()}
</ul>

View File

@@ -0,0 +1,53 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON
} from './constants.js';
import { setSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
open = $bindable(false),
onOpenChange = () => {},
class: className,
style,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
} = $props();
const sidebar = setSidebar({
open: () => open,
setOpen: (value: boolean) => {
open = value;
onOpenChange(value);
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={sidebar.toggle}
title="Toggle Sidebar"
class={cn(
'absolute inset-y-0 z-[2000] hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-[calc(1/2*100%-1px)] after:w-[2px] hover:after:bg-sidebar-border sm:flex',
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
'[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize',
'group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar',
'[[data-side=left][data-collapsible=offcanvas]_&]:-right-2',
'[[data-side=right][data-collapsible=offcanvas]_&]:-left-2',
className
)}
{...restProps}
>
{@render children?.()}
</button>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="sidebar-separator"
data-sidebar="separator"
class={cn("bg-sidebar-border", className)}
{...restProps}
/>

View File

@@ -0,0 +1,35 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils.js";
import PanelLeftIcon from "@lucide/svelte/icons/panel-left";
import type { ComponentProps } from "svelte";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
onclick,
...restProps
}: ComponentProps<typeof Button> & {
onclick?: (e: MouseEvent) => void;
} = $props();
const sidebar = useSidebar();
</script>
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
class={cn("size-7", className)}
type="button"
onclick={(e) => {
onclick?.(e);
sidebar.toggle();
}}
{...restProps}
>
<PanelLeftIcon />
<span class="sr-only">Toggle Sidebar</span>
</Button>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_WIDTH_MOBILE } from './constants.js';
import { useSidebar } from './context.svelte.js';
let {
ref = $bindable(null),
side = 'left',
variant = 'sidebar',
collapsible = 'offcanvas',
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
side?: 'left' | 'right';
variant?: 'sidebar' | 'floating' | 'inset';
collapsible?: 'offcanvas' | 'icon' | 'none';
} = $props();
const sidebar = useSidebar();
</script>
{#if collapsible === 'none'}
<div
class={cn(
'flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground',
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root bind:open={() => sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}>
<Sheet.Content
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
class="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<Sheet.Header class="sr-only">
<Sheet.Title>Sidebar</Sheet.Title>
<Sheet.Description>Displays the mobile sidebar.</Sheet.Description>
</Sheet.Header>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="group peer hidden text-sidebar-foreground md:block"
data-state={sidebar.state}
data-collapsible={sidebar.state === 'collapsed' ? collapsible : ''}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
<!-- This is what handles the sidebar gap on desktop -->
<div
data-slot="sidebar-gap"
class={cn(
'relative w-(--sidebar-width) bg-transparent transition-[width] duration-100 ease-linear',
'group-data-[collapsible=offcanvas]:w-0',
'group-data-[side=right]:rotate-180',
variant === 'floating' || variant === 'inset'
? 'group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon)'
)}
></div>
<div
data-slot="sidebar-container"
class={cn(
'fixed inset-y-0 z-[1000] hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-100 ease-linear md:flex',
side === 'left'
? 'left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]'
: 'right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]',
// Adjust the padding for floating and inset variants.
variant === 'floating' || variant === 'inset'
? 'p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]'
: 'group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l',
className
)}
{...restProps}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
class="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
>
{@render children?.()}
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn("bg-accent animate-pulse rounded-md", className)}
{...restProps}
></div>

View File

@@ -0,0 +1,21 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
const Portal = TooltipPrimitive.Portal;
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
};

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = "top",
children,
arrowClasses,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
} = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs",
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
"bg-primary z-50 size-2.5 rotate-45 rounded-[2px]",
"data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]",
"data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]",
"data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2",
"data-[side=left]:-translate-y-[calc(50%_-_3px)]",
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />

View File

@@ -0,0 +1,9 @@
import { MediaQuery } from "svelte/reactivity";
const DEFAULT_MOBILE_BREAKPOINT = 768;
export class IsMobile extends MediaQuery {
constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) {
super(`max-width: ${breakpoint - 1}px`);
}
}

View File

@@ -3,6 +3,8 @@
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import favicon from '$lib/assets/favicon.svg';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import LeadSidebar from '$lib/components/ui/lead-sidebar/lead-sidebar.svelte';
let { children } = $props();
</script>
@@ -13,4 +15,10 @@
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}
<Sidebar.Provider>
<LeadSidebar />
<main>
{@render children?.()}
</main>
</Sidebar.Provider>

View File

@@ -0,0 +1 @@
export const prerender = true;

View File

@@ -1,24 +1,37 @@
<script lang="ts">
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { Button } from '$lib/components/ui/button/index.js';
import { toggleMode } from 'mode-watcher';
import Grid from '$lib/components/grid/grid.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { fade } from 'svelte/transition';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
const sidebar = useSidebar();
let socket = new WebSocket('ws://localhost:7050');
</script>
<!-- <Button onclick={toggleMode} variant="outline" size="icon"> -->
<!-- <SunIcon -->
<!-- class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90" -->
<!-- /> -->
<!-- <MoonIcon -->
<!-- class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0" -->
<!-- /> -->
<!-- <span class="sr-only">Toggle theme</span> -->
<!-- </Button> -->
{#if sidebar?.openMobile || sidebar?.open}
<button
class="fixed inset-0 z-[700] bg-black/40"
aria-label="Close sidebar"
onclick={() => (sidebar.isMobile ? sidebar.setOpenMobile(false) : sidebar.setOpen(false))}
transition:fade={{ duration: 100 }}
></button>
{/if}
<div class="m-0">
<Grid {socket} />
<div class="absolute left-0 min-h-0 w-full">
<div class="flex h-[100vh] flex-col">
<div class="h-[60px] w-full p-3">
<Sidebar.Trigger />
</div>
<div class="grid-wrapper min-h-0 w-full flex-1">
<Grid class="h-full min-w-0" {socket} />
</div>
</div>
</div>
<style>
.grid-wrapper {
border-top: 2px solid var(--color-input);
}
</style>