🙃
This commit is contained in:
20
backend/Cargo.lock
generated
20
backend/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
0
backend/src/common.rs
Normal 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
72
backend/src/grid.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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
19
backend/src/messages.rs
Normal 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>,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 don’t 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}
|
||||
|
||||
19
frontend/src/lib/components/grid/messages.d.ts
vendored
Normal file
19
frontend/src/lib/components/grid/messages.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
7
frontend/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
20
frontend/src/lib/components/ui/separator/separator.svelte
Normal file
20
frontend/src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
36
frontend/src/lib/components/ui/sheet/index.ts
Normal file
36
frontend/src/lib/components/ui/sheet/index.ts
Normal 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,
|
||||
};
|
||||
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal file
7
frontend/src/lib/components/ui/sheet/sheet-close.svelte
Normal 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} />
|
||||
58
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal file
58
frontend/src/lib/components/ui/sheet/sheet-content.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-footer.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-header.svelte
Normal 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>
|
||||
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal file
20
frontend/src/lib/components/ui/sheet/sheet-overlay.svelte
Normal 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}
|
||||
/>
|
||||
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal file
17
frontend/src/lib/components/ui/sheet/sheet-title.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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} />
|
||||
6
frontend/src/lib/components/ui/sidebar/constants.ts
Normal file
6
frontend/src/lib/components/ui/sidebar/constants.ts
Normal 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";
|
||||
81
frontend/src/lib/components/ui/sidebar/context.svelte.ts
Normal file
81
frontend/src/lib/components/ui/sidebar/context.svelte.ts
Normal 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));
|
||||
}
|
||||
75
frontend/src/lib/components/ui/sidebar/index.ts
Normal file
75
frontend/src/lib/components/ui/sidebar/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
21
frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-footer.svelte
Normal 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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
21
frontend/src/lib/components/ui/sidebar/sidebar-group.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-group.svelte
Normal 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>
|
||||
21
frontend/src/lib/components/ui/sidebar/sidebar-header.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-header.svelte
Normal 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>
|
||||
21
frontend/src/lib/components/ui/sidebar/sidebar-input.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-input.svelte
Normal 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}
|
||||
/>
|
||||
24
frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte
Normal file
24
frontend/src/lib/components/ui/sidebar/sidebar-inset.svelte
Normal 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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
21
frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte
Normal file
21
frontend/src/lib/components/ui/sidebar/sidebar-menu.svelte
Normal 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>
|
||||
@@ -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>
|
||||
36
frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte
Normal file
36
frontend/src/lib/components/ui/sidebar/sidebar-rail.svelte
Normal 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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
101
frontend/src/lib/components/ui/sidebar/sidebar.svelte
Normal file
101
frontend/src/lib/components/ui/sidebar/sidebar.svelte
Normal 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}
|
||||
7
frontend/src/lib/components/ui/skeleton/index.ts
Normal file
7
frontend/src/lib/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./skeleton.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
};
|
||||
17
frontend/src/lib/components/ui/skeleton/skeleton.svelte
Normal file
17
frontend/src/lib/components/ui/skeleton/skeleton.svelte
Normal 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>
|
||||
21
frontend/src/lib/components/ui/tooltip/index.ts
Normal file
21
frontend/src/lib/components/ui/tooltip/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
9
frontend/src/lib/hooks/is-mobile.svelte.ts
Normal file
9
frontend/src/lib/hooks/is-mobile.svelte.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<Sidebar.Provider>
|
||||
<LeadSidebar />
|
||||
|
||||
<main>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
</Sidebar.Provider>
|
||||
|
||||
1
frontend/src/routes/+layout.ts
Normal file
1
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user