🙃
This commit is contained in:
20
backend/Cargo.lock
generated
20
backend/Cargo.lock
generated
@@ -331,6 +331,8 @@ dependencies = [
|
|||||||
"env_logger",
|
"env_logger",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
]
|
]
|
||||||
@@ -512,6 +514,12 @@ version = "0.1.26"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
|
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.219"
|
version = "1.0.219"
|
||||||
@@ -532,6 +540,18 @@ dependencies = [
|
|||||||
"syn",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
|
|||||||
@@ -7,5 +7,7 @@ edition = "2024"
|
|||||||
env_logger = "0.11.8"
|
env_logger = "0.11.8"
|
||||||
futures-util = "0.3.31"
|
futures-util = "0.3.31"
|
||||||
log = "0.4.27"
|
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 = { version = "1.47.1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
||||||
tokio-tungstenite = "0.27.0"
|
tokio-tungstenite = "0.27.0"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::evaluator::*;
|
use crate::evaluator::*;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[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 struct CellRef {
|
||||||
pub row: i64,
|
pub row: usize,
|
||||||
pub col: i64,
|
pub col: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CellRef {
|
impl CellRef {
|
||||||
|
// Zero indexed
|
||||||
pub fn new(s: String) -> Result<CellRef, String> {
|
pub fn new(s: String) -> Result<CellRef, String> {
|
||||||
let s = s.trim();
|
let s = s.trim();
|
||||||
let mut col: i64 = 0;
|
let mut col: usize = 0;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
||||||
// consume leading letters for the column
|
// consume leading letters for the column
|
||||||
for (idx, ch) in s.char_indices() {
|
for (idx, ch) in s.char_indices() {
|
||||||
if ch.is_ascii_alphabetic() {
|
if ch.is_ascii_alphabetic() {
|
||||||
let u = ch.to_ascii_uppercase() as u8;
|
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;
|
col = col * 26 + val;
|
||||||
i = idx + ch.len_utf8();
|
i = idx + ch.len_utf8();
|
||||||
} else {
|
} else {
|
||||||
@@ -90,8 +93,11 @@ impl CellRef {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(row) = row_part.parse::<i64>() {
|
if let Ok(row) = row_part.parse::<usize>() {
|
||||||
Ok(CellRef { row, col })
|
Ok(CellRef {
|
||||||
|
row: row - 1,
|
||||||
|
col: col - 1,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(format!("Parse error: invalid row number."))
|
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::parser::*;
|
||||||
use crate::tokenizer::Literal;
|
use crate::tokenizer::Literal;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::HashSet;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
@@ -17,84 +18,28 @@ impl fmt::Display for Eval {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Evaluator {
|
pub fn evaluate(str: String, grid: Option<&Grid>) -> Result<(Eval, HashSet<CellRef>), String> {
|
||||||
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> {
|
|
||||||
let (expr, deps) = parse(&str)?;
|
let (expr, deps) = parse(&str)?;
|
||||||
|
|
||||||
match self.evaluate_expr(&expr) {
|
match evaluate_expr(&expr, grid) {
|
||||||
Ok(it) => Ok((it, deps)),
|
Ok(it) => Ok((it, deps)),
|
||||||
Err(it) => Err(it),
|
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 {
|
let res = match expr {
|
||||||
Expr::Literal(lit) => Eval::Literal(lit.clone()),
|
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 } => {
|
Expr::Infix { op, lhs, rhs } => {
|
||||||
let lval = self.evaluate_expr(lhs)?;
|
let lval = evaluate_expr(lhs, grid)?;
|
||||||
let rval = self.evaluate_expr(rhs)?;
|
let rval = evaluate_expr(rhs, grid)?;
|
||||||
|
|
||||||
match op {
|
match op {
|
||||||
InfixOp::ADD => eval_add(&lval, &rval)?,
|
InfixOp::ADD => eval_add(&lval, &rval)?,
|
||||||
@@ -105,27 +50,26 @@ impl Evaluator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Expr::Prefix { op, expr } => {
|
Expr::Prefix { op, expr } => {
|
||||||
let val = self.evaluate_expr(expr)?;
|
let val = evaluate_expr(expr, grid)?;
|
||||||
|
|
||||||
match op {
|
match op {
|
||||||
PrefixOp::POS => eval_pos(&val)?,
|
PrefixOp::POS => eval_pos(&val)?,
|
||||||
PrefixOp::NEG => eval_neg(&val)?,
|
PrefixOp::NEG => eval_neg(&val)?,
|
||||||
PrefixOp::NOT => eval_not(&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)),
|
it => return Err(format!("Evaluation error: Unsupported expression {:?}", it)),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(res)
|
Ok(res)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fn eval_add(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
|
fn eval_add(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
|
||||||
match (lval, rval) {
|
match (lval, rval) {
|
||||||
(Eval::Literal(a), Eval::Literal(b)) => {
|
(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));
|
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> {
|
fn eval_sub(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
|
||||||
match (lval, rval) {
|
match (lval, rval) {
|
||||||
(Eval::Literal(a), Eval::Literal(b)) => {
|
(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));
|
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> {
|
fn eval_mul(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
|
||||||
match (lval, rval) {
|
match (lval, rval) {
|
||||||
(Eval::Literal(a), Eval::Literal(b)) => {
|
(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));
|
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> {
|
fn eval_div(lval: &Eval, rval: &Eval) -> Result<Eval, String> {
|
||||||
match (lval, rval) {
|
match (lval, rval) {
|
||||||
(Eval::Literal(a), Eval::Literal(b)) => {
|
(Eval::Literal(a), Eval::Literal(b)) => {
|
||||||
if let (Literal::Integer(_), Literal::Integer(y)) = (a, b) {
|
if let (Literal::Number(_), Literal::Number(y)) = (a, b) {
|
||||||
if *y == 0 {
|
if *y == 0f64 {
|
||||||
return Err(
|
return Err(
|
||||||
"Evaluation error: integers attempted to divide by zero.".to_string()
|
"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));
|
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>(
|
fn eval_numeric_infix(lhs: &Literal, rhs: &Literal, op: fn(f64, f64) -> f64) -> Option<Literal> {
|
||||||
lhs: &Literal,
|
|
||||||
rhs: &Literal,
|
|
||||||
int_op: FInt,
|
|
||||||
double_op: FDouble,
|
|
||||||
) -> Option<Literal>
|
|
||||||
where
|
|
||||||
FInt: Fn(i64, i64) -> i64,
|
|
||||||
FDouble: Fn(f64, f64) -> f64,
|
|
||||||
{
|
|
||||||
match (lhs, rhs) {
|
match (lhs, rhs) {
|
||||||
(Literal::Integer(a), Literal::Integer(b)) => Some(Literal::Integer(int_op(*a, *b))),
|
(Literal::Number(a), Literal::Number(b)) => Some(Literal::Number(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)))
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_pos(val: &Eval) -> Result<Eval, String> {
|
fn eval_pos(val: &Eval) -> Result<Eval, String> {
|
||||||
match val {
|
match val {
|
||||||
Eval::Literal(Literal::Integer(it)) => Ok(Eval::Literal(Literal::Integer(*it))),
|
Eval::Literal(Literal::Number(it)) => Ok(Eval::Literal(Literal::Number(*it))),
|
||||||
Eval::Literal(Literal::Double(it)) => Ok(Eval::Literal(Literal::Double(*it))),
|
|
||||||
_ => Err("Evaluation error: expected numeric type for POS function.".to_string()),
|
_ => Err("Evaluation error: expected numeric type for POS function.".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn eval_neg(val: &Eval) -> Result<Eval, String> {
|
fn eval_neg(val: &Eval) -> Result<Eval, String> {
|
||||||
match val {
|
match val {
|
||||||
Eval::Literal(Literal::Integer(it)) => Ok(Eval::Literal(Literal::Integer(-it))),
|
Eval::Literal(Literal::Number(it)) => Ok(Eval::Literal(Literal::Number(-it))),
|
||||||
Eval::Literal(Literal::Double(it)) => Ok(Eval::Literal(Literal::Double(-it))),
|
|
||||||
_ => Err("Evaluation error: expected numeric type for NEG function.".to_string()),
|
_ => 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 cell;
|
||||||
mod evaluator;
|
mod evaluator;
|
||||||
|
mod grid;
|
||||||
|
mod messages;
|
||||||
mod parser;
|
mod parser;
|
||||||
mod tokenizer;
|
mod tokenizer;
|
||||||
|
|
||||||
use futures_util::{SinkExt, StreamExt, TryStreamExt, future};
|
use futures_util::{SinkExt, StreamExt, TryStreamExt};
|
||||||
use log::info;
|
use log::info;
|
||||||
use std::{env, io::Error};
|
use std::{env, io::Error};
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
|
||||||
use crate::{cell::CellRef, evaluator::Evaluator};
|
use crate::{
|
||||||
|
evaluator::Eval,
|
||||||
|
grid::Grid,
|
||||||
|
messages::{LeadMsg, MsgType},
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Error> {
|
async fn main() -> Result<(), Error> {
|
||||||
env_logger::init();
|
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()
|
let addr = env::args()
|
||||||
.nth(1)
|
.nth(1)
|
||||||
@@ -38,78 +34,8 @@ async fn main() -> Result<(), Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
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) {
|
async fn accept_connection(stream: TcpStream) {
|
||||||
let addr = stream
|
let addr = stream
|
||||||
.peer_addr()
|
.peer_addr()
|
||||||
@@ -125,56 +51,51 @@ async fn accept_connection(stream: TcpStream) {
|
|||||||
let (mut write, mut read) = ws_stream.split();
|
let (mut write, mut read) = ws_stream.split();
|
||||||
|
|
||||||
// Each connection gets its own evaluator
|
// 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) {
|
while let Some(msg) = read.try_next().await.unwrap_or(None) {
|
||||||
if msg.is_text() {
|
if msg.is_text() {
|
||||||
let input = msg.to_text().unwrap_or("").trim().to_string();
|
let input = msg.to_text().unwrap();
|
||||||
|
|
||||||
let cmds = ["set", "get"];
|
if let Ok(req) = serde_json::from_str::<LeadMsg>(&input) {
|
||||||
let cmd = &input[0..3.min(input.len())]; // avoid panic on short strings
|
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
|
let _ = write
|
||||||
.send(format!("ERR invalid command: {}", input).into())
|
.send(serde_json::to_string(&res).unwrap().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())
|
|
||||||
.await;
|
.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 {
|
} else {
|
||||||
let _ = write
|
continue;
|
||||||
.send(format!("ERR invalid cell reference: {}", raw_ref).into())
|
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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> {
|
) -> Result<Expr, String> {
|
||||||
let mut lhs = match input.next() {
|
let mut lhs = match input.next() {
|
||||||
Token::Literal(it) => Expr::Literal(it),
|
Token::Literal(it) => Expr::Literal(it),
|
||||||
Token::Identifier(id) if id == "true" => Expr::Literal(Literal::Boolean(true)),
|
Token::OpenParen => {
|
||||||
Token::Identifier(id) if id == "false" => Expr::Literal(Literal::Boolean(false)),
|
|
||||||
Token::Paren('(') => {
|
|
||||||
let lhs = _parse(input, 0, deps)?;
|
let lhs = _parse(input, 0, deps)?;
|
||||||
if input.next() != Token::Paren(')') {
|
if input.next() != Token::CloseParen {
|
||||||
return Err(format!("Parse error: expected closing paren."));
|
return Err(format!("Parse error: expected closing paren."));
|
||||||
}
|
}
|
||||||
Expr::Group(Box::new(lhs))
|
Expr::Group(Box::new(lhs))
|
||||||
@@ -194,14 +192,14 @@ pub fn _parse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Token::Identifier(id) => match input.peek() {
|
Token::Identifier(id) => match input.peek() {
|
||||||
Token::Paren('(') => {
|
Token::OpenParen => {
|
||||||
input.next();
|
input.next();
|
||||||
|
|
||||||
let mut args: Vec<Expr> = Vec::new();
|
let mut args: Vec<Expr> = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
let nxt = input.peek();
|
let nxt = input.peek();
|
||||||
|
|
||||||
if nxt == Token::Paren(')') {
|
if nxt == Token::CloseParen {
|
||||||
input.next();
|
input.next();
|
||||||
break;
|
break;
|
||||||
} else if nxt != Token::Comma && args.len() != 0 {
|
} else if nxt != Token::Comma && args.len() != 0 {
|
||||||
@@ -286,6 +284,5 @@ pub fn _parse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Ok(lhs)
|
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 {
|
pub enum Literal {
|
||||||
Integer(i64),
|
Number(f64),
|
||||||
Double(f64),
|
|
||||||
Boolean(bool),
|
Boolean(bool),
|
||||||
String(String),
|
String(String),
|
||||||
}
|
}
|
||||||
@@ -11,7 +13,8 @@ pub enum Token {
|
|||||||
Identifier(String), // Could be a function
|
Identifier(String), // Could be a function
|
||||||
Literal(Literal),
|
Literal(Literal),
|
||||||
Operator(char),
|
Operator(char),
|
||||||
Paren(char),
|
OpenParen,
|
||||||
|
CloseParen,
|
||||||
Comma,
|
Comma,
|
||||||
Eof,
|
Eof,
|
||||||
}
|
}
|
||||||
@@ -39,29 +42,38 @@ impl Tokenizer {
|
|||||||
break;
|
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() {
|
} else if c.is_ascii_digit() {
|
||||||
// parse number
|
// parse number
|
||||||
let mut number = String::new();
|
let mut number = String::new();
|
||||||
let mut is_decimal = false;
|
let mut is_decimal = false;
|
||||||
|
let mut is_exp = false;
|
||||||
|
|
||||||
while let Some(&ch) = chars.peek() {
|
while let Some(&ch) = chars.peek() {
|
||||||
if ch.is_ascii_digit() {
|
if ch.is_ascii_digit() {
|
||||||
number.push(ch);
|
number.push(ch);
|
||||||
chars.next();
|
chars.next();
|
||||||
} else if ch == '.' && !is_decimal {
|
} else if ch == '.' && !is_decimal && !is_exp {
|
||||||
is_decimal = true;
|
is_decimal = true;
|
||||||
number.push(ch);
|
number.push(ch);
|
||||||
chars.next();
|
chars.next();
|
||||||
|
} else if ch == 'e' && !is_decimal && !is_exp {
|
||||||
|
is_exp = true;
|
||||||
|
number.push(ch);
|
||||||
|
chars.next();
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if is_decimal {
|
|
||||||
tokens.push(Token::Literal(Literal::Double(number.parse().unwrap())))
|
// TODO: REMOVE UNWRAP
|
||||||
} else {
|
tokens.push(Token::Literal(Literal::Number(number.parse().unwrap())));
|
||||||
tokens.push(Token::Literal(Literal::Integer(number.parse().unwrap())))
|
|
||||||
};
|
|
||||||
} else if c == '"' || c == '\'' {
|
} else if c == '"' || c == '\'' {
|
||||||
// parse string literal
|
// parse string literal
|
||||||
let mut string = String::new();
|
let mut string = String::new();
|
||||||
@@ -89,7 +101,11 @@ impl Tokenizer {
|
|||||||
tokens.push(Token::Operator(c));
|
tokens.push(Token::Operator(c));
|
||||||
chars.next();
|
chars.next();
|
||||||
} else if "()".contains(c) {
|
} else if "()".contains(c) {
|
||||||
tokens.push(Token::Paren(c));
|
if c == '(' {
|
||||||
|
tokens.push(Token::OpenParen);
|
||||||
|
} else {
|
||||||
|
tokens.push(Token::CloseParen);
|
||||||
|
}
|
||||||
chars.next();
|
chars.next();
|
||||||
} else if c == ',' {
|
} else if c == ',' {
|
||||||
tokens.push(Token::Comma);
|
tokens.push(Token::Comma);
|
||||||
@@ -116,20 +132,140 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tokenizer() {
|
fn test_single_token() {
|
||||||
let raw = "hello hello 1.23 this 5 (1+2)";
|
assert_eq!(
|
||||||
let expected: Vec<Token> = vec![
|
Tokenizer::new("1").unwrap().tokens,
|
||||||
Token::Identifier("hello".to_string()),
|
Vec::from([Token::Literal(Literal::Number(1.0))])
|
||||||
Token::Identifier("hello".to_string()),
|
);
|
||||||
Token::Literal(Literal::Double(1.23)),
|
assert_eq!(
|
||||||
Token::Identifier("this".to_string()),
|
Tokenizer::new("2.0").unwrap().tokens,
|
||||||
Token::Literal(Literal::Integer(5)),
|
Vec::from([Token::Literal(Literal::Number(2.0))])
|
||||||
Token::Paren('('),
|
);
|
||||||
Token::Literal(Literal::Integer(1)),
|
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::Operator('+'),
|
||||||
Token::Literal(Literal::Integer(2)),
|
Token::Operator('-'),
|
||||||
Token::Paren(')'),
|
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();
|
let t = Tokenizer::new(&raw).unwrap();
|
||||||
assert_eq!(t.tokens, expected);
|
assert_eq!(t.tokens, expected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,28 @@
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--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 {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
val: string;
|
val: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
resizeable?: boolean;
|
resizeable?: boolean;
|
||||||
direction?: 'col' | 'row';
|
direction?: 'col' | 'row' | 'blank';
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// --- Drag Logic ---
|
// --- Drag Logic ---
|
||||||
@@ -62,7 +62,12 @@
|
|||||||
<div
|
<div
|
||||||
style:width
|
style:width
|
||||||
style:height
|
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">
|
<span class="pointer-events-none flex h-full w-full items-center justify-center select-none">
|
||||||
{val}
|
{val}
|
||||||
@@ -88,20 +93,34 @@
|
|||||||
background-color: var(--color-background);
|
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 {
|
.active {
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
background-color: color-mix(in oklab, var(--color-primary) 80%, var(--color-background) 80%);
|
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 Styles --- */
|
||||||
.resizer {
|
.resizer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
/* Make it easier to grab */
|
|
||||||
z-index: 10;
|
|
||||||
/* Subtle visual cue, becomes more visible on hover */
|
/* Subtle visual cue, becomes more visible on hover */
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.1s ease-in-out;
|
transition: opacity 0.1s ease-in-out;
|
||||||
|
z-index: 60;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for vertical (column) resizing */
|
/* Style for vertical (column) resizing */
|
||||||
@@ -113,7 +132,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Style for horizontal (row) resizing */
|
|
||||||
.resizer-row {
|
.resizer-row {
|
||||||
cursor: row-resize;
|
cursor: row-resize;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
cla = '',
|
||||||
width = '80px',
|
width = '80px',
|
||||||
height = '30px',
|
height = '30px',
|
||||||
raw_val = $bindable(''),
|
raw_val = $bindable(''),
|
||||||
@@ -13,10 +14,11 @@
|
|||||||
active = false,
|
active = false,
|
||||||
editing = false
|
editing = false
|
||||||
}: {
|
}: {
|
||||||
|
cla?: string;
|
||||||
width?: string;
|
width?: string;
|
||||||
height?: string;
|
height?: string;
|
||||||
raw_val?: string;
|
raw_val?: string;
|
||||||
val?: number | string | undefined;
|
val?: LiteralValue | undefined;
|
||||||
onmousedown?: (e: MouseEvent) => void;
|
onmousedown?: (e: MouseEvent) => void;
|
||||||
startediting?: () => void;
|
startediting?: () => void;
|
||||||
stopediting?: () => void;
|
stopediting?: () => void;
|
||||||
@@ -51,9 +53,8 @@
|
|||||||
<div use:autofocusWithin onkeydown={handleKeydown}>
|
<div use:autofocusWithin onkeydown={handleKeydown}>
|
||||||
<Input
|
<Input
|
||||||
style="width: {width}; height: {height}"
|
style="width: {width}; height: {height}"
|
||||||
class="relative rounded-none p-1
|
class="relative rounded-none p-1 !transition-none delay-0 duration-0
|
||||||
!transition-none delay-0 duration-0
|
focus:z-20 focus:shadow-[0_0_0_1px_var(--color-primary)] focus:outline-none"
|
||||||
focus:z-20"
|
|
||||||
onblur={(e) => {
|
onblur={(e) => {
|
||||||
raw_val = (e.target as HTMLInputElement).value;
|
raw_val = (e.target as HTMLInputElement).value;
|
||||||
stopediting();
|
stopediting();
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
{onmousedown}
|
{onmousedown}
|
||||||
style:width
|
style:width
|
||||||
style:height
|
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 !== ''}
|
{#if raw_val !== '' || val !== ''}
|
||||||
<span class="pointer-events-none select-none">
|
<span class="pointer-events-none select-none">
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
|
z-index: 20;
|
||||||
border: 1px solid var(--color-primary);
|
border: 1px solid var(--color-primary);
|
||||||
outline: 1px solid var(--color-primary);
|
outline: 1px solid var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,84 +3,57 @@
|
|||||||
import Cell from '$lib/components/grid/cell.svelte';
|
import Cell from '$lib/components/grid/cell.svelte';
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import CellHeader from './cell-header.svelte';
|
import CellHeader from './cell-header.svelte';
|
||||||
import {
|
import { colToStr, refToStr, type CellT } from './utils';
|
||||||
refFromStr,
|
import clsx from 'clsx';
|
||||||
splitErrorString,
|
|
||||||
colToStr,
|
|
||||||
refFromPos,
|
|
||||||
type CellData,
|
|
||||||
type CellValue
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
socket
|
socket,
|
||||||
|
class: className = ''
|
||||||
}: {
|
}: {
|
||||||
|
class?: string;
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
socket.onmessage = (msg: MessageEvent) => {
|
socket.onmessage = (msg: MessageEvent) => {
|
||||||
const input = msg.data.toString().trim();
|
let res: LeadMsg;
|
||||||
|
|
||||||
if (input.startsWith('ERR')) {
|
try {
|
||||||
let split = splitErrorString(input);
|
res = JSON.parse(msg.data);
|
||||||
toast.error(split[0], {
|
console.log(res);
|
||||||
description: split[1]
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
|
setCellVal(res.cell.row, res.cell.col, res.eval.value);
|
||||||
let strRef: string | undefined;
|
break;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 rows = 50;
|
||||||
let cols = 60;
|
let cols = 40;
|
||||||
|
|
||||||
let default_row_height = '30px';
|
let default_row_height = '30px';
|
||||||
let default_col_width = '80px';
|
let default_col_width = '80px';
|
||||||
|
|
||||||
// Only store touched cells
|
// 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 row_heights: Record<number, string> = $state({});
|
||||||
let col_widths: 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 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) {
|
if (grid_vals[key(i, j)] === undefined) {
|
||||||
console.warn('Cell raw value was undefined but recieved eval.');
|
console.warn('Cell raw value was undefined but recieved eval.');
|
||||||
} else {
|
} 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
|
// 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 {
|
else {
|
||||||
setCellRaw(i, j, v);
|
setCellRaw(row, col, v);
|
||||||
console.log(i, j);
|
|
||||||
socket.send(`set ${refFromPos(i, j).str} ${v}`);
|
let msg: LeadMsg = {
|
||||||
}
|
msg_type: 'set',
|
||||||
|
cell: { row, col },
|
||||||
|
raw: v
|
||||||
};
|
};
|
||||||
|
|
||||||
// $effect(() => {
|
socket.send(JSON.stringify(msg));
|
||||||
// $inspect(grid_vals);
|
}
|
||||||
// });
|
};
|
||||||
|
|
||||||
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
|
function handleCellInteraction(i: number, j: number, e: MouseEvent) {
|
||||||
if (editing_cell) {
|
if (editing_cell) {
|
||||||
@@ -170,7 +145,7 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// --- This is the same reference-inserting logic as before ---
|
// --- This is the same reference-inserting logic as before ---
|
||||||
const ref = refFromPos(i, j).str;
|
const ref = refToStr(i, j);
|
||||||
if (el) {
|
if (el) {
|
||||||
const { selectionStart, selectionEnd } = el;
|
const { selectionStart, selectionEnd } = el;
|
||||||
const before = el.value.slice(0, selectionStart ?? 0);
|
const before = el.value.slice(0, selectionStart ?? 0);
|
||||||
@@ -202,15 +177,18 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid-wrapper relative max-h-[100vh] max-w-full overflow-auto">
|
<div
|
||||||
<div class="sticky top-0 z-40 flex w-fit">
|
class={clsx('grid-wrapper relative h-full min-h-0 max-w-full min-w-0 overflow-auto', className)}
|
||||||
<div class="sticky top-0 left-0 z-50">
|
>
|
||||||
|
<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
|
<CellHeader
|
||||||
resizeable={false}
|
resizeable={false}
|
||||||
height={default_row_height}
|
height={default_row_height}
|
||||||
width={default_col_width}
|
width={default_col_width}
|
||||||
val=""
|
val=""
|
||||||
active={false}
|
active={false}
|
||||||
|
direction="blank"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -226,8 +204,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#each Array(rows) as _, i}
|
{#each Array(rows) as _, i}
|
||||||
<div class="flex w-fit">
|
<div class="relative flex w-fit">
|
||||||
<div class="sticky left-0 z-30 flex w-fit">
|
<div class="sticky left-0 flex w-fit" style="z-index: {rows - i + 40}">
|
||||||
<CellHeader
|
<CellHeader
|
||||||
direction="row"
|
direction="row"
|
||||||
width={default_col_width}
|
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 CellT {
|
||||||
|
|
||||||
export interface CellData {
|
|
||||||
raw_val: string;
|
raw_val: string;
|
||||||
val: CellValue;
|
val: LiteralValue | undefined;
|
||||||
}
|
|
||||||
|
|
||||||
export interface CellRef {
|
|
||||||
row: number;
|
|
||||||
col: number;
|
|
||||||
str: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +18,7 @@ export function refFromStr(ref: string): CellRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const row = parseInt(rowStr, 10);
|
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};
|
* Zero indexed | A1 == {row: 0, col: 0};
|
||||||
*/
|
*/
|
||||||
export function refFromPos(row: number, col: number): CellRef {
|
export function refToStr(row: number, col: number): string {
|
||||||
return { row, col, str: colToStr(col) + (row + 1).toString() };
|
return 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];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ModeWatcher } from 'mode-watcher';
|
||||||
import { Toaster } from '$lib/components/ui/sonner/index.js';
|
import { Toaster } from '$lib/components/ui/sonner/index.js';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
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();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -13,4 +15,10 @@
|
|||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<Sidebar.Provider>
|
||||||
|
<LeadSidebar />
|
||||||
|
|
||||||
|
<main>
|
||||||
{@render children?.()}
|
{@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">
|
<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 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');
|
let socket = new WebSocket('ws://localhost:7050');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- <Button onclick={toggleMode} variant="outline" size="icon"> -->
|
{#if sidebar?.openMobile || sidebar?.open}
|
||||||
<!-- <SunIcon -->
|
<button
|
||||||
<!-- class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90" -->
|
class="fixed inset-0 z-[700] bg-black/40"
|
||||||
<!-- /> -->
|
aria-label="Close sidebar"
|
||||||
<!-- <MoonIcon -->
|
onclick={() => (sidebar.isMobile ? sidebar.setOpenMobile(false) : sidebar.setOpen(false))}
|
||||||
<!-- class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0" -->
|
transition:fade={{ duration: 100 }}
|
||||||
<!-- /> -->
|
></button>
|
||||||
<!-- <span class="sr-only">Toggle theme</span> -->
|
{/if}
|
||||||
<!-- </Button> -->
|
|
||||||
|
|
||||||
<div class="m-0">
|
<div class="absolute left-0 min-h-0 w-full">
|
||||||
<Grid {socket} />
|
<div class="flex h-[100vh] flex-col">
|
||||||
|
<div class="h-[60px] w-full p-3">
|
||||||
|
<Sidebar.Trigger />
|
||||||
</div>
|
</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