diff --git a/Cargo.lock b/Cargo.lock index 0ecd2118..a9f305bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1049,6 +1049,7 @@ dependencies = [ "uuid", "webauthn-rs", "webauthn-rs-proto", + "yrs", ] [[package]] @@ -2556,6 +2557,9 @@ name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +dependencies = [ + "getrandom 0.3.4", +] [[package]] name = "fdeflate" @@ -6907,6 +6911,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallstr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" +dependencies = [ + "smallvec", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -9742,6 +9755,24 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yrs" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cea9488df47b1e7068710c05c7e2506daf4e81e5ff140f58157320396c2ed0a2" +dependencies = [ + "arc-swap", + "async-lock", + "async-trait", + "dashmap", + "fastrand", + "serde", + "serde_json", + "smallstr", + "smallvec", + "thiserror 2.0.18", +] + [[package]] name = "zbus" version = "5.16.0" diff --git a/app/package.json b/app/package.json index 0c2fd606..d37ba4d0 100644 --- a/app/package.json +++ b/app/package.json @@ -14,7 +14,7 @@ "tauri": "tauri" }, "dependencies": { - "@profidev/pleiades": "^1.9.6", + "@profidev/pleiades": "1.9.7", "@sveltejs/enhanced-img": "0.10.4", "@tauri-apps/api": "^2", "@tauri-apps/plugin-barcode-scanner": "^2.4.5", diff --git a/app/src/routes/scan/+page.svelte b/app/src/routes/scan/+page.svelte index 9db7f5d9..b9e80704 100644 --- a/app/src/routes/scan/+page.svelte +++ b/app/src/routes/scan/+page.svelte @@ -22,7 +22,6 @@ const result = await scan({ formats: [Format.QRCode], windowed: true }); const url = new URL(result.content); const code = url.searchParams.get('code'); - toast.success(`Scanned code: ${code}`); goto(`/login?code=${code}`); } catch { toast.error('Failed to scan QR code'); diff --git a/app/src/routes/setup/+page.svelte b/app/src/routes/setup/+page.svelte index 8bcc1616..078d3a7e 100644 --- a/app/src/routes/setup/+page.svelte +++ b/app/src/routes/setup/+page.svelte @@ -61,10 +61,4 @@ }; - + diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 39a6870e..65811794 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -69,3 +69,4 @@ url = { version = "2.5.8", features = ["serde"] } uuid = "1.23.1" webauthn-rs = { version = "0.5.5", features = ["conditional-ui"] } webauthn-rs-proto = "0.5.5" +yrs = { version = "0.27.0", features = ["sync"] } diff --git a/backend/entity/src/entities/mod.rs b/backend/entity/src/entities/mod.rs index caecf161..620e03d0 100644 --- a/backend/entity/src/entities/mod.rs +++ b/backend/entity/src/entities/mod.rs @@ -8,6 +8,8 @@ pub mod group_permission; pub mod group_user; pub mod invalid_jwt; pub mod key; +pub mod note; +pub mod note_user; pub mod o_auth_client; pub mod o_auth_client_additional_redirect_uri; pub mod o_auth_client_group; diff --git a/backend/entity/src/entities/note.rs b/backend/entity/src/entities/note.rs new file mode 100644 index 00000000..38f338ee --- /dev/null +++ b/backend/entity/src/entities/note.rs @@ -0,0 +1,46 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "note")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub title: String, + #[sea_orm(column_type = "VarBinary(StringLen::None)")] + pub content: Vec, + pub preview: String, + pub owner: Uuid, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::note_user::Entity")] + NoteUser, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::Owner", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::NoteUser.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::note_user::Relation::User.def() + } + fn via() -> Option { + Some(super::note_user::Relation::Note.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/entity/src/entities/note_user.rs b/backend/entity/src/entities/note_user.rs new file mode 100644 index 00000000..e599fa83 --- /dev/null +++ b/backend/entity/src/entities/note_user.rs @@ -0,0 +1,46 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "note_user")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub note: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub user: Uuid, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::note::Entity", + from = "Column::Note", + to = "super::note::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Note, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::User", + to = "super::user::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Note.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/backend/entity/src/entities/prelude.rs b/backend/entity/src/entities/prelude.rs index 33fd2b0f..e25f0a96 100644 --- a/backend/entity/src/entities/prelude.rs +++ b/backend/entity/src/entities/prelude.rs @@ -6,6 +6,8 @@ pub use super::group_permission::Entity as GroupPermission; pub use super::group_user::Entity as GroupUser; pub use super::invalid_jwt::Entity as InvalidJwt; pub use super::key::Entity as Key; +pub use super::note::Entity as Note; +pub use super::note_user::Entity as NoteUser; pub use super::o_auth_client::Entity as OAuthClient; pub use super::o_auth_client_additional_redirect_uri::Entity as OAuthClientAdditionalRedirectUri; pub use super::o_auth_client_group::Entity as OAuthClientGroup; diff --git a/backend/entity/src/entities/user.rs b/backend/entity/src/entities/user.rs index c74a5d8a..d34902cc 100644 --- a/backend/entity/src/entities/user.rs +++ b/backend/entity/src/entities/user.rs @@ -22,6 +22,10 @@ pub enum Relation { Apod, #[sea_orm(has_many = "super::group_user::Entity")] GroupUser, + #[sea_orm(has_many = "super::note::Entity")] + Note, + #[sea_orm(has_many = "super::note_user::Entity")] + NoteUser, #[sea_orm(has_many = "super::o_auth_client_user::Entity")] OAuthClientUser, #[sea_orm(has_many = "super::passkey::Entity")] @@ -44,6 +48,12 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::NoteUser.def() + } +} + impl Related for Entity { fn to() -> RelationDef { Relation::OAuthClientUser.def() @@ -77,6 +87,15 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + super::note_user::Relation::Note.def() + } + fn via() -> Option { + Some(super::note_user::Relation::User.def().rev()) + } +} + impl Related for Entity { fn to() -> RelationDef { super::o_auth_client_user::Relation::OAuthClient.def() diff --git a/backend/migration/src/lib.rs b/backend/migration/src/lib.rs index 83fa6f3b..774038b3 100644 --- a/backend/migration/src/lib.rs +++ b/backend/migration/src/lib.rs @@ -9,6 +9,7 @@ mod m20250412_072549_add_oauth_confidential; mod m20250415_162623_user_settings; mod m20260507_102052_user_ext; mod m20260605_071137_oauth_client_pkce; +mod m20260611_120000_create_note_table; pub struct Migrator; @@ -31,6 +32,7 @@ impl MigratorTrait for Migrator { Box::new(m20250415_162623_user_settings::Migration), Box::new(m20260507_102052_user_ext::Migration), Box::new(m20260605_071137_oauth_client_pkce::Migration), + Box::new(m20260611_120000_create_note_table::Migration), ] } } diff --git a/backend/migration/src/m20260611_120000_create_note_table.rs b/backend/migration/src/m20260611_120000_create_note_table.rs new file mode 100644 index 00000000..b2fcc40b --- /dev/null +++ b/backend/migration/src/m20260611_120000_create_note_table.rs @@ -0,0 +1,90 @@ +use centaurus::db::migrations::m3_user::User; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Note::Table) + .if_not_exists() + .col(pk_uuid(Note::Id)) + .col(string(Note::Title)) + .col(binary(Note::Content)) + .col(string(Note::Preview)) + .col(uuid(Note::Owner)) + .foreign_key( + ForeignKey::create() + .from(Note::Table, Note::Owner) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(NoteUser::Table) + .if_not_exists() + .primary_key( + Index::create() + .table(NoteUser::Table) + .col(NoteUser::Note) + .col(NoteUser::User), + ) + .col(uuid(NoteUser::Note)) + .col(uuid(NoteUser::User)) + .foreign_key( + ForeignKey::create() + .from(NoteUser::Table, NoteUser::Note) + .to(Note::Table, Note::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .from(NoteUser::Table, NoteUser::User) + .to(User::Table, User::Id) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(NoteUser::Table) + .table(Note::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Note { + Table, + Id, + Title, + Content, + Preview, + Owner, +} + +#[derive(DeriveIden)] +enum NoteUser { + Table, + Note, + User, +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 6bcf747a..1874b3b2 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,4 +1,5 @@ use centaurus::db::init::Connection; +use notes::NoteTable; use oauth::{ oauth_client::OauthClientTable, oauth_policy::OAuthPolicyTable, oauth_scope::OAuthScopeTable, }; @@ -7,6 +8,7 @@ use user::{passkey::PasskeyTable, settings::SettingsTable}; use crate::db::user::user_ext::UserExtTable; +pub mod notes; pub mod oauth; pub mod services; pub mod user; @@ -19,6 +21,7 @@ pub trait DBTrait { fn oauth_scope(&self) -> OAuthScopeTable<'_>; fn apod(&self) -> ApodTable<'_>; fn settings(&self) -> SettingsTable<'_>; + fn notes(&self) -> NoteTable<'_>; } impl DBTrait for Connection { @@ -49,4 +52,8 @@ impl DBTrait for Connection { fn settings(&self) -> SettingsTable<'_> { SettingsTable::new(&self.0) } + + fn notes(&self) -> NoteTable<'_> { + NoteTable::new(&self.0) + } } diff --git a/backend/src/db/notes.rs b/backend/src/db/notes.rs new file mode 100644 index 00000000..29f8eee6 --- /dev/null +++ b/backend/src/db/notes.rs @@ -0,0 +1,275 @@ +use centaurus::{db::tables::group::SimpleUserInfo, error::Result}; +use entity::{note, note_user, prelude::*, user}; +use schemars::JsonSchema; +use sea_orm::{ + ActiveValue::Set, Condition, ConnectionTrait, IntoActiveModel, TransactionTrait, prelude::*, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, JsonSchema)] +pub struct NoteInfo { + pub id: Uuid, + pub title: String, + pub preview: String, + pub owner: SimpleUserInfo, + pub shared_with: Vec, + pub is_owner: bool, +} + +struct NoteOwnerLink; + +impl Linked for NoteOwnerLink { + type FromEntity = note::Entity; + type ToEntity = user::Entity; + + fn link(&self) -> Vec { + vec![note::Relation::User.def()] + } +} + +pub struct NoteTable<'db> { + db: &'db DatabaseConnection, +} + +impl<'db> NoteTable<'db> { + pub fn new(db: &'db DatabaseConnection) -> Self { + Self { db } + } + + pub async fn has_access(&self, user_id: Uuid, note_id: Uuid) -> Result { + if self.is_owner(user_id, note_id).await? { + return Ok(true); + } + + let count = note_user::Entity::find() + .filter(note_user::Column::Note.eq(note_id)) + .filter(note_user::Column::User.eq(user_id)) + .count(self.db) + .await?; + + Ok(count > 0) + } + + pub async fn shared_users(&self, note_id: Uuid) -> Result> { + let shared_users: Vec = note_user::Entity::find() + .filter(note_user::Column::Note.eq(note_id)) + .all(self.db) + .await? + .into_iter() + .map(|row| row.user) + .collect(); + + Ok(shared_users) + } + + pub async fn is_owner(&self, user_id: Uuid, note_id: Uuid) -> Result { + let count = note::Entity::find() + .filter(note::Column::Id.eq(note_id)) + .filter(note::Column::Owner.eq(user_id)) + .count(self.db) + .await?; + + Ok(count > 0) + } + + pub async fn list_for_user(&self, user_id: Uuid) -> Result> { + let shared_note_ids: Vec = note_user::Entity::find() + .filter(note_user::Column::User.eq(user_id)) + .all(self.db) + .await? + .into_iter() + .map(|row| row.note) + .collect(); + + let notes_with_owners = note::Entity::find() + .filter( + Condition::any() + .add(note::Column::Owner.eq(user_id)) + .add(note::Column::Id.is_in(shared_note_ids)), + ) + .find_also_linked(NoteOwnerLink) + .all(self.db) + .await?; + + let notes: Vec = notes_with_owners + .iter() + .map(|(note, _)| note.clone()) + .collect(); + + let shared_users = notes + .load_many_to_many(user::Entity, note_user::Entity, self.db) + .await?; + + notes_with_owners + .into_iter() + .zip(shared_users) + .map(|((note, owners), shared)| { + let owner = owners.ok_or(DbErr::RecordNotFound("owner not found".into()))?; + Ok(NoteInfo { + id: note.id, + title: note.title, + preview: note.preview, + owner: SimpleUserInfo { + id: owner.id, + name: owner.name, + }, + shared_with: shared + .into_iter() + .map(|u| SimpleUserInfo { + id: u.id, + name: u.name, + }) + .collect(), + is_owner: note.owner == user_id, + }) + }) + .collect::>>() + } + + pub async fn info(&self, note_id: Uuid, user_id: Uuid) -> Result> { + let res = Note::find_by_id(note_id) + .find_also_linked(NoteOwnerLink) + .one(self.db) + .await?; + + let Some((note, owners)) = res else { + return Ok(None); + }; + + let owner = owners.ok_or(DbErr::RecordNotFound("owner not found".into()))?; + + let shared_with = note_user::Entity::find() + .filter(note_user::Column::Note.eq(note_id)) + .find_also_related(user::Entity) + .all(self.db) + .await? + .into_iter() + .filter_map(|(_, user)| { + user.map(|u| SimpleUserInfo { + id: u.id, + name: u.name, + }) + }) + .collect(); + + Ok(Some(NoteInfo { + id: note.id, + title: note.title, + preview: note.preview, + owner: SimpleUserInfo { + id: owner.id, + name: owner.name, + }, + shared_with, + is_owner: note.owner == user_id, + })) + } + + pub async fn create(&self, owner: Uuid, title: String, shared_with: Vec) -> Result { + let id = Uuid::new_v4(); + let txn = self.db.begin().await?; + + note::ActiveModel { + id: Set(id), + title: Set(title), + content: Set(Vec::new()), + preview: Set("".into()), + owner: Set(owner), + } + .insert(&txn) + .await?; + + replace_shared_users(&txn, id, owner, shared_with).await?; + + txn.commit().await?; + + Ok(id) + } + + pub async fn delete(&self, note_id: Uuid) -> Result<()> { + note::Entity::delete_by_id(note_id).exec(self.db).await?; + Ok(()) + } + + pub async fn edit_title(&self, note_id: Uuid, title: String) -> Result<()> { + let mut note: note::ActiveModel = Note::find_by_id(note_id) + .one(self.db) + .await? + .ok_or(DbErr::RecordNotFound("note not found".into()))? + .into(); + + note.title = Set(title); + note.update(self.db).await?; + + Ok(()) + } + + pub async fn set_shared_users( + &self, + note_id: Uuid, + owner: Uuid, + shared_with: Vec, + ) -> Result<()> { + let txn = self.db.begin().await?; + replace_shared_users(&txn, note_id, owner, shared_with).await?; + txn.commit().await?; + Ok(()) + } + + pub async fn set_content(&self, note_id: Uuid, content: Vec, preview: String) -> Result<()> { + let mut note: note::ActiveModel = Note::find_by_id(note_id) + .one(self.db) + .await? + .ok_or(DbErr::RecordNotFound("note not found".into()))? + .into(); + + note.content = Set(content); + note.preview = Set(preview); + note.update(self.db).await?; + + Ok(()) + } + + pub async fn get_content(&self, note_id: Uuid) -> Result> { + let note: note::Model = Note::find_by_id(note_id) + .one(self.db) + .await? + .ok_or(DbErr::RecordNotFound("note not found".into()))?; + + Ok(note.content) + } +} + +async fn replace_shared_users( + conn: &C, + note_id: Uuid, + owner: Uuid, + shared_with: Vec, +) -> Result<()> { + note_user::Entity::delete_many() + .filter(note_user::Column::Note.eq(note_id)) + .exec(conn) + .await?; + + let users: Vec = shared_with.into_iter().filter(|id| *id != owner).collect(); + + if users.is_empty() { + return Ok(()); + } + + let models = users + .into_iter() + .map(|user_id| { + note_user::Model { + note: note_id, + user: user_id, + } + .into_active_model() + }) + .collect::>(); + + note_user::Entity::insert_many(models).exec(conn).await?; + + Ok(()) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 4960d7ff..29fc81f5 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -21,6 +21,7 @@ mod auth; mod cli; mod config; mod db; +mod notes; mod oauth; mod oauth_management; mod services; @@ -54,6 +55,7 @@ fn api_router(rate_limiter: &mut RateLimiter) -> ApiRouter { .nest("/services", services::router()) .nest("/oauth", oauth::router()) .nest("/oauth_management", oauth_management::router()) + .nest("/notes", notes::router()) } async fn state(mut router: ApiRouter, config: Config) -> ApiRouter { @@ -67,6 +69,7 @@ async fn state(mut router: ApiRouter, config: Config) -> ApiRouter { oauth_management::init(&db).await; router = endpoints::user::state(router); + router = notes::state(router); router = auth::state(router, &config, &db).await; router = mail::state(router, &db, &config).await; router = oauth::state(router, &config).await; diff --git a/backend/src/notes/management.rs b/backend/src/notes/management.rs new file mode 100644 index 00000000..fd434351 --- /dev/null +++ b/backend/src/notes/management.rs @@ -0,0 +1,183 @@ +use aide::axum::{ + ApiRouter, + routing::{delete_with, get_with, post_with, put_with}, +}; +use axum::{Json, extract::Path}; +use centaurus::{ + backend::auth::jwt_auth::JwtAuth, + bail, + db::{ + init::Connection, + tables::{ConnectionExt, group::SimpleUserInfo}, + }, + error::Result, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + db::{DBTrait, notes::NoteInfo}, + utils::{UpdateMessage, Updater}, +}; + +pub fn router() -> ApiRouter { + ApiRouter::new() + .api_route("/", get_with(list, |op| op.id("listNotes"))) + .api_route("/", post_with(create, |op| op.id("createNote"))) + .api_route("/", put_with(edit, |op| op.id("editNote"))) + .api_route("/", delete_with(delete, |op| op.id("deleteNote"))) + .api_route("/{uuid}", get_with(info, |op| op.id("infoNote"))) + .api_route("/users", get_with(list_users, |op| op.id("listUsersNote"))) + .api_route("/share", put_with(share, |op| op.id("shareNote"))) +} + +async fn list(auth: JwtAuth, db: Connection) -> Result>> { + Ok(Json(db.notes().list_for_user(auth.user_id).await?)) +} + +#[derive(Deserialize, JsonSchema)] +struct NoteCreateReq { + title: String, + #[serde(default)] + shared_with: Vec, +} + +#[derive(Serialize, JsonSchema)] +struct NoteCreateRes { + id: Uuid, +} + +async fn create( + auth: JwtAuth, + db: Connection, + updater: Updater, + Json(req): Json, +) -> Result> { + let mut users = Vec::with_capacity(req.shared_with.len() + 1); + users.push(auth.user_id); + users.extend_from_slice(&req.shared_with); + + let id = db + .notes() + .create(auth.user_id, req.title, req.shared_with) + .await?; + + notify_note_update(&updater, users, id).await; + + Ok(Json(NoteCreateRes { id })) +} + +#[derive(Deserialize, JsonSchema)] +struct NotePath { + uuid: Uuid, +} + +async fn info( + auth: JwtAuth, + db: Connection, + Path(NotePath { uuid }): Path, +) -> Result> { + if !db.notes().has_access(auth.user_id, uuid).await? { + bail!(NOT_FOUND, "note not found"); + } + + let Some(mut note) = db.notes().info(uuid, auth.user_id).await? else { + bail!(NOT_FOUND, "note not found"); + }; + + note.is_owner = note.owner.id == auth.user_id; + + Ok(Json(note)) +} + +#[derive(Deserialize, JsonSchema)] +struct NoteEditReq { + note_id: Uuid, + title: String, +} + +async fn edit( + auth: JwtAuth, + db: Connection, + updater: Updater, + Json(req): Json, +) -> Result<()> { + if !db.notes().is_owner(auth.user_id, req.note_id).await? { + bail!(FORBIDDEN, "forbidden"); + } + + db.notes().edit_title(req.note_id, req.title).await?; + + let mut users = db.notes().shared_users(req.note_id).await?; + users.push(auth.user_id); + + notify_note_update(&updater, users, req.note_id).await; + + Ok(()) +} + +#[derive(Deserialize, JsonSchema)] +struct NoteDeleteReq { + note_id: Uuid, +} + +async fn delete( + auth: JwtAuth, + db: Connection, + updater: Updater, + Json(req): Json, +) -> Result<()> { + if !db.notes().is_owner(auth.user_id, req.note_id).await? { + bail!(FORBIDDEN, "forbidden"); + } + + let mut users = db.notes().shared_users(req.note_id).await?; + users.push(auth.user_id); + + db.notes().delete(req.note_id).await?; + + notify_note_update(&updater, users, req.note_id).await; + + Ok(()) +} + +#[derive(Deserialize, JsonSchema)] +struct NoteShareReq { + note_id: Uuid, + shared_with: Vec, +} + +async fn share( + auth: JwtAuth, + db: Connection, + updater: Updater, + Json(req): Json, +) -> Result<()> { + if !db.notes().is_owner(auth.user_id, req.note_id).await? { + bail!(FORBIDDEN, "forbidden"); + } + + let mut users = vec![auth.user_id]; + users.extend_from_slice(&req.shared_with); + + db.notes() + .set_shared_users(req.note_id, auth.user_id, req.shared_with) + .await?; + + notify_note_update(&updater, users, req.note_id).await; + + Ok(()) +} + +async fn list_users(_auth: JwtAuth, db: Connection) -> Result>> { + let users = db.user().list_users_simple().await?; + Ok(Json(users)) +} + +async fn notify_note_update(updater: &Updater, users: Vec, note_id: Uuid) { + let message = UpdateMessage::Note { uuid: note_id }; + for user_id in users { + updater.send_to(user_id, message).await; + } +} diff --git a/backend/src/notes/mod.rs b/backend/src/notes/mod.rs new file mode 100644 index 00000000..c285d38f --- /dev/null +++ b/backend/src/notes/mod.rs @@ -0,0 +1,19 @@ +use aide::axum::ApiRouter; +use axum::Extension; + +use crate::notes::state::NoteEditing; + +mod management; +mod preview; +mod state; +mod websocket; + +pub fn router() -> ApiRouter { + ApiRouter::new() + .nest("/management", management::router()) + .nest("/websocket", websocket::router()) +} + +pub fn state(router: ApiRouter) -> ApiRouter { + router.layer(Extension(NoteEditing::init())) +} diff --git a/backend/src/notes/preview.rs b/backend/src/notes/preview.rs new file mode 100644 index 00000000..1c90ec23 --- /dev/null +++ b/backend/src/notes/preview.rs @@ -0,0 +1,61 @@ +use yrs::{AsyncTransact, Doc, GetString, ReadTxn}; + +const PREVIEW_MAX_LENGTH: usize = 500; + +pub async fn render_preview(doc: &Doc) -> String { + let txn = doc.transact().await; + let Some(fragment) = txn.get_xml_fragment("default") else { + return String::new(); + }; + + let content = fragment.get_string(&txn); + xml_to_string(&content) +} + +fn xml_to_string(content: &str) -> String { + let mut result = String::new(); + + let mut in_tag = false; + let mut tag_buffer = String::new(); + let mut is_trimmed = false; + + for c in content.chars() { + if result.chars().count() >= PREVIEW_MAX_LENGTH { + is_trimmed = true; + break; + } + + match c { + '<' => { + in_tag = true; + tag_buffer.clear(); + } + '>' => { + in_tag = false; + if tag_buffer.starts_with("/") + && (tag_buffer.contains("paragraph") + || tag_buffer.contains("heading") + || tag_buffer.contains("li")) + && !result.ends_with(' ') + && !result.is_empty() + { + result.push(' '); + } + } + c => { + if in_tag { + tag_buffer.push(c); + } else { + result.push(c); + } + } + } + } + + let trimmed = result.trim(); + if is_trimmed { + format!("{}...", &trimmed[..PREVIEW_MAX_LENGTH]) + } else { + trimmed.to_string() + } +} diff --git a/backend/src/notes/state.rs b/backend/src/notes/state.rs new file mode 100644 index 00000000..2b1a7dd7 --- /dev/null +++ b/backend/src/notes/state.rs @@ -0,0 +1,249 @@ +use std::{ + sync::{ + Arc, + atomic::{AtomicIsize, AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use axum::{ + Extension, + body::Bytes, + extract::{ + FromRequestParts, + ws::{Message, WebSocket}, + }, +}; +use centaurus::{db::init::Connection, error::Result, eyre::Context}; +use dashmap::DashMap; +use image::EncodableLayout; +use tokio::{ + spawn, + sync::{ + Mutex, + broadcast::{Receiver, Sender, channel}, + mpsc, + }, + time::sleep, +}; +use uuid::Uuid; +use yrs::{ + AsyncTransact, Doc, ReadTxn, StateVector, Subscription, Update, + encoding::write::Write, + sync::{ + Awareness, DefaultProtocol, + protocol::{AsyncProtocol, MSG_SYNC, MSG_SYNC_UPDATE}, + }, + updates::{ + decoder::Decode, + encoder::{Encode, Encoder, EncoderV1}, + }, +}; + +use crate::{db::DBTrait, notes::preview::render_preview}; + +const MB: usize = 1024 * 1024; + +#[derive(Clone, FromRequestParts)] +#[from_request(via(Extension))] +pub struct NoteEditing { + docs: Arc>>, +} + +pub struct NoteState { + doc: Arc>, + sender: Sender, + #[allow(dead_code)] + doc_subscription: Subscription, + #[allow(dead_code)] + awareness_subscription: Subscription, + subscriber_count: AtomicUsize, + save_counter: AtomicIsize, +} + +impl NoteEditing { + pub fn init() -> Self { + Self { + docs: Arc::new(DashMap::new()), + } + } + + pub async fn get_or_open_note(&self, note_id: Uuid, db: &Connection) -> Result> { + if let Some(state) = self.docs.get(¬e_id) { + state.subscriber_count.fetch_add(1, Ordering::Relaxed); + return Ok(state.clone()); + } + + let content = db.notes().get_content(note_id).await?; + + let doc = Doc::new(); + if !content.is_empty() { + doc + .transact_mut() + .await + .apply_update(Update::decode_v1(&content).context("failed to decode note content")?) + .context("failed to apply update")?; + } + + let (sender, _) = channel(10); + let doc_subscription = doc + .observe_update_v1({ + let sender = sender.clone(); + + move |_txn, update| { + let mut encoder = EncoderV1::new(); + encoder.write_var(MSG_SYNC); + encoder.write_var(MSG_SYNC_UPDATE); + encoder.write_buf(&update.update); + let _ = sender.send(Message::Binary(Bytes::from_owner(encoder.to_vec()))); + } + }) + .context("failed to observe update")?; + + let mut awareness = Awareness::new(doc); + let (awareness_sender, mut awareness_receiver) = mpsc::channel(10); + let awareness_subscription = awareness.on_update(move |_awareness, event, _origin| { + let changes = event.all_changes(); + let _ = awareness_sender.try_send(changes); + }); + + let doc_arc = Arc::new(Mutex::new(awareness)); + + spawn({ + let sender = sender.clone(); + let doc_arc = doc_arc.clone(); + async move { + while let Some(changes) = awareness_receiver.recv().await { + let awareness = doc_arc.lock().await; + let Ok(upgrade) = awareness.update_with_clients(changes) else { + tracing::warn!("failed to update with clients"); + continue; + }; + + let payload = yrs::sync::Message::Awareness(upgrade).encode_v1(); + let _ = sender.send(Message::Binary(Bytes::from_owner(payload))); + } + } + }); + + let state = Arc::new(NoteState { + doc: doc_arc, + subscriber_count: AtomicUsize::new(1), + save_counter: AtomicIsize::new(0), + doc_subscription, + awareness_subscription, + sender, + }); + + state.clone().start_save_task(db.clone(), note_id); + + self.docs.insert(note_id, state.clone()); + + Ok(state) + } + + pub async fn close_note(&self, note_id: Uuid, db: &Connection) -> Result<()> { + let Some(state) = self.docs.get(¬e_id) else { + return Ok(()); + }; + + if state.subscriber_count.load(Ordering::Relaxed) > 1 { + state.subscriber_count.fetch_sub(1, Ordering::Relaxed); + return Ok(()); + } + drop(state); + + let Some((_, state)) = self.docs.remove(¬e_id) else { + return Ok(()); + }; + + state.save(db, note_id).await?; + state.save_counter.store(-1, Ordering::Relaxed); + + Ok(()) + } +} + +impl NoteState { + pub fn receiver(&self) -> Receiver { + self.sender.subscribe() + } + + pub async fn init_protocol(&self, ws: &mut WebSocket) -> Result<()> { + let awareness = self.doc.lock().await; + let msgs = DefaultProtocol + .start::(&awareness) + .await + .context("failed to start protocol")?; + drop(awareness); + + for msg in msgs { + let payload = msg.encode_v1(); + ws.send(Message::Binary(Bytes::from_owner(payload))) + .await + .context("failed to send message")?; + } + + Ok(()) + } + + pub async fn handle_message(&self, msg: Message, ws: &mut WebSocket) { + let Message::Binary(data) = msg else { + return; + }; + + let mut awareness = self.doc.lock().await; + let Ok(res) = DefaultProtocol + .handle(&mut awareness, data.as_bytes()) + .await + else { + return; + }; + drop(awareness); + + self.save_counter.fetch_add(1, Ordering::Relaxed); + + for msg in res { + let payload = msg.encode_v1(); + if let Err(e) = ws.send(Message::Binary(Bytes::from_owner(payload))).await { + tracing::warn!("failed to send message: {}", e); + } + } + } + + pub async fn save(&self, db: &Connection, note_id: Uuid) -> Result<()> { + let awareness = self.doc.lock().await; + let doc = awareness.doc(); + doc.transact_mut().await.gc(None); + let content = doc + .transact() + .await + .encode_state_as_update_v1(&StateVector::default()); + + if content.len() > MB * 10 { + tracing::warn!("content size exceeds 10MB: {}", content.len()); + return Ok(()); + } + + let preview = render_preview(doc).await; + db.notes().set_content(note_id, content, preview).await?; + + Ok(()) + } + + pub fn start_save_task(self: Arc, db: Connection, note_id: Uuid) { + spawn(async move { + loop { + let count = self.save_counter.swap(0, Ordering::Relaxed); + if count > 0 { + self.save(&db, note_id).await.ok(); + } else if count < 0 { + drop(self); + return; + } + + sleep(Duration::from_secs(10)).await; + } + }); + } +} diff --git a/backend/src/notes/websocket.rs b/backend/src/notes/websocket.rs new file mode 100644 index 00000000..da6754e9 --- /dev/null +++ b/backend/src/notes/websocket.rs @@ -0,0 +1,80 @@ +use aide::axum::ApiRouter; +use axum::{ + extract::{ + Path, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + response::Response, + routing::get, +}; +use centaurus::{backend::auth::jwt_auth::JwtAuth, bail, db::init::Connection, error::Result}; +use schemars::JsonSchema; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{db::DBTrait, notes::state::NoteEditing}; + +pub fn router() -> ApiRouter { + ApiRouter::new().route("/{uuid}", get(notes_websocket)) +} + +#[derive(Deserialize, JsonSchema)] +struct NotePath { + uuid: Uuid, +} + +async fn notes_websocket( + auth: JwtAuth, + state: NoteEditing, + Path(NotePath { uuid }): Path, + db: Connection, + ws: WebSocketUpgrade, +) -> Result { + if !db.notes().has_access(auth.user_id, uuid).await? { + bail!(NOT_FOUND, "note not found"); + } + + Ok(ws.on_upgrade(move |ws| handle_socket(ws, state, db, uuid))) +} + +async fn handle_socket(mut ws: WebSocket, state: NoteEditing, db: Connection, note_id: Uuid) { + let doc_state = match state.get_or_open_note(note_id, &db).await { + Ok(arc) => arc, + Err(e) => { + tracing::warn!("failed to get or open note: {}", e); + return; + } + }; + + if let Err(e) = doc_state.init_protocol(&mut ws).await { + tracing::warn!("failed to init protocol: {}", e); + return; + } + + let mut receiver = doc_state.receiver(); + + loop { + tokio::select! { + msg = ws.recv() => { + match msg { + Some(Ok(Message::Close(_)) | Err(_)) | None => break, + Some(Ok(msg)) => { + doc_state.handle_message(msg, &mut ws).await; + } + } + } + msg = receiver.recv() => { + let Ok(msg) = msg else { + break; + }; + if let Err(e) = ws.send(msg).await { + tracing::warn!("failed to send message: {}", e); + } + } + } + } + + if let Err(e) = state.close_note(note_id, &db).await { + tracing::warn!("failed to close note: {}", e); + } +} diff --git a/backend/src/utils.rs b/backend/src/utils.rs index 05d71655..818d3be8 100644 --- a/backend/src/utils.rs +++ b/backend/src/utils.rs @@ -38,6 +38,9 @@ pub enum UpdateMessage { OAuthPolicy { uuid: Uuid, }, + Note { + uuid: Uuid, + }, } pub fn generate_secret() -> String { diff --git a/frontend/package.json b/frontend/package.json index 226ebebf..5ac75748 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,22 +14,36 @@ "api": "openapi-ts" }, "dependencies": { - "@profidev/pleiades": "^1.9.6", - "@simplewebauthn/browser": "^13.3.0", + "@profidev/pleiades": "1.9.7", + "@simplewebauthn/browser": "13.3.0", "@sveltejs/enhanced-img": "0.10.4", + "@tiptap/core": "3.26.1", + "@tiptap/extension-bubble-menu": "3.26.1", + "@tiptap/extension-character-count": "3.26.1", + "@tiptap/extension-collaboration": "3.26.1", + "@tiptap/extension-collaboration-caret": "3.26.1", + "@tiptap/extension-highlight": "3.26.1", + "@tiptap/extension-text-align": "3.26.1", + "@tiptap/extension-text-style": "3.26.1", + "@tiptap/extension-typography": "3.26.1", + "@tiptap/pm": "3.26.1", + "@tiptap/starter-kit": "3.26.1", "jsencrypt": "3.5.4", - "qrcode": "^1.5.4", - "valibot": "^1.2.0" + "qrcode": "1.5.4", + "svelte-tiptap": "3.0.1", + "valibot": "1.2.0", + "y-websocket": "3.0.0", + "yjs": "13.6.31" }, "devDependencies": { "@fontsource-variable/geist": "5.2.9", - "@hey-api/openapi-ts": "0.98.1", + "@hey-api/openapi-ts": "0.98.2", "@lucide/svelte": "1.17.0", "@sveltejs/adapter-node": "5.5.4", - "@sveltejs/kit": "2.63.0", + "@sveltejs/kit": "2.65.0", "@sveltejs/vite-plugin-svelte": "7.1.2", "@tailwindcss/vite": "4.3.0", - "@types/node": "25.9.1", + "@types/node": "25.9.3", "@types/qrcode": "^1.5.6", "shadcn-svelte": "1.3.0", "svelte": "5.56.1", diff --git a/frontend/src/lib/backend/updater.svelte.ts b/frontend/src/lib/backend/updater.svelte.ts index c657bf8c..395d701b 100644 --- a/frontend/src/lib/backend/updater.svelte.ts +++ b/frontend/src/lib/backend/updater.svelte.ts @@ -13,7 +13,8 @@ export enum UpdateType { OAuthScope = 'OAuthScope', OAuthPolicy = 'OAuthPolicy', Passkey = 'Passkey', - Apod = 'Apod' + Apod = 'Apod', + Note = 'Note' } export type UpdateMessage = @@ -23,7 +24,8 @@ export type UpdateMessage = | UpdateType.Group | UpdateType.OAuthClient | UpdateType.OAuthScope - | UpdateType.OAuthPolicy; + | UpdateType.OAuthPolicy + | UpdateType.Note; uuid: string; } | { @@ -51,6 +53,7 @@ const handleMessage = (msg: UpdateMessage, user: string) => { invalidate('/api/group/users').catch(() => {}); invalidate('/api/oauth_management/client/users').catch(() => {}); invalidate(`/api/user/info/avatar/${msg.uuid}`).catch(() => {}); + invalidate(`/api/notes/management/users`).catch(() => {}); // Same as current user if (msg.uuid === user) { invalidate('/api/user/info').catch(() => {}); @@ -90,6 +93,12 @@ const handleMessage = (msg: UpdateMessage, user: string) => { case UpdateType.Apod: { invalidate('/api/services/apod').catch(() => {}); invalidate('/api/services/apod/get_image_info').catch(() => {}); + break; + } + case UpdateType.Note: { + invalidate('/api/notes/management').catch(() => {}); + invalidate(`/api/notes/management/${msg.uuid}`).catch(() => {}); + break; } default: { break; diff --git a/frontend/src/lib/client/index.ts b/frontend/src/lib/client/index.ts index e47d208e..066228c2 100644 --- a/frontend/src/lib/client/index.ts +++ b/frontend/src/lib/client/index.ts @@ -11,16 +11,19 @@ export { confirmEmailChange, convertOidcUser, createGroup, + createNote, createOauthClient, createOAuthPolicy, createOAuthScope, createUser, deleteGroup, + deleteNote, deleteOauthClient, deleteOAuthPolicy, deleteOAuthScope, deleteUser, editGroup, + editNote, editOauthClient, editOAuthPolicy, editOAuthScope, @@ -36,6 +39,7 @@ export { getOidcSettings, groupInfo, info, + infoNote, infoOauthClient, infoOAuthPolicy, infoOAuthScope, @@ -47,6 +51,7 @@ export { listGroupsOAuthClient, listGroupsOAuthPolicy, listGroupsSimple, + listNotes, listOauthClients, listOAuthPolicies, listOAuthScopes, @@ -54,6 +59,7 @@ export { listPoliciesOAuthScope, listScopesOAuthClient, listUsers, + listUsersNote, listUsersOAuthClient, listUsersSimple, logout, @@ -73,6 +79,7 @@ export { saveMailSettings, sendResetLink, setGoodApod, + shareNote, siteUrl, startAuthentication, startEmailChange, @@ -145,6 +152,11 @@ export type { CreateGroupRequest, CreateGroupResponse, CreateGroupResponses, + CreateNoteData, + CreateNoteError, + CreateNoteErrors, + CreateNoteResponse, + CreateNoteResponses, CreateOauthClientData, CreateOauthClientError, CreateOauthClientErrors, @@ -177,6 +189,10 @@ export type { DeleteGroupErrors, DeleteGroupRequest, DeleteGroupResponses, + DeleteNoteData, + DeleteNoteError, + DeleteNoteErrors, + DeleteNoteResponses, DeleteOauthClientData, DeleteOauthClientError, DeleteOauthClientErrors, @@ -202,6 +218,10 @@ export type { EditGroupErrors, EditGroupRequest, EditGroupResponses, + EditNoteData, + EditNoteError, + EditNoteErrors, + EditNoteResponses, EditOauthClientData, EditOauthClientError, EditOauthClientErrors, @@ -267,6 +287,10 @@ export type { GroupViewPath, InfoData, InfoErrors, + InfoNoteData, + InfoNoteErrors, + InfoNoteResponse, + InfoNoteResponses, InfoOauthClientData, InfoOauthClientErrors, InfoOauthClientResponse, @@ -315,6 +339,10 @@ export type { ListGroupsSimpleErrors, ListGroupsSimpleResponse, ListGroupsSimpleResponses, + ListNotesData, + ListNotesErrors, + ListNotesResponse, + ListNotesResponses, ListOauthClientsData, ListOauthClientsErrors, ListOauthClientsResponse, @@ -341,6 +369,10 @@ export type { ListScopesOAuthClientResponses, ListUsersData, ListUsersErrors, + ListUsersNoteData, + ListUsersNoteErrors, + ListUsersNoteResponse, + ListUsersNoteResponses, ListUsersOAuthClientData, ListUsersOAuthClientErrors, ListUsersOAuthClientResponse, @@ -361,6 +393,13 @@ export type { MailActiveResponses, MailSettings, MailSettingsResponse, + NoteCreateReq, + NoteCreateRes, + NoteDeleteReq, + NoteEditReq, + NoteInfo, + NotePath, + NoteShareReq, OAuthClientEditReq, OAuthClientInfo, OAuthClientPath, @@ -437,6 +476,10 @@ export type { SetGoodReq, SettingsInfo, SetupPayload, + ShareNoteData, + ShareNoteError, + ShareNoteErrors, + ShareNoteResponses, SimpleGroupInfo, SimpleOAuthPolicyInfo, SimpleOAuthScopeInfo, diff --git a/frontend/src/lib/client/sdk.gen.ts b/frontend/src/lib/client/sdk.gen.ts index 9d655e08..ef771e15 100644 --- a/frontend/src/lib/client/sdk.gen.ts +++ b/frontend/src/lib/client/sdk.gen.ts @@ -36,6 +36,9 @@ import type { CreateGroupData, CreateGroupErrors, CreateGroupResponses, + CreateNoteData, + CreateNoteErrors, + CreateNoteResponses, CreateOauthClientData, CreateOauthClientErrors, CreateOauthClientResponses, @@ -51,6 +54,9 @@ import type { DeleteGroupData, DeleteGroupErrors, DeleteGroupResponses, + DeleteNoteData, + DeleteNoteErrors, + DeleteNoteResponses, DeleteOauthClientData, DeleteOauthClientErrors, DeleteOauthClientResponses, @@ -66,6 +72,9 @@ import type { EditGroupData, EditGroupErrors, EditGroupResponses, + EditNoteData, + EditNoteErrors, + EditNoteResponses, EditOauthClientData, EditOauthClientErrors, EditOauthClientResponses, @@ -105,6 +114,9 @@ import type { GroupInfoResponses, InfoData, InfoErrors, + InfoNoteData, + InfoNoteErrors, + InfoNoteResponses, InfoOauthClientData, InfoOauthClientErrors, InfoOauthClientResponses, @@ -138,6 +150,9 @@ import type { ListGroupsSimpleData, ListGroupsSimpleErrors, ListGroupsSimpleResponses, + ListNotesData, + ListNotesErrors, + ListNotesResponses, ListOauthClientsData, ListOauthClientsErrors, ListOauthClientsResponses, @@ -158,6 +173,9 @@ import type { ListScopesOAuthClientResponses, ListUsersData, ListUsersErrors, + ListUsersNoteData, + ListUsersNoteErrors, + ListUsersNoteResponses, ListUsersOAuthClientData, ListUsersOAuthClientErrors, ListUsersOAuthClientResponses, @@ -208,6 +226,9 @@ import type { SetGoodApodData, SetGoodApodErrors, SetGoodApodResponses, + ShareNoteData, + ShareNoteErrors, + ShareNoteResponses, SiteUrlData, SiteUrlErrors, SiteUrlResponses, @@ -1487,3 +1508,94 @@ export const listGroupsOAuthPolicy = ( ListGroupsOAuthPolicyErrors, ThrowOnError >({ url: '/api/oauth_management/policy/groups', ...options }); + +export const deleteNote = ( + options: Options +): RequestResult => + (options.client ?? client).delete< + DeleteNoteResponses, + DeleteNoteErrors, + ThrowOnError + >({ + url: '/api/notes/management', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + +export const listNotes = ( + options?: Options +): RequestResult => + (options?.client ?? client).get< + ListNotesResponses, + ListNotesErrors, + ThrowOnError + >({ url: '/api/notes/management', ...options }); + +export const createNote = ( + options: Options +): RequestResult => + (options.client ?? client).post< + CreateNoteResponses, + CreateNoteErrors, + ThrowOnError + >({ + url: '/api/notes/management', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + +export const editNote = ( + options: Options +): RequestResult => + (options.client ?? client).put< + EditNoteResponses, + EditNoteErrors, + ThrowOnError + >({ + url: '/api/notes/management', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + +export const infoNote = ( + options: Options +): RequestResult => + (options.client ?? client).get< + InfoNoteResponses, + InfoNoteErrors, + ThrowOnError + >({ url: '/api/notes/management/{uuid}', ...options }); + +export const listUsersNote = ( + options?: Options +): RequestResult => + (options?.client ?? client).get< + ListUsersNoteResponses, + ListUsersNoteErrors, + ThrowOnError + >({ url: '/api/notes/management/users', ...options }); + +export const shareNote = ( + options: Options +): RequestResult => + (options.client ?? client).put< + ShareNoteResponses, + ShareNoteErrors, + ThrowOnError + >({ + url: '/api/notes/management/share', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); diff --git a/frontend/src/lib/client/types.gen.ts b/frontend/src/lib/client/types.gen.ts index 7303cf26..202260a1 100644 --- a/frontend/src/lib/client/types.gen.ts +++ b/frontend/src/lib/client/types.gen.ts @@ -238,6 +238,42 @@ export type MailSettingsResponse = { settings: MailSettings; }; +export type NoteCreateReq = { + shared_with?: Array; + title: string; +}; + +export type NoteCreateRes = { + id: string; +}; + +export type NoteDeleteReq = { + note_id: string; +}; + +export type NoteEditReq = { + note_id: string; + title: string; +}; + +export type NoteInfo = { + id: string; + is_owner: boolean; + owner: SimpleUserInfo; + preview: string; + shared_with: Array; + title: string; +}; + +export type NotePath = { + uuid: string; +}; + +export type NoteShareReq = { + note_id: string; + shared_with: Array; +}; + export type OAuthClientEditReq = { additional_redirect_uris: Array; client_id: string; @@ -3123,3 +3159,233 @@ export type ListGroupsOAuthPolicyResponses = { export type ListGroupsOAuthPolicyResponse = ListGroupsOAuthPolicyResponses[keyof ListGroupsOAuthPolicyResponses]; + +export type DeleteNoteData = { + body: NoteDeleteReq; + path?: never; + query?: never; + url: '/api/notes/management'; +}; + +export type DeleteNoteErrors = { + /** + * Failed to parse the request body as JSON + */ + 400: string; + /** + * Expected request with `Content-Type: application/json` + */ + 415: string; + /** + * Failed to deserialize the JSON body into the target type + */ + 422: string; + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type DeleteNoteError = DeleteNoteErrors[keyof DeleteNoteErrors]; + +export type DeleteNoteResponses = { + /** + * no content + */ + 200: unknown; +}; + +export type ListNotesData = { + body?: never; + path?: never; + query?: never; + url: '/api/notes/management'; +}; + +export type ListNotesErrors = { + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type ListNotesResponses = { + 200: Array; +}; + +export type ListNotesResponse = ListNotesResponses[keyof ListNotesResponses]; + +export type CreateNoteData = { + body: NoteCreateReq; + path?: never; + query?: never; + url: '/api/notes/management'; +}; + +export type CreateNoteErrors = { + /** + * Failed to parse the request body as JSON + */ + 400: string; + /** + * Expected request with `Content-Type: application/json` + */ + 415: string; + /** + * Failed to deserialize the JSON body into the target type + */ + 422: string; + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type CreateNoteError = CreateNoteErrors[keyof CreateNoteErrors]; + +export type CreateNoteResponses = { + 200: NoteCreateRes; +}; + +export type CreateNoteResponse = CreateNoteResponses[keyof CreateNoteResponses]; + +export type EditNoteData = { + body: NoteEditReq; + path?: never; + query?: never; + url: '/api/notes/management'; +}; + +export type EditNoteErrors = { + /** + * Failed to parse the request body as JSON + */ + 400: string; + /** + * Expected request with `Content-Type: application/json` + */ + 415: string; + /** + * Failed to deserialize the JSON body into the target type + */ + 422: string; + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type EditNoteError = EditNoteErrors[keyof EditNoteErrors]; + +export type EditNoteResponses = { + /** + * no content + */ + 200: unknown; +}; + +export type InfoNoteData = { + body?: never; + path: { + uuid: string; + }; + query?: never; + url: '/api/notes/management/{uuid}'; +}; + +export type InfoNoteErrors = { + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type InfoNoteResponses = { + 200: NoteInfo; +}; + +export type InfoNoteResponse = InfoNoteResponses[keyof InfoNoteResponses]; + +export type ListUsersNoteData = { + body?: never; + path?: never; + query?: never; + url: '/api/notes/management/users'; +}; + +export type ListUsersNoteErrors = { + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type ListUsersNoteResponses = { + 200: Array; +}; + +export type ListUsersNoteResponse = + ListUsersNoteResponses[keyof ListUsersNoteResponses]; + +export type ShareNoteData = { + body: NoteShareReq; + path?: never; + query?: never; + url: '/api/notes/management/share'; +}; + +export type ShareNoteErrors = { + /** + * Failed to parse the request body as JSON + */ + 400: string; + /** + * Expected request with `Content-Type: application/json` + */ + 415: string; + /** + * Failed to deserialize the JSON body into the target type + */ + 422: string; + /** + * An error occurred + */ + '4XX': unknown; + /** + * An error occurred + */ + '5XX': unknown; +}; + +export type ShareNoteError = ShareNoteErrors[keyof ShareNoteErrors]; + +export type ShareNoteResponses = { + /** + * no content + */ + 200: unknown; +}; diff --git a/frontend/src/lib/components/UserAvatar.svelte b/frontend/src/lib/components/UserAvatar.svelte new file mode 100644 index 00000000..199a310f --- /dev/null +++ b/frontend/src/lib/components/UserAvatar.svelte @@ -0,0 +1,28 @@ + + + + + {username ? initials(username) : '?'} + diff --git a/frontend/src/lib/components/nav.svelte.ts b/frontend/src/lib/components/nav.svelte.ts index 9f65bf66..c48550f0 100644 --- a/frontend/src/lib/components/nav.svelte.ts +++ b/frontend/src/lib/components/nav.svelte.ts @@ -7,6 +7,7 @@ import KeyRound from '@lucide/svelte/icons/key-round'; import Goal from '@lucide/svelte/icons/goal'; import UserKey from '@lucide/svelte/icons/user-key'; import Telescope from '@lucide/svelte/icons/telescope'; +import NotepadText from '@lucide/svelte/icons/notepad-text'; import type { NavGroup } from '@profidev/pleiades/components/nav/sidebar/types'; export const items: NavGroup[] = [ @@ -16,6 +17,11 @@ export const items: NavGroup[] = [ }, { items: [ + { + href: '/notes', + icon: NotepadText, + label: 'Notes' + }, { href: '/apod', icon: Telescope, diff --git a/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte b/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte new file mode 100644 index 00000000..6db73117 --- /dev/null +++ b/frontend/src/lib/components/notes/NoteActiveEditorsIndicator.svelte @@ -0,0 +1,81 @@ + + +{#if editors.length > 0} + + +
+ {#each editors.slice(0, 4) as editor, index (editor.clientId)} +
+ +
+ {/each} + {#if extraCount > 0} +
+ +{extraCount} +
+ {/if} +
+ +
+ +

+ Currently editing +

+
    + {#each editors as editor (editor.clientId)} +
  • + + {editor.name} + {#if editor.color} + + {/if} +
  • + {/each} +
+
+
+{/if} diff --git a/frontend/src/lib/components/notes/NoteShareControl.svelte b/frontend/src/lib/components/notes/NoteShareControl.svelte new file mode 100644 index 00000000..be6dd5d3 --- /dev/null +++ b/frontend/src/lib/components/notes/NoteShareControl.svelte @@ -0,0 +1,124 @@ + + +{#if readonly} +
+ {#if selected.length > 0} +
+ {#each selected.slice(0, 4) as user (user.id)} + + {/each} +
+ + {:else} + + + {/if} +
+{:else} + + + {#if selected.length > 0} +
+ {#each selected.slice(0, 4) as user (user.id)} + + {/each} + {#if extraCount > 0} +
+ +{extraCount} +
+ {/if} +
+ + {:else} + + + {/if} +
+ + + + + + No people found + {#each shareableUsers as user (user.id)} + toggleUser(user.id)} + class="[&_svg.cn-command-item-indicator]:hidden!" + > + + + {user.name} + + + + {/each} + + + + +
+{/if} diff --git a/frontend/src/lib/components/notes/types.ts b/frontend/src/lib/components/notes/types.ts new file mode 100644 index 00000000..f0dc1f14 --- /dev/null +++ b/frontend/src/lib/components/notes/types.ts @@ -0,0 +1,6 @@ +export interface NoteActiveEditor { + clientId: number; + id?: string; + name: string; + color?: string; +} diff --git a/frontend/src/lib/components/tiptap/TipTab.svelte b/frontend/src/lib/components/tiptap/TipTab.svelte new file mode 100644 index 00000000..c795b366 --- /dev/null +++ b/frontend/src/lib/components/tiptap/TipTab.svelte @@ -0,0 +1,136 @@ + + + + +{#if editorState.editor} +
+ {/* @ts-ignore */ null} + + + + +
+{/if} diff --git a/frontend/src/lib/components/tiptap/config.ts b/frontend/src/lib/components/tiptap/config.ts new file mode 100644 index 00000000..722c8b4d --- /dev/null +++ b/frontend/src/lib/components/tiptap/config.ts @@ -0,0 +1,56 @@ +import TextAlign from '@tiptap/extension-text-align'; +import { Color, TextStyle } from '@tiptap/extension-text-style'; +import { Highlight } from '@tiptap/extension-highlight'; +import Typography from '@tiptap/extension-typography'; +import StarterKit from '@tiptap/starter-kit'; +import type { Extensions } from '@tiptap/core'; +import SearchAndReplace from './extensions/search-and-replace'; +import CharacterCount from '@tiptap/extension-character-count'; + +export const extensions = [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: 'list-disc' + } + }, + heading: { + levels: [1, 2, 3, 4] + }, + orderedList: { + HTMLAttributes: { + class: 'list-decimal' + } + }, + undoRedo: false + }), + TextAlign.configure({ + types: ['heading', 'paragraph'] + }), + TextStyle, + Color, + Highlight.configure({ + multicolor: true + }), + SearchAndReplace, + Typography, + CharacterCount.configure({ + autoTrim: true, + limit: 50_000, + mode: 'nodeSize' + }) +] satisfies Extensions; + +const AVATAR_COLORS = [ + '#958DF1', + '#F98181', + '#FBBC88', + '#FAF594', + '#70CFF8', + '#94FADB', + '#B9F18D', + '#FF85A2' +]; + +export const getRandomColor = () => + AVATAR_COLORS[Math.floor(Math.random() * AVATAR_COLORS.length)]; diff --git a/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts new file mode 100644 index 00000000..d1588085 --- /dev/null +++ b/frontend/src/lib/components/tiptap/extensions/search-and-replace.ts @@ -0,0 +1,499 @@ +import { type Editor as CoreEditor, Extension, type Range } from '@tiptap/core'; +import type { Node as PMNode } from '@tiptap/pm/model'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; + +export interface SearchAndReplaceStorage { + searchTerm: string; + replaceTerm: string; + results: Range[]; + lastSearchTerm: string; + selectedResult: number; + lastSelectedResult: number; + caseSensitive: boolean; + lastCaseSensitiveState: boolean; + useRegex: boolean; + lastUseRegexState: boolean; +} + +declare module '@tiptap/core' { + interface Storage { + searchAndReplace: SearchAndReplaceStorage; + } + + interface Commands { + search: { + /** + * @description Set search term in extension. + */ + setSearchTerm: (searchTerm: string) => ReturnType; + /** + * @description Set replace term in extension. + */ + setReplaceTerm: (replaceTerm: string) => ReturnType; + /** + * @description Replace first instance of search result with given replace term. + */ + replace: () => ReturnType; + /** + * @description Replace all instances of search result with given replace term. + */ + replaceAll: () => ReturnType; + /** + * @description Select the next search result. + */ + selectNextResult: () => ReturnType; + /** + * @description Select the previous search result. + */ + selectPreviousResult: () => ReturnType; + /** + * @description Set case sensitivity in extension. + */ + setCaseSensitive: (caseSensitive: boolean) => ReturnType; + /** + * @description Set regex search mode in extension. + */ + setUseRegex: (useRegex: boolean) => ReturnType; + }; + } +} + +interface TextNodeWithPosition { + text: string; + pos: number; +} + +const getRegex = ( + searchString: string, + disableRegex: boolean, + caseSensitive: boolean +): RegExp => { + const escapedString = disableRegex + ? searchString.replace(/[-/\\^$*+?.()|[\]{}]/g, String.raw`\$&`) + : searchString; + return new RegExp(escapedString, caseSensitive ? 'gu' : 'gui'); +}; + +export const isValidSearchPattern = ( + searchString: string, + useRegex: boolean, + caseSensitive: boolean +): boolean => { + if (!searchString || !useRegex) { + return true; + } + + try { + getRegex(searchString, false, caseSensitive); + return true; + } catch { + return false; + } +}; + +interface ProcessedSearches { + decorationsToReturn: DecorationSet; + results: Range[]; +} + +const processSearches = ( + doc: PMNode, + searchTerm: RegExp, + selectedResultIndex: number, + searchResultClass: string, + selectedResultClass: string +): ProcessedSearches => { + const decorations: Decoration[] = []; + const results: Range[] = []; + const textNodesWithPosition: TextNodeWithPosition[] = []; + + if (!searchTerm) { + return { decorationsToReturn: DecorationSet.empty, results: [] }; + } + + doc.descendants((node, pos) => { + if (node.isText) { + textNodesWithPosition.push({ pos, text: node.text || '' }); + } + }); + + for (const { text, pos } of textNodesWithPosition) { + const matches = [...text.matchAll(searchTerm)].filter( + ([matchText]) => matchText.length > 0 + ); + + for (const match of matches) { + if (match.index !== undefined) { + results.push({ + from: pos + match.index, + to: pos + match.index + match[0].length + }); + } + } + } + + for (let i = 0; i < results.length; i += 1) { + const result = results[i]; + if (!result) { + continue; + } + const { from, to } = result; + decorations.push( + Decoration.inline(from, to, { + class: + selectedResultIndex === i ? selectedResultClass : searchResultClass + }) + ); + } + + return { + decorationsToReturn: DecorationSet.create(doc, decorations), + results + }; +}; + +const replace = ( + replaceTerm: string, + results: Range[], + { state, dispatch }: any +) => { + const [firstResult] = results; + + if (!firstResult) { + return; + } + + const { from, to } = firstResult; + + if (dispatch) { + dispatch(state.tr.insertText(replaceTerm, from, to)); + } +}; + +const rebaseNextResult = ( + replaceTerm: string, + index: number, + lastOffset: number, + results: Range[] +): [number, Range[]] | undefined => { + const nextIndex = index + 1; + + if (!results[nextIndex]) { + return undefined; + } + + const currentResult = results[index]; + if (!currentResult) { + return undefined; + } + + const { from: currentFrom, to: currentTo } = currentResult; + + const offset = currentTo - currentFrom - replaceTerm.length + lastOffset; + + const { from, to } = results[nextIndex]; + + results[nextIndex] = { + from: from - offset, + to: to - offset + }; + + return [offset, results]; +}; + +const replaceAll = ( + replaceTerm: string, + results: Range[], + { tr, dispatch }: { tr: any; dispatch: any } +) => { + if (!results.length) { + return; + } + + let offset = 0; + + for (let i = 0; i < results.length; i += 1) { + const result = results[i]; + if (!result) { + continue; + } + const { from, to } = result; + tr.insertText(replaceTerm, from, to); + const rebaseResponse = rebaseNextResult(replaceTerm, i, offset, results); + + if (rebaseResponse) { + const [nextOffset] = rebaseResponse; + offset = nextOffset; + } + } + + dispatch(tr); +}; + +const selectNext = (editor: CoreEditor) => { + const { results } = editor.storage.searchAndReplace; + + if (!results.length) { + return; + } + + const { selectedResult } = editor.storage.searchAndReplace; + + if (selectedResult >= results.length - 1) { + editor.storage.searchAndReplace.selectedResult = 0; + } else { + editor.storage.searchAndReplace.selectedResult += 1; + } + + const result = results[editor.storage.searchAndReplace.selectedResult]; + if (!result) { + return; + } + + const { from } = result; + + const { view } = editor; + + if (view) { + view + .domAtPos(from) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +}; + +const selectPrevious = (editor: CoreEditor) => { + const { results } = editor.storage.searchAndReplace; + + if (!results.length) { + return; + } + + const { selectedResult } = editor.storage.searchAndReplace; + + if (selectedResult <= 0) { + editor.storage.searchAndReplace.selectedResult = results.length - 1; + } else { + editor.storage.searchAndReplace.selectedResult -= 1; + } + + const { from } = results[editor.storage.searchAndReplace.selectedResult]; + + const { view } = editor; + + if (view) { + view + .domAtPos(from) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +}; + +export const searchAndReplacePluginKey = new PluginKey( + 'searchAndReplacePlugin' +); + +export interface SearchAndReplaceOptions { + searchResultClass: string; + selectedResultClass: string; + disableRegex: boolean; +} + +export const SearchAndReplace = Extension.create< + SearchAndReplaceOptions, + SearchAndReplaceStorage +>({ + addCommands() { + return { + replace: + () => + ({ editor, state, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replace(replaceTerm, results, { dispatch, state }); + + return false; + }, + replaceAll: + () => + ({ editor, tr, dispatch }) => { + const { replaceTerm, results } = editor.storage.searchAndReplace; + + replaceAll(replaceTerm, results, { dispatch, tr }); + + return false; + }, + selectNextResult: + () => + ({ editor }) => { + selectNext(editor); + + return false; + }, + selectPreviousResult: + () => + ({ editor }) => { + selectPrevious(editor); + + return false; + }, + setCaseSensitive: + (caseSensitive: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.caseSensitive = caseSensitive; + + return false; + }, + setReplaceTerm: + (replaceTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.replaceTerm = replaceTerm; + + return false; + }, + setSearchTerm: + (searchTerm: string) => + ({ editor }) => { + editor.storage.searchAndReplace.searchTerm = searchTerm; + + return false; + }, + setUseRegex: + (useRegex: boolean) => + ({ editor }) => { + editor.storage.searchAndReplace.useRegex = useRegex; + + return false; + } + }; + }, + + addOptions() { + return { + disableRegex: true, + searchResultClass: ' bg-yellow-200', + selectedResultClass: 'bg-yellow-500' + }; + }, + + addProseMirrorPlugins() { + const { editor } = this; + const { searchResultClass, selectedResultClass, disableRegex } = + this.options; + + const setLastSearchTerm = (t: string) => { + editor.storage.searchAndReplace.lastSearchTerm = t; + }; + + const setLastSelectedResult = (r: number) => { + editor.storage.searchAndReplace.lastSelectedResult = r; + }; + + const setLastCaseSensitiveState = (s: boolean) => { + editor.storage.searchAndReplace.lastCaseSensitiveState = s; + }; + + const setLastUseRegexState = (s: boolean) => { + editor.storage.searchAndReplace.lastUseRegexState = s; + }; + + return [ + new Plugin({ + key: searchAndReplacePluginKey, + props: { + decorations(state) { + return this.getState(state); + } + }, + state: { + apply({ doc, docChanged }, oldState) { + const { + searchTerm, + selectedResult, + lastSearchTerm, + lastSelectedResult, + caseSensitive, + lastCaseSensitiveState, + useRegex, + lastUseRegexState + } = editor.storage.searchAndReplace; + + if ( + !docChanged && + lastSearchTerm === searchTerm && + selectedResult === lastSelectedResult && + lastCaseSensitiveState === caseSensitive && + lastUseRegexState === useRegex + ) { + return oldState; + } + + setLastSearchTerm(searchTerm); + setLastSelectedResult(selectedResult); + setLastCaseSensitiveState(caseSensitive); + setLastUseRegexState(useRegex); + + if (!searchTerm) { + editor.storage.searchAndReplace.selectedResult = 0; + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } + + let searchRegex = new RegExp(/./); + try { + searchRegex = getRegex( + searchTerm, + useRegex ? false : disableRegex, + caseSensitive + ); + } catch { + editor.storage.searchAndReplace.selectedResult = 0; + editor.storage.searchAndReplace.results = []; + return DecorationSet.empty; + } + + const { decorationsToReturn, results } = processSearches( + doc, + searchRegex, + selectedResult, + searchResultClass, + selectedResultClass + ); + + editor.storage.searchAndReplace.results = results; + + if (selectedResult > results.length) { + editor.storage.searchAndReplace.selectedResult = + results.length > 0 ? results.length : 0; + } + + return decorationsToReturn; + }, + init: () => DecorationSet.empty + } + }) + ]; + }, + + addStorage() { + return { + caseSensitive: false, + lastCaseSensitiveState: false, + lastSearchTerm: '', + lastSelectedResult: 0, + lastUseRegexState: false, + replaceTerm: '', + results: [], + searchTerm: '', + selectedResult: 0, + useRegex: false + }; + }, + + name: 'searchAndReplace' +}); + +export default SearchAndReplace; diff --git a/frontend/src/lib/components/tiptap/tiptap.css b/frontend/src/lib/components/tiptap/tiptap.css new file mode 100644 index 00000000..e5a06657 --- /dev/null +++ b/frontend/src/lib/components/tiptap/tiptap.css @@ -0,0 +1,469 @@ +:root { + /* Color System */ + --editor-text-default: hsl(240 10% 3.9%); + --editor-text-gray: hsl(240 3.8% 46.1%); + --editor-text-brown: hsl(25 95% 53%); + --editor-text-orange: hsl(24 95% 53%); + --editor-text-yellow: hsl(48 96% 53%); + --editor-text-green: hsl(142 71% 45%); + --editor-text-blue: hsl(221 83% 53%); + --editor-text-purple: hsl(269 97% 85%); + --editor-text-pink: hsl(336 80% 58%); + --editor-text-red: hsl(0 84% 60%); + + /* Background Colors */ + --editor-bg-default: hsl(0 0% 100%); + --editor-bg-subtle: hsl(0 0% 98%); + --editor-bg-muted: hsl(240 5% 96%); + + /* Highlight Colors */ + --editor-highlight-default: hsl(0 0% 98%); + --editor-highlight-gray: hsl(240 5% 96%); + --editor-highlight-brown: hsl(43 96% 96%); + --editor-highlight-orange: hsl(33 100% 96%); + --editor-highlight-yellow: hsl(54 100% 96%); + --editor-highlight-green: hsl(142 71% 96%); + --editor-highlight-blue: hsl(217 91% 96%); + --editor-highlight-purple: hsl(269 97% 96%); + --editor-highlight-pink: hsl(336 80% 96%); + --editor-highlight-red: hsl(0 84% 96%); + + /* Border Colors */ + --editor-border-default: hsl(240 5% 88%); + --editor-border-strong: hsl(240 5% 65%); + + /* Spacing System */ + --editor-spacing-1: 0.25rem; + --editor-spacing-2: 0.5rem; + --editor-spacing-3: 0.75rem; + --editor-spacing-4: 1rem; + --editor-spacing-6: 1.5rem; + --editor-spacing-8: 2rem; + --editor-spacing-12: 3rem; + --editor-spacing-16: 4rem; + + /* Typography */ + --editor-font-sans: + system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + --editor-font-mono: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + --editor-font-serif: Georgia, Cambria, 'Times New Roman', Times, serif; + + /* Animation */ + --editor-transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --editor-transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --editor-transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Shadows */ + --editor-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --editor-shadow-md: + 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --editor-shadow-lg: + 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); +} + +/* Dark Mode Custom Properties */ +.dark { + --editor-text-default: hsl(0 0% 98%); + --editor-text-gray: hsl(240 5% 64.9%); + --editor-text-brown: hsl(25 95% 53%); + --editor-text-orange: hsl(24 95% 53%); + --editor-text-yellow: hsl(48 96% 53%); + --editor-text-green: hsl(142 71% 45%); + --editor-text-blue: hsl(221 83% 53%); + --editor-text-purple: hsl(269 97% 85%); + --editor-text-pink: hsl(336 80% 58%); + --editor-text-red: hsl(0 84% 60%); + + --editor-bg-default: hsl(240 10% 3.9%); + --editor-bg-subtle: hsl(240 3.7% 15.9%); + --editor-bg-muted: hsl(240 5% 26%); + + --editor-highlight-default: hsl(240 3.7% 15.9%); + --editor-highlight-gray: hsl(240 5% 26%); + --editor-highlight-brown: hsl(43 96% 10%); + --editor-highlight-orange: hsl(33 100% 10%); + --editor-highlight-yellow: hsl(54 100% 10%); + --editor-highlight-green: hsl(142 71% 10%); + --editor-highlight-blue: hsl(217 91% 20%); + --editor-highlight-purple: hsl(269 97% 10%); + --editor-highlight-pink: hsl(336 80% 10%); + --editor-highlight-red: hsl(0 84% 10%); + + --editor-border-default: hsl(240 5% 26%); + --editor-border-strong: hsl(240 5% 64.9%); +} + +/* Core Editor Styles */ +.ProseMirror { + caret-color: var(--editor-text-default); + outline: none; + padding: 0 var(--editor-spacing-8); + padding-top: var(--editor-spacing-4); + margin: 0; + flex-grow: 1; + font-family: var(--editor-font-sans); + position: relative; + /* background-color: var(--editor-bg-default); */ + color: var(--editor-text-default); + transition: all var(--editor-transition-normal); + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.ProseMirror:focus { + outline: none; + box-shadow: none; +} + +.ProseMirror .selection, +.ProseMirror *::selection { + background-color: var(--editor-highlight-blue); + /* color: var(--editor-text-default); */ +} + +.ProseMirror > .react-renderer { + margin: var(--editor-spacing-12) 0; + transition: all var(--editor-transition-normal); +} + +.ProseMirror > .react-renderer:first-child { + margin-top: 0; +} + +.ProseMirror > .react-renderer:last-child { + margin-bottom: 0; +} + +/* Typography Styles */ +.ProseMirror p { + line-height: 1.75; + margin: 0; + color: var(--editor-text-default); +} + +.ProseMirror > p { + margin: 0; +} + +.ProseMirror h1, +.ProseMirror h2, +.ProseMirror h3, +.ProseMirror h4 { + font-family: var(--editor-font-sans); + font-weight: 700; + letter-spacing: -0.025em; + color: var(--editor-text-default); + scroll-margin-top: var(--editor-spacing-16); + line-height: 1.2; +} + +.ProseMirror h1 { + font-size: 2.5rem; + margin: var(--editor-spacing-8) 0 var(--editor-spacing-4); +} + +.ProseMirror h2 { + font-size: 2rem; + margin: var(--editor-spacing-8) 0 var(--editor-spacing-4); +} + +.ProseMirror h3 { + font-size: 1.5rem; + margin: var(--editor-spacing-6) 0 var(--editor-spacing-3); +} + +.ProseMirror h4 { + font-size: 1.25rem; + margin: var(--editor-spacing-4) 0 var(--editor-spacing-2); +} + +.ProseMirror a { + color: var(--editor-text-blue); + cursor: pointer; + text-decoration: underline; + text-decoration-thickness: 0.1em; + text-underline-offset: 0.2em; + transition: all var(--editor-transition-fast); +} + +.ProseMirror a:hover { + color: var(--editor-text-blue); + text-decoration-thickness: 0.2em; +} + +.ProseMirror code { + font-family: var(--editor-font-mono); + font-size: 0.9em; + background-color: var(--editor-bg-muted); + padding: 0.2em 0.4em; + border-radius: 4px; + color: var(--editor-text-default); + border: 1px solid var(--editor-border-default); +} + +.ProseMirror pre { + margin: var(--editor-spacing-6) 0; + padding: var(--editor-spacing-4); + background-color: var(--editor-bg-subtle); + border-radius: 8px; + overflow-x: auto; + border: 1px solid var(--editor-border-default); +} + +.ProseMirror pre code { + background-color: transparent; + padding: 0; + border: none; + font-size: 0.875rem; + line-height: 1.7; + color: var(--editor-text-default); +} + +.ProseMirror blockquote { + margin: var(--editor-spacing-6) 0; + padding: var(--editor-spacing-4) var(--editor-spacing-6); + border-left: 4px solid var(--editor-border-strong); + font-style: italic; + color: var(--editor-text-gray); + background-color: var(--editor-bg-subtle); + border-radius: 0 8px 8px 0; +} + +/* Lists */ +.ProseMirror ul, +.ProseMirror ol { + margin: var(--editor-spacing-4) 0; + padding-left: var(--editor-spacing-6); +} + +.ProseMirror li { + margin: var(--editor-spacing-2) 0; + padding-left: var(--editor-spacing-2); +} + +.ProseMirror ul { + list-style-type: disc; +} + +.ProseMirror ul ul { + list-style-type: circle; +} + +.ProseMirror ul ul ul { + list-style-type: square; +} + +.ProseMirror ol { + list-style-type: decimal; +} + +.ProseMirror ol ol { + list-style-type: lower-alpha; +} + +.ProseMirror ol ol ol { + list-style-type: lower-roman; +} + +/* Tables */ +.ProseMirror table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + margin: var(--editor-spacing-6) 0; + border: 1px solid var(--editor-border-default); + border-radius: 8px; + overflow: hidden; +} + +.ProseMirror th { + background-color: var(--editor-bg-subtle); + font-weight: 600; + text-align: left; + padding: var(--editor-spacing-3) var(--editor-spacing-4); + border-bottom: 2px solid var(--editor-border-default); +} + +.ProseMirror td { + padding: var(--editor-spacing-3) var(--editor-spacing-4); + border-bottom: 1px solid var(--editor-border-default); + transition: background-color var(--editor-transition-fast); +} + +.ProseMirror tr:last-child td { + border-bottom: none; +} + +.ProseMirror tr:hover td { + background-color: var(--editor-bg-subtle); +} + +/* Images */ +.ProseMirror img { + max-width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid var(--editor-border-default); + box-shadow: var(--editor-shadow-sm); + transition: all var(--editor-transition-normal); + display: block; + margin: var(--editor-spacing-1) auto; +} + +.ProseMirror img:hover { + box-shadow: var(--editor-shadow-lg); + transform: translateY(-2px); +} + +/* Horizontal Rule */ +.ProseMirror hr { + margin: var(--editor-spacing-8) 0; + border: none; + border-top: 2px solid var(--editor-border-default); +} + +/* Floating Menu & Toolbar */ +.floating-menu { + background-color: var(--editor-bg-default); + border: 1px solid var(--editor-border-default); + box-shadow: var(--editor-shadow-lg); + border-radius: 8px; + padding: var(--editor-spacing-1); + display: flex; + gap: var(--editor-spacing-1); + align-items: center; + animation: fadeIn var(--editor-transition-normal); + backdrop-filter: blur(8px); +} + +.toolbar-button { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + height: 2.25rem; + padding: 0 var(--editor-spacing-3); + transition: all var(--editor-transition-fast); + background-color: transparent; + color: var(--editor-text-default); + border: 1px solid transparent; +} + +.toolbar-button:hover { + background-color: var(--editor-bg-subtle); + color: var(--editor-text-default); +} + +.toolbar-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--editor-border-strong); +} + +.toolbar-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar-button.active { + background-color: var(--editor-bg-muted); + color: var(--editor-text-blue); +} + +/* Placeholder Styles +.ProseMirror p.is-editor-empty:first-child::before { + content: "Start writing or press '/' for commands..."; + color: var(--editor-text-gray); + pointer-events: none; + float: left; + height: 0; +} */ + +.tiptap { + /* Give a remote user a caret */ + .collaboration-carets__caret { + border-left: 1px solid #0d0d0d; + border-right: 1px solid #0d0d0d; + margin-left: -1px; + margin-right: -1px; + pointer-events: none; + position: relative; + word-break: normal; + } + + /* Render the username above the caret */ + .collaboration-carets__label { + border-radius: 3px 3px 3px 0; + color: #0d0d0d; + font-size: 12px; + font-style: normal; + font-weight: 600; + left: -1px; + line-height: normal; + padding: 0.1rem 0.3rem; + position: absolute; + top: -1.4em; + user-select: none; + white-space: nowrap; + } +} + +/* Mobile Optimizations */ +@media (max-width: 640px) { + .ProseMirror { + padding: var(--editor-spacing-4); + } + + .ProseMirror h1 { + font-size: 2rem; + } + .ProseMirror h2 { + font-size: 1.75rem; + } + .ProseMirror h3 { + font-size: 1.5rem; + } + .ProseMirror h4 { + font-size: 1.25rem; + } + .ProseMirror p { + font-size: 1rem; + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Print Styles */ +@media print { + .ProseMirror { + padding: 0; + max-width: none; + } + + .floating-menu, + .toolbar-button { + display: none; + } +} + +.is-editor-empty::before { + color: var(--editor-text-gray); + content: attr(data-placeholder); + float: left; + height: 0; + pointer-events: none; +} diff --git a/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte new file mode 100644 index 00000000..55ce6c49 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/EditorToolbar.svelte @@ -0,0 +1,13 @@ + + +
+ + + +
diff --git a/frontend/src/lib/components/tiptap/toolbar/alignment.svelte b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte new file mode 100644 index 00000000..79ebb16d --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/alignment.svelte @@ -0,0 +1,115 @@ + + +{#snippet alignmentMenu()} + e.preventDefault()} + class="w-42" + > + + {#each alignmentOptions as option, index (index)} + {@const OptionIcon = option.icon} + handleAlign(option.value)}> + + + + {option.name} + {#if option.value === currentTextAlign} + + {/if} + + {/each} + + +{/snippet} + +{#if inOverflowMenu} + + + {#snippet child({ props })} + {@const CurrentIcon = currentOption.icon} + + {/snippet} + + {@render alignmentMenu()} + +{:else} + + + + {#snippet child({ props })} + {@const CurrentIcon = currentOption.icon} + + {#snippet child({ props: triggerProps })} + + + + + {currentOption.name} + + + {/snippet} + + {/snippet} + + Text Alignment + + {@render alignmentMenu()} + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte new file mode 100644 index 00000000..4919608c --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/blockquote.svelte @@ -0,0 +1,60 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Blockquote + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/bold.svelte b/frontend/src/lib/components/tiptap/toolbar/bold.svelte new file mode 100644 index 00000000..3202fc44 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/bold.svelte @@ -0,0 +1,59 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Bold + (cmd + b) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte new file mode 100644 index 00000000..8b27c2c4 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/bullet-list.svelte @@ -0,0 +1,60 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Bullet List + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/code-block.svelte b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte new file mode 100644 index 00000000..e60de192 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/code-block.svelte @@ -0,0 +1,60 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Code Block + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte new file mode 100644 index 00000000..43a6736c --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/color-and-highlight.svelte @@ -0,0 +1,173 @@ + + +{#snippet colorMenu()} + + + + + + {#each TEXT_COLORS as { name, color } (name)} + handleSetColor(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
+ A +
+ {name} + {#if currentColor === color} + + {/if} +
+ {/each} +
+ + + + + {#each HIGHLIGHT_COLORS as { name, color } (name)} + handleSetHighlight(color)} + class="flex w-full cursor-pointer items-center rounded-sm px-2 py-1 text-sm [&_svg.cn-command-item-indicator]:hidden!" + > +
+
+ A +
+ {name} +
+ {#if currentHighlight === color} + + {/if} +
+ {/each} +
+
+
+
+
+{/snippet} + + + {#if inOverflowMenu} + + {#snippet child({ props })} + + {/snippet} + + {@render colorMenu()} + {:else} +
+ + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + + A + + + {/snippet} + + {/snippet} + + Text Color & Highlight + + {@render colorMenu()} +
+ {/if} +
diff --git a/frontend/src/lib/components/tiptap/toolbar/headings.svelte b/frontend/src/lib/components/tiptap/toolbar/headings.svelte new file mode 100644 index 00000000..34a01d4c --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/headings.svelte @@ -0,0 +1,103 @@ + + +{#snippet headingMenu()} + + editor.chain().focus().setParagraph().run()} + class={cn( + 'flex h-fit items-center gap-2', + !isHeadingActive && 'bg-accent' + )} + > + Normal + + {#each levels as level (level)} + editor.chain().focus().toggleHeading({ level }).run()} + class={cn( + 'flex items-center gap-2', + editor.isActive('heading', { level }) && 'bg-accent' + )} + > + H{level} + + {/each} + +{/snippet} + +{#if inOverflowMenu} + + + {#snippet child({ props })} + + {/snippet} + + {@render headingMenu()} + +{:else} + + + {#snippet child({ props })} + + + {#snippet child({ props: triggerProps })} + + {activeLevel ? `H${activeLevel}` : 'Normal'} + + + {/snippet} + + {@render headingMenu()} + + {/snippet} + + + Headings + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/italic.svelte b/frontend/src/lib/components/tiptap/toolbar/italic.svelte new file mode 100644 index 00000000..9632501e --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/italic.svelte @@ -0,0 +1,61 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Italic + (cmd + i) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/link.svelte b/frontend/src/lib/components/tiptap/toolbar/link.svelte new file mode 100644 index 00000000..18372c25 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/link.svelte @@ -0,0 +1,150 @@ + + +{#snippet linkMenu()} + e.preventDefault()} + class="relative px-3 py-2.5" + > +
+
+

Attach a link to the selected text

+
+ +
+ {#if linkHref} + + + Remove + + {/if} + + {linkHref ? 'Update' : 'Confirm'} + +
+
+
+
+
+{/snippet} + + + {#if inOverflowMenu} + + {#snippet child({ props })} + + {/snippet} + + {@render linkMenu()} + {:else} + + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + +

+

+ Link +

+
+ {/snippet} +
+ {/snippet} +
+ + Link + +
+ {@render linkMenu()} + {/if} +
diff --git a/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte new file mode 100644 index 00000000..d9acd469 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/ordered-list.svelte @@ -0,0 +1,60 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Ordered List + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/redo.svelte b/frontend/src/lib/components/tiptap/toolbar/redo.svelte new file mode 100644 index 00000000..413513d7 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/redo.svelte @@ -0,0 +1,55 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Redo + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte new file mode 100644 index 00000000..3de85aa0 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/search-and-replace-toolbar.svelte @@ -0,0 +1,261 @@ + + +{#snippet searchMenu()} + e.preventDefault()} + class="flex w-[412px] flex-col gap-1.5 px-3 py-2.5" + > +
+ handleSearchInput(e.currentTarget.value)} + class="w-48" + placeholder="Search..." + aria-invalid={isInvalidRegex} + title={isInvalidRegex ? 'Invalid regular expression' : undefined} + /> + + {results.length === 0 + ? selectedResult + : selectedResult + 1}/{results.length} + + + + + + + + + + + {#snippet child({ props })} + + + + {/snippet} + + Match case + + + + + {#snippet child({ props })} + + + + {/snippet} + + Use regular expression + + + + + {#snippet child({ props })} + + + + {/snippet} + + Replace + +
+ + {#if replacing} +
+ handleReplaceInput(e.currentTarget.value)} + class="w-48" + placeholder="Replace..." + /> + + Replace + + + Replace All + +
+ {/if} +
+{/snippet} + + + {#if inOverflowMenu} + + {#snippet child({ props })} + + {/snippet} + + {@render searchMenu()} + {:else} + + {#snippet child({ props })} + + +

Search & Replace

+
+ {/snippet} +
+ {@render searchMenu()} + {/if} +
diff --git a/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte new file mode 100644 index 00000000..30427c8f --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/strikethrough.svelte @@ -0,0 +1,61 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Strikethrough + (cmd + shift + x) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte new file mode 100644 index 00000000..6e8133e1 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-container.svelte @@ -0,0 +1,170 @@ + + +{#snippet toolbarSeparator()} + +{/snippet} + +{#snippet renderItem(item: ToolbarEntry, inOverflowMenu: boolean)} + {#if item.isSeparator} + {#if !inOverflowMenu} + {@render toolbarSeparator()} + {/if} + {:else if item.component} + + {/if} +{/snippet} + +
+
+ {#each items as item, index (item.id)} + {#if index < layout.visibleCount && layout.visibility[index]} + {@render renderItem(item, false)} + {/if} + {/each} + + {#if layout.showOverflow} + + + + {#snippet child({ props })} + + {#snippet child({ props: triggerProps })} + + + + {/snippet} + + {/snippet} + + More tools + + e.preventDefault()} + > + {#each items as item, index (item.id)} + {#if index >= layout.visibleCount && !item.isSeparator} + {@render renderItem(item, true)} + {/if} + {/each} + + + {/if} +
+ + + +
diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte new file mode 100644 index 00000000..714ec5ac --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow-trigger.svelte @@ -0,0 +1,38 @@ + + + + + {label} + {#if hasSubmenu} + + {/if} + diff --git a/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts new file mode 100644 index 00000000..abb08503 --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/toolbar-overflow.ts @@ -0,0 +1,69 @@ +export interface ToolbarOverflowItem { + id: string; + isSeparator?: boolean; +} + +export const totalWidth = ( + itemWidths: number[], + count: number, + gap: number +): number => { + if (count <= 0) { + return 0; + } + const itemsWidth = itemWidths + .slice(0, count) + .reduce((sum, width) => sum + width, 0); + return itemsWidth + gap * (count - 1); +}; + +export const calculateVisibleCount = ( + containerWidth: number, + itemWidths: number[], + overflowButtonWidth: number, + gap: number +): { visibleCount: number; showOverflow: boolean } => { + const itemCount = itemWidths.length; + + if (itemCount === 0 || containerWidth <= 0) { + return { showOverflow: false, visibleCount: 0 }; + } + + const fitsAll = totalWidth(itemWidths, itemCount, gap) <= containerWidth; + if (fitsAll) { + return { showOverflow: false, visibleCount: itemCount }; + } + + const availableWithOverflow = containerWidth - overflowButtonWidth - gap; + let visibleCount = 0; + + for (let i = 0; i < itemCount; i++) { + const nextWidth = totalWidth(itemWidths, i + 1, gap); + if (nextWidth > availableWithOverflow) { + break; + } + visibleCount = i + 1; + } + + return { showOverflow: true, visibleCount }; +}; + +export const cleanupSeparatorVisibility = ( + items: ToolbarOverflowItem[], + visibleCount: number +): boolean[] => + items.map((item, index) => { + if (index >= visibleCount) { + return false; + } + + if (!item.isSeparator) { + return true; + } + + const prevVisible = index > 0 && !items[index - 1]?.isSeparator; + const nextVisible = + index + 1 < visibleCount && !items[index + 1]?.isSeparator; + + return prevVisible && nextVisible; + }); diff --git a/frontend/src/lib/components/tiptap/toolbar/underline.svelte b/frontend/src/lib/components/tiptap/toolbar/underline.svelte new file mode 100644 index 00000000..9b8cd87b --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/underline.svelte @@ -0,0 +1,61 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Underline + (cmd + u) + + +{/if} diff --git a/frontend/src/lib/components/tiptap/toolbar/undo.svelte b/frontend/src/lib/components/tiptap/toolbar/undo.svelte new file mode 100644 index 00000000..28ccab0a --- /dev/null +++ b/frontend/src/lib/components/tiptap/toolbar/undo.svelte @@ -0,0 +1,55 @@ + + +{#if inOverflowMenu} + +{:else} + + + {#snippet child({ props })} + + + + {/snippet} + + + Undo + + +{/if} diff --git a/frontend/src/routes/auth/app/+page.svelte b/frontend/src/routes/auth/app/+page.svelte index 11b76247..d69bdf92 100644 --- a/frontend/src/routes/auth/app/+page.svelte +++ b/frontend/src/routes/auth/app/+page.svelte @@ -6,8 +6,7 @@ import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import { logout, requestAppCode, type UserInfo } from '$lib/client'; import { toast } from '@profidev/pleiades/components/util/general'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; - import { avatarUrl } from '$lib/permissions.svelte.js'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; let { data } = $props(); @@ -70,10 +69,7 @@ {#if user} - +
{user.name} {user.email} diff --git a/frontend/src/routes/notes/+page.svelte b/frontend/src/routes/notes/+page.svelte new file mode 100644 index 00000000..68255e56 --- /dev/null +++ b/frontend/src/routes/notes/+page.svelte @@ -0,0 +1,153 @@ + + +
+
+

Notes

+ +
+ + + +
+ diff --git a/frontend/src/routes/notes/+page.ts b/frontend/src/routes/notes/+page.ts new file mode 100644 index 00000000..8b537684 --- /dev/null +++ b/frontend/src/routes/notes/+page.ts @@ -0,0 +1,7 @@ +import { listNotes } from '$lib/client'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ fetch, url }) => ({ + error: url.searchParams.get('error'), + notes: listNotes({ fetch }).then(({ data }) => data ?? []) +}); diff --git a/frontend/src/routes/notes/[id]/+page.svelte b/frontend/src/routes/notes/[id]/+page.svelte new file mode 100644 index 00000000..dc5c6143 --- /dev/null +++ b/frontend/src/routes/notes/[id]/+page.svelte @@ -0,0 +1,219 @@ + + +
+
+ + + { + if (event.key === 'Enter') { + event.currentTarget.blur(); + } + }} + /> + + + + + {#if note} +
+ + +
+ {/if} +
+
+ +
+
+ diff --git a/frontend/src/routes/notes/[id]/+page.ts b/frontend/src/routes/notes/[id]/+page.ts new file mode 100644 index 00000000..eac925c7 --- /dev/null +++ b/frontend/src/routes/notes/[id]/+page.ts @@ -0,0 +1,11 @@ +import { infoNote, listUsersNote } from '$lib/client'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ params, fetch }) => ({ + id: params.id, + noteRes: infoNote({ + fetch, + path: { uuid: params.id } + }), + usersPromise: listUsersNote({ fetch }) +}); diff --git a/frontend/src/routes/notes/create/+page.svelte b/frontend/src/routes/notes/create/+page.svelte new file mode 100644 index 00000000..e58ad7c3 --- /dev/null +++ b/frontend/src/routes/notes/create/+page.svelte @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/routes/notes/create/Information.svelte b/frontend/src/routes/notes/create/Information.svelte new file mode 100644 index 00000000..1a02fd45 --- /dev/null +++ b/frontend/src/routes/notes/create/Information.svelte @@ -0,0 +1,44 @@ + + + + {#snippet children({ props })} + + {/snippet} + diff --git a/frontend/src/routes/notes/create/schema.svelte.ts b/frontend/src/routes/notes/create/schema.svelte.ts new file mode 100644 index 00000000..45f0a9f7 --- /dev/null +++ b/frontend/src/routes/notes/create/schema.svelte.ts @@ -0,0 +1,5 @@ +import z from 'zod'; + +export const information = z.object({ + title: z.string().min(1, 'Title is required').default('') +}); diff --git a/frontend/src/routes/oauth/+page.svelte b/frontend/src/routes/oauth/+page.svelte index 20dd3d9c..510052c5 100644 --- a/frontend/src/routes/oauth/+page.svelte +++ b/frontend/src/routes/oauth/+page.svelte @@ -7,8 +7,7 @@ import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import { authorizeConfirm, logout, type UserInfo } from '$lib/client'; import { toast } from '@profidev/pleiades/components/util/general'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; - import { avatarUrl } from '$lib/permissions.svelte.js'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; let { data } = $props(); @@ -78,10 +77,7 @@ {#if user} - +
{user.name} {user.email} diff --git a/frontend/src/routes/oauth/logout/+page.svelte b/frontend/src/routes/oauth/logout/+page.svelte index 85926f88..21b741f8 100644 --- a/frontend/src/routes/oauth/logout/+page.svelte +++ b/frontend/src/routes/oauth/logout/+page.svelte @@ -4,9 +4,8 @@ import { Skeleton } from '@profidev/pleiades/components/ui/skeleton'; import { goto } from '$app/navigation'; import type { UserInfo } from '$lib/client'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; - import { avatarUrl } from '$lib/permissions.svelte.js'; import { toast } from '@profidev/pleiades/components/util/general'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; let { data } = $props(); @@ -41,10 +40,7 @@ {#if user} - +
{user.name} {user.email} diff --git a/frontend/src/routes/users/[uuid]/+page.svelte b/frontend/src/routes/users/[uuid]/+page.svelte index 8f80650e..c1af1211 100644 --- a/frontend/src/routes/users/[uuid]/+page.svelte +++ b/frontend/src/routes/users/[uuid]/+page.svelte @@ -34,7 +34,7 @@ } from '$lib/client'; import { getEncrypt } from '$lib/backend/auth.svelte.js'; import { Skeleton } from '@profidev/pleiades/components/ui/skeleton'; - import SimpleAvatar from '$lib/components/SimpleAvatar.svelte'; + import UserAvatar from '$lib/components/UserAvatar.svelte'; import { Label } from '@profidev/pleiades/components/ui/label'; import { Input } from '@profidev/pleiades/components/ui/input'; @@ -231,8 +231,9 @@
-