Skip to content

Commit

Permalink
✨ Arrangeable sidebar and topbar (#308)
Browse files Browse the repository at this point in the history
* 🚧 Arrangeable side-bar and home screen

* WIP: fixed animation

* sync sidebar with arrangement config

* sync topbar with arrangement config

* add migration

* Fix lints, doc comments, hide certain items
  • Loading branch information
aaronleopold committed Apr 19, 2024
1 parent cc376ec commit 09b280f
Show file tree
Hide file tree
Showing 46 changed files with 1,519 additions and 99 deletions.
2 changes: 1 addition & 1 deletion .prettierignore
Expand Up @@ -8,6 +8,6 @@ build
.next
.expo

packages/browser/src/i18n/locales/*.json
packages/i18n/src/locales/*.json

CHANGELOG.md
7 changes: 4 additions & 3 deletions apps/server/src/routers/api/v1/auth.rs
Expand Up @@ -342,9 +342,10 @@ pub async fn register(
// supported on the prisma client. Until then, this ugly mess is necessary.
let _user_preferences = db
.user_preferences()
.create(vec![user_preferences::user::connect(user::id::equals(
created_user.id.clone(),
))])
.create(vec![
user_preferences::user::connect(user::id::equals(created_user.id.clone())),
user_preferences::user_id::set(Some(created_user.id.clone())),
])
.exec()
.await?;

Expand Down
95 changes: 93 additions & 2 deletions apps/server/src/routers/api/v1/user.rs
Expand Up @@ -13,7 +13,10 @@ use specta::Type;
use stump_core::{
config::StumpConfig,
db::{
entity::{AgeRestriction, LoginActivity, User, UserPermission, UserPreferences},
entity::{
AgeRestriction, Arrangement, LoginActivity, NavigationItem, User,
UserPermission, UserPreferences,
},
query::pagination::{Pageable, Pagination, PaginationQuery},
},
filesystem::{
Expand Down Expand Up @@ -50,7 +53,11 @@ pub(crate) fn mount(app_state: AppState) -> Router<AppState> {
"/users/me",
Router::new()
.route("/", put(update_current_user))
.route("/preferences", put(update_current_user_preferences)),
.route("/preferences", put(update_current_user_preferences))
.route(
"/navigation-arrangement",
get(get_navigation_arrangement).put(update_navigation_arrangement),
),
)
.nest(
"/users/:id",
Expand Down Expand Up @@ -609,6 +616,90 @@ async fn update_current_user_preferences(
Ok(Json(updated_preferences))
}

#[utoipa::path(
get,
path = "/api/v1/users/me/navigation-arrangement",
tag = "user",
responses(
(status = 200, description = "Successfully fetched user navigation arrangement"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "User preferences not found"),
(status = 500, description = "Internal server error"),
)
)]
async fn get_navigation_arrangement(
session: Session,
State(ctx): State<AppState>,
) -> APIResult<Json<Arrangement<NavigationItem>>> {
let user = get_session_user(&session)?;
let db = &ctx.db;

let user_preferences = db
.user_preferences()
.find_unique(user_preferences::user_id::equals(user.id.clone()))
.exec()
.await?
.ok_or(APIError::NotFound(format!(
"User preferences with id {} not found",
user.id
)))?;
let user_preferences = UserPreferences::from(user_preferences);

Ok(Json(user_preferences.navigation_arrangement))
}

#[utoipa::path(
put,
path = "/api/v1/users/me/navigation-arrangement",
tag = "user",
request_body = Arrangement<NavigationItem>,
responses(
(status = 200, description = "Successfully updated user navigation arrangement", body = Arrangement<NavigationItem>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "User preferences not found"),
(status = 500, description = "Internal server error"),
)
)]
async fn update_navigation_arrangement(
session: Session,
State(ctx): State<AppState>,
Json(input): Json<Arrangement<NavigationItem>>,
) -> APIResult<Json<Arrangement<NavigationItem>>> {
let user = get_session_user(&session)?;
let db = &ctx.db;

let user_preferences = db
.user_preferences()
.find_unique(user_preferences::user_id::equals(user.id.clone()))
.exec()
.await?
.ok_or(APIError::NotFound(format!(
"User preferences with id {} not found",
user.id
)))?;
let user_preferences = UserPreferences::from(user_preferences);

let _updated_preferences = db
.user_preferences()
.update(
user_preferences::id::equals(user_preferences.id.clone()),
vec![user_preferences::navigation_arrangement::set(Some(
serde_json::to_vec(&input).map_err(|e| {
APIError::InternalServerError(format!(
"Failed to serialize navigation arrangement: {}",
e
))
})?,
))],
)
.exec()
.await?;

Ok(Json(input))
}

#[derive(Deserialize, Type, ToSchema)]
pub struct DeleteUser {
pub hard_delete: Option<bool>,
Expand Down
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "user_preferences" ADD COLUMN "home_arrangement" BLOB;
ALTER TABLE "user_preferences" ADD COLUMN "navigation_arrangement" BLOB;
3 changes: 3 additions & 0 deletions core/prisma/schema.prisma
Expand Up @@ -683,6 +683,9 @@ model UserPreferences {
prefer_accent_color Boolean @default(true)
show_thumbnails_in_headers Boolean @default(false)
navigation_arrangement Bytes?
home_arrangement Bytes?
user User?
user_id String? @unique
Expand Down
157 changes: 157 additions & 0 deletions core/src/db/entity/user/preferences.rs
Expand Up @@ -4,6 +4,125 @@ use utoipa::ToSchema;

use crate::prisma;

#[derive(Default, Debug, Clone, Serialize, Deserialize, Type, ToSchema)]
pub enum NavigationMode {
#[default]
#[serde(rename = "SIDEBAR")]
SideBar,
#[serde(rename = "TOPBAR")]
TopBar,
}

// TODO: support order_by for some options with actions

#[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)]
pub struct NaviationItemDisplayOptions {
#[serde(default = "default_true")]
pub show_create_action: bool,
#[serde(default)]
pub show_link_to_all: bool,
}

impl Default for NaviationItemDisplayOptions {
fn default() -> Self {
Self {
show_create_action: true,
show_link_to_all: false,
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)]
#[serde(tag = "type")]
pub enum NavigationItem {
Home,
Explore,
Libraries(NaviationItemDisplayOptions),
SmartLists(NaviationItemDisplayOptions),
BookClubs(NaviationItemDisplayOptions),
}

// TODO: support order_by in some options (e.g. Library)

#[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)]
#[serde(tag = "type")]
pub enum HomeItem {
ContinueReading,
RecentlyAddedBooks,
RecentlyAddedSeries,
Library { library_id: String },
SmartList { smart_list_id: String },
}

#[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)]
pub struct ArrangementItem<I> {
item: I,
#[serde(default = "default_true")]
visible: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize, Type, ToSchema)]
pub struct Arrangement<I> {
locked: bool,
items: Vec<ArrangementItem<I>>,
}

impl<I> Arrangement<I> {
pub fn default_navigation() -> Arrangement<NavigationItem> {
Arrangement {
locked: true,
items: vec![
ArrangementItem {
item: NavigationItem::Home,
visible: true,
},
ArrangementItem {
item: NavigationItem::Explore,
visible: true,
},
ArrangementItem {
item: NavigationItem::Libraries(
NaviationItemDisplayOptions::default(),
),
visible: true,
},
ArrangementItem {
item: NavigationItem::SmartLists(
NaviationItemDisplayOptions::default(),
),
visible: true,
},
ArrangementItem {
item: NavigationItem::BookClubs(
NaviationItemDisplayOptions::default(),
),
visible: true,
},
],
}
}

pub fn default_home() -> Arrangement<HomeItem> {
Arrangement {
locked: true,
items: vec![
ArrangementItem {
item: HomeItem::ContinueReading,
visible: true,
},
ArrangementItem {
item: HomeItem::RecentlyAddedBooks,
visible: true,
},
ArrangementItem {
item: HomeItem::RecentlyAddedSeries,
visible: true,
},
],
}
}
}

fn default_navigation_mode() -> String {
"SIDEBAR".to_string()
}
Expand Down Expand Up @@ -48,6 +167,11 @@ pub struct UserPreferences {
pub prefer_accent_color: bool,
#[serde(default)]
pub show_thumbnails_in_headers: bool,

#[serde(default = "Arrangement::<NavigationItem>::default_navigation")]
pub navigation_arrangement: Arrangement<NavigationItem>,
#[serde(default = "Arrangement::<HomeItem>::default_home")]
pub home_arrangement: Arrangement<HomeItem>,
}

impl Default for UserPreferences {
Expand All @@ -68,6 +192,8 @@ impl Default for UserPreferences {
enable_hide_scrollbar: false,
prefer_accent_color: true,
show_thumbnails_in_headers: false,
navigation_arrangement: Arrangement::<NavigationItem>::default_navigation(),
home_arrangement: Arrangement::<HomeItem>::default_home(),
}
}
}
Expand All @@ -78,6 +204,35 @@ impl Default for UserPreferences {

impl From<prisma::user_preferences::Data> for UserPreferences {
fn from(data: prisma::user_preferences::Data) -> UserPreferences {
let navigation_arrangement = data
.navigation_arrangement
.map(|bytes| {
serde_json::from_slice(&bytes).map_or_else(
|error| {
tracing::error!(
?error,
"Failed to deserialize navigation arrangement"
);
Arrangement::<NavigationItem>::default_navigation()
},
|v| v,
)
})
.unwrap_or_else(Arrangement::<NavigationItem>::default_navigation);

let home_arrangement = data
.home_arrangement
.map(|bytes| {
serde_json::from_slice(&bytes).map_or_else(
|error| {
tracing::error!(?error, "Failed to deserialize home arrangement");
Arrangement::<HomeItem>::default_home()
},
|v| v,
)
})
.unwrap_or_else(Arrangement::<HomeItem>::default_home);

UserPreferences {
id: data.id,
locale: data.locale,
Expand All @@ -94,6 +249,8 @@ impl From<prisma::user_preferences::Data> for UserPreferences {
enable_hide_scrollbar: data.enable_hide_scrollbar,
prefer_accent_color: data.prefer_accent_color,
show_thumbnails_in_headers: data.show_thumbnails_in_headers,
navigation_arrangement,
home_arrangement,
}
}
}
12 changes: 12 additions & 0 deletions core/src/lib.rs
Expand Up @@ -271,7 +271,19 @@ mod tests {
file.write_all(format!("{}\n\n", ts_export::<User>()?).as_bytes())?;
file.write_all(format!("{}\n\n", ts_export::<UserPermission>()?).as_bytes())?;
file.write_all(format!("{}\n\n", ts_export::<AgeRestriction>()?).as_bytes())?;

file.write_all(format!("{}\n\n", ts_export::<NavigationMode>()?).as_bytes())?;
file.write_all(format!("{}\n\n", ts_export::<HomeItem>()?).as_bytes())?;
file.write_all(
format!("{}\n\n", ts_export::<NaviationItemDisplayOptions>()?).as_bytes(),
)?;
file.write_all(format!("{}\n\n", ts_export::<NavigationItem>()?).as_bytes())?;
file.write_all(
format!("{}\n\n", ts_export::<ArrangementItem<()>>()?).as_bytes(),
)?;
file.write_all(format!("{}\n\n", ts_export::<Arrangement<()>>()?).as_bytes())?;
file.write_all(format!("{}\n\n", ts_export::<UserPreferences>()?).as_bytes())?;

file.write_all(format!("{}\n\n", ts_export::<LoginActivity>()?).as_bytes())?;

file.write_all(format!("{}\n\n", ts_export::<FileStatus>()?).as_bytes())?;
Expand Down

0 comments on commit 09b280f

Please sign in to comment.