simplify list ownership & invitations

This commit is contained in:
2026-08-01 15:46:38 -04:00
parent 65886903cd
commit 5da5fee942
3 changed files with 138 additions and 223 deletions
+66 -137
View File
@@ -46,22 +46,9 @@ pub struct SessionUser {
pub struct GroceryList {
pub id: String,
pub name: String,
pub owner_id: String,
pub revision: i64,
}
#[derive(Clone, Debug)]
pub struct ListAccess {
pub list: GroceryList,
pub role: String,
}
#[derive(Clone, Debug)]
pub struct ListSummary {
pub list: GroceryList,
pub role: String,
}
#[derive(Clone, Debug)]
pub struct Item {
pub id: String,
@@ -80,11 +67,6 @@ pub struct Category {
pub name: String,
}
#[derive(Clone, Debug)]
pub struct InvitationInfo {
pub list_name: String,
}
impl Database {
pub fn open(path: impl AsRef<Path>) -> DbResult<Self> {
let connection = Connection::open(path).map_err(sql_error)?;
@@ -240,27 +222,21 @@ impl Database {
.await
}
pub async fn list_summaries(&self, user_id: String) -> DbResult<Vec<ListSummary>> {
pub async fn list_summaries(&self) -> DbResult<Vec<GroceryList>> {
self.call(move |connection| {
let mut statement = connection
.prepare(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role
"SELECT l.id, l.name, l.revision
FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE m.user_id = ?1
ORDER BY l.created_at DESC",
)
.map_err(sql_error)?;
let rows = statement
.query_map(params![user_id], |row| {
Ok(ListSummary {
list: GroceryList {
.query_map([], |row| {
Ok(GroceryList {
id: row.get(0)?,
name: row.get(1)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
revision: row.get(2)?,
})
})
.map_err(sql_error)?;
@@ -270,27 +246,19 @@ impl Database {
.await
}
pub async fn create_list(&self, owner_id: String, name: String) -> DbResult<GroceryList> {
pub async fn create_list(&self, name: String) -> DbResult<GroceryList> {
self.call(move |connection| {
let list = GroceryList {
id: Uuid::new_v4().to_string(),
name,
owner_id: owner_id.clone(),
revision: 0,
};
let transaction = connection.transaction().map_err(sql_error)?;
transaction
.execute(
"INSERT INTO lists (id, name, owner_id, revision, created_at)
VALUES (?1, ?2, ?3, 0, ?4)",
params![list.id, list.name, owner_id, now()],
)
.map_err(sql_error)?;
transaction
.execute(
"INSERT INTO list_members (list_id, user_id, role)
VALUES (?1, ?2, 'owner')",
params![list.id, list.owner_id],
"INSERT INTO lists (id, name, revision, created_at)
VALUES (?1, ?2, 0, ?3)",
params![list.id, list.name, now()],
)
.map_err(sql_error)?;
for (position, category_name) in DEFAULT_CATEGORIES.iter().enumerate() {
@@ -314,28 +282,19 @@ impl Database {
.await
}
pub async fn list_access(
&self,
list_id: String,
user_id: String,
) -> DbResult<Option<ListAccess>> {
pub async fn list_access(&self, list_id: String) -> DbResult<Option<GroceryList>> {
self.call(move |connection| {
connection
.query_row(
"SELECT l.id, l.name, l.owner_id, l.revision, m.role
"SELECT l.id, l.name, l.revision
FROM lists l
JOIN list_members m ON m.list_id = l.id
WHERE l.id = ?1 AND m.user_id = ?2",
params![list_id, user_id],
WHERE l.id = ?1",
params![list_id],
|row| {
Ok(ListAccess {
list: GroceryList {
Ok(GroceryList {
id: row.get(0)?,
name: row.get(1)?,
owner_id: row.get(2)?,
revision: row.get(3)?,
},
role: row.get(4)?,
revision: row.get(2)?,
})
},
)
@@ -547,19 +506,14 @@ impl Database {
.await
}
pub async fn create_invitation(
&self,
list_id: String,
created_by: String,
token: String,
) -> DbResult<i64> {
pub async fn create_invitation(&self, created_by: String, token: String) -> DbResult<i64> {
self.call(move |connection| {
let expires_at = now() + 60 * 60 * 24 * 7;
connection
.execute(
"INSERT INTO invitations (token_hash, list_id, created_by, expires_at)
VALUES (?1, ?2, ?3, ?4)",
params![hash_secret(&token), list_id, created_by, expires_at],
"INSERT INTO invitations (token_hash, created_by, expires_at)
VALUES (?1, ?2, ?3)",
params![hash_secret(&token), created_by, expires_at],
)
.map_err(sql_error)?;
Ok(expires_at)
@@ -567,47 +521,39 @@ impl Database {
.await
}
pub async fn invitation(&self, token: String) -> DbResult<Option<InvitationInfo>> {
pub async fn invitation(&self, token: String) -> DbResult<bool> {
self.call(move |connection| {
connection
let valid = connection
.query_row(
"SELECT l.name
FROM invitations i
JOIN lists l ON l.id = i.list_id
WHERE i.token_hash = ?1 AND i.expires_at > ?2",
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()],
|row| {
Ok(InvitationInfo {
list_name: row.get(0)?,
})
},
|_| Ok(()),
)
.optional()
.map_err(sql_error)
.map_err(sql_error)?
.is_some();
Ok(valid)
})
.await
}
pub async fn accept_invitation(&self, token: String, user_id: String) -> DbResult<String> {
pub async fn accept_invitation(&self, token: String) -> DbResult<()> {
self.call(move |connection| {
let transaction = connection.transaction().map_err(sql_error)?;
let invitation = transaction
let valid = transaction
.query_row(
"SELECT list_id FROM invitations
"SELECT 1 FROM invitations
WHERE token_hash = ?1 AND expires_at > ?2",
params![hash_secret(&token), now()],
|row| row.get::<_, String>(0),
|_| Ok(()),
)
.optional()
.map_err(sql_error)?
.ok_or(DbError::NotFound)?;
transaction
.execute(
"INSERT OR IGNORE INTO list_members (list_id, user_id, role)
VALUES (?1, ?2, 'member')",
params![invitation, user_id],
)
.map_err(sql_error)?;
.is_some();
if !valid {
return Err(DbError::NotFound);
}
transaction
.execute(
"DELETE FROM invitations WHERE token_hash = ?1",
@@ -615,7 +561,7 @@ impl Database {
)
.map_err(sql_error)?;
transaction.commit().map_err(sql_error)?;
Ok(invitation)
Ok(())
})
.await
}
@@ -653,16 +599,9 @@ fn migrate(connection: &Connection) -> DbResult<()> {
CREATE TABLE IF NOT EXISTS lists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS list_members (
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('owner', 'member')),
PRIMARY KEY (list_id, user_id)
);
CREATE TABLE IF NOT EXISTS categories (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
@@ -673,7 +612,6 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE TABLE IF NOT EXISTS invitations (
token_hash TEXT PRIMARY KEY,
list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
@@ -692,8 +630,7 @@ fn migrate(connection: &Connection) -> DbResult<()> {
);
CREATE INDEX IF NOT EXISTS items_list_idx ON items(list_id);
CREATE INDEX IF NOT EXISTS categories_list_idx ON categories(list_id);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS list_members_user_idx ON list_members(user_id);",
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);",
)
.map_err(sql_error)?;
@@ -798,12 +735,8 @@ mod tests {
#[tokio::test]
async fn creates_a_list_and_item() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user("test@example.com".into(), "Test User".into(), "hash".into())
.await
.unwrap();
let list = database
.create_list(user.id.clone(), "Weekly shop".into())
.create_list("Weekly shop".into())
.await
.unwrap();
database
@@ -829,12 +762,8 @@ mod tests {
.create_user("owner@example.com".into(), "Owner".into(), "hash".into())
.await
.unwrap();
let member = database
.create_user("member@example.com".into(), "Member".into(), "hash".into())
.await
.unwrap();
let list = database
.create_list(owner.id.clone(), "Household".into())
.create_list("Household".into())
.await
.unwrap();
assert_eq!(database.categories(list.id.clone()).await.unwrap().len(), 6);
@@ -848,52 +777,60 @@ mod tests {
assert_eq!(session.user.id, owner.id);
assert_eq!(session.csrf_token, csrf_token);
// Any registered account can access every list.
assert_eq!(
database
.list_access(list.id.clone())
.await
.unwrap()
.unwrap()
.name,
"Household"
);
let invitation_token = "test-invitation".to_owned();
database
.create_invitation(list.id.clone(), owner.id, invitation_token.clone())
.create_invitation(owner.id.clone(), invitation_token.clone())
.await
.unwrap();
assert_eq!(
assert!(
database
.invitation(invitation_token.clone())
.await
.unwrap()
.unwrap()
.list_name,
"Household"
);
let accepted_list = database
.accept_invitation(invitation_token.clone(), member.id.clone())
database
.accept_invitation(invitation_token.clone())
.await
.unwrap();
assert_eq!(accepted_list, list.id);
assert!(
database
!database
.invitation(invitation_token)
.await
.unwrap()
.is_none()
);
// A list created later is also accessible to every account.
let future_list = database
.create_list("Future shop".into())
.await
.unwrap();
assert_eq!(
database
.list_access(list.id, member.id)
.list_access(future_list.id)
.await
.unwrap()
.unwrap()
.role,
"member"
.name,
"Future shop"
);
}
#[tokio::test]
async fn checked_state_is_set_not_toggled() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user("check@example.com".into(), "Checker".into(), "hash".into())
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
let list = database.create_list("List".into()).await.unwrap();
database
.add_item(
list.id.clone(),
@@ -923,15 +860,7 @@ mod tests {
#[tokio::test]
async fn checking_an_item_does_not_change_list_order() {
let database = Database::open_in_memory().unwrap();
let user = database
.create_user(
"order@example.com".into(),
"Order Tester".into(),
"hash".into(),
)
.await
.unwrap();
let list = database.create_list(user.id, "List".into()).await.unwrap();
let list = database.create_list("List".into()).await.unwrap();
database
.add_item(
list.id.clone(),
+26 -33
View File
@@ -267,7 +267,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.route("/lists/{list_id}/items/{item_id}/edit", post(edit_item))
.route("/lists/{list_id}/items/{item_id}/delete", post(delete_item))
.route("/lists/{list_id}/categories", post(create_category))
.route("/lists/{list_id}/invitations", post(create_invitation))
.route("/invitations", post(create_invitation))
.route("/lists/{list_id}/stream", get(list_stream))
.route("/invite/{token}", get(invitation_page))
.route("/invite/{token}/accept", post(accept_invitation))
@@ -435,7 +435,7 @@ async fn lists_page(
) -> Result<Response, AppError> {
let lists = state
.db
.list_summaries(user.session.user.id.clone())
.list_summaries()
.await?;
Ok(html_response(views::lists_page(
&user.session.user,
@@ -456,7 +456,7 @@ async fn create_list(
"List names must be between 1 and 80 characters.".into(),
));
}
let list = state.db.create_list(user.session.user.id, name).await?;
let list = state.db.create_list(name).await?;
Ok(Redirect::to(&format!("/lists/{}", list.id)).into_response())
}
@@ -465,7 +465,7 @@ async fn list_page(
user: CurrentUser,
Path(list_id): Path<String>,
) -> Result<Response, AppError> {
let access = require_access(&state, &user, &list_id).await?;
let access = require_access(&state, &list_id).await?;
let items = state.db.items(list_id.clone()).await?;
let categories = state.db.categories(list_id.clone()).await?;
let presence = state.hub.presence(&list_id).await;
@@ -486,7 +486,7 @@ async fn add_item(
LoggedForm(form): LoggedForm<ItemForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, &list_id).await?;
let name = form.name.trim().to_owned();
let quantity = form.quantity.trim().to_owned();
let note = form.note.trim().to_owned();
@@ -514,7 +514,7 @@ async fn check_item(
LoggedForm(form): LoggedForm<CheckForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, &list_id).await?;
let checked = match form.checked.as_str() {
"1" | "true" => true,
"0" | "false" => false,
@@ -538,7 +538,7 @@ async fn edit_item(
LoggedForm(form): LoggedForm<ItemForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, &list_id).await?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 120 {
return Err(AppError::BadRequest(
@@ -570,7 +570,7 @@ async fn delete_item(
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, &list_id).await?;
let revision = state.db.delete_item(list_id.clone(), item_id).await?;
state
.hub
@@ -586,7 +586,7 @@ async fn create_category(
LoggedForm(form): LoggedForm<CategoryForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
require_access(&state, &user, &list_id).await?;
require_access(&state, &list_id).await?;
let name = form.name.trim().to_owned();
if name.is_empty() || name.chars().count() > 60 {
return Err(AppError::BadRequest(
@@ -599,7 +599,7 @@ async fn create_category(
.publish_list_changed(list_id.clone(), revision)
.await;
let access = require_access(&state, &user, &list_id).await?;
let access = require_access(&state, &list_id).await?;
let items = state.db.items(list_id.clone()).await?;
let categories = state.db.categories(list_id).await?;
Ok(html_response(views::category_created(
@@ -613,20 +613,13 @@ async fn create_category(
async fn create_invitation(
State(state): State<AppState>,
user: CurrentUser,
Path(list_id): Path<String>,
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let access = require_access(&state, &user, &list_id).await?;
if access.role != "owner" {
return Err(AppError::BadRequest(
"Only the list owner can create invitations.".into(),
));
}
let token = db::new_secret();
state
.db
.create_invitation(list_id, user.session.user.id, token.clone())
.create_invitation(user.session.user.id, token.clone())
.await?;
let url = format!(
"{}/invite/{token}",
@@ -643,11 +636,12 @@ async fn invitation_page(
let info = state
.db
.invitation(token.clone())
.await?
.ok_or(AppError::NotFound)?;
.await?;
if !info {
return Err(AppError::NotFound);
}
let user = optional_user(&state, &headers).await?;
Ok(html_response(views::invite_page(
&info,
user.as_ref().map(|current| &current.session.user),
&token,
None,
@@ -663,11 +657,11 @@ async fn accept_invitation(
LoggedForm(form): LoggedForm<CsrfForm>,
) -> Result<Response, AppError> {
verify_csrf(&user, &form.csrf)?;
let list_id = state
state
.db
.accept_invitation(token, user.session.user.id)
.accept_invitation(token)
.await?;
Ok(Redirect::to(&format!("/lists/{list_id}")).into_response())
Ok(Redirect::to("/lists").into_response())
}
async fn list_stream(
@@ -676,7 +670,7 @@ async fn list_stream(
Path(list_id): Path<String>,
websocket: WebSocketUpgrade,
) -> Result<Response, AppError> {
require_access(&state, &user, &list_id).await?;
require_access(&state, &list_id).await?;
let state_for_socket = state.clone();
let user_for_socket = user.clone();
Ok(websocket
@@ -784,7 +778,7 @@ async fn websocket_snapshot(
list_id: &str,
presence: &[hub::PresenceUser],
) -> Result<String, AppError> {
let access = require_access(state, user, list_id).await?;
let access = require_access(state, list_id).await?;
let items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?;
Ok(
@@ -799,7 +793,7 @@ async fn websocket_list_update(
user: &CurrentUser,
list_id: &str,
) -> Result<String, AppError> {
let access = require_access(state, user, list_id).await?;
let access = require_access(state, list_id).await?;
let items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?;
Ok(
@@ -813,11 +807,11 @@ async fn list_fragment_response(
user: &CurrentUser,
list_id: &str,
) -> Result<Response, AppError> {
let access = require_access(state, user, list_id).await?;
let access = require_access(state, list_id).await?;
let items = state.db.items(list_id.to_owned()).await?;
let categories = state.db.categories(list_id.to_owned()).await?;
Ok(html_response(views::list_items_fragment(
&access.list,
&access,
&items,
&categories,
&user.session.csrf_token,
@@ -827,12 +821,11 @@ async fn list_fragment_response(
async fn require_access(
state: &AppState,
user: &CurrentUser,
list_id: &str,
) -> Result<db::ListAccess, AppError> {
) -> Result<db::GroceryList, AppError> {
state
.db
.list_access(list_id.to_owned(), user.session.user.id.clone())
.list_access(list_id.to_owned())
.await?
.ok_or(AppError::NotFound)
}
@@ -877,7 +870,7 @@ async fn can_register(state: &AppState, invite: Option<&str>) -> Result<bool, Ap
let Some(invite) = invite.filter(|invite| !invite.is_empty()) else {
return Ok(false);
};
Ok(state.db.invitation(invite.to_owned()).await?.is_some())
Ok(state.db.invitation(invite.to_owned()).await?)
}
fn hash_password(password: &str) -> Result<String, String> {
+61 -68
View File
@@ -1,7 +1,7 @@
use maud::{DOCTYPE, Markup, html};
use crate::{
db::{Category, GroceryList, InvitationInfo, Item, ListAccess, ListSummary, User},
db::{Category, GroceryList, Item, User},
hub::PresenceUser,
};
@@ -13,7 +13,7 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
div class="auth-card" {
p class="eyebrow" { "SUSTENANCE" }
h1 { "Welcome back" }
p class="lede" { "Keep the household running, one item at a time." }
p class="lede" { "Keep the shopping list in sync." }
@if let Some(error) = error {
div class="alert alert-error" role="alert" { (error) }
}
@@ -69,23 +69,23 @@ pub fn registration_closed_page() -> Markup {
None,
html! {
div class="auth-card" {
p class="eyebrow" { "PRIVATE HOUSEHOLD" }
p class="eyebrow" { "PRIVATE LISTS" }
h1 { "Registration is invite-only" }
p class="lede" { "Ask someone who owns a list to send you an invitation link." }
p class="lede" { "Ask someone who uses the app to send you an invitation link." }
a class="button button-primary" href="/login" { "Back to sign in" }
}
},
)
}
pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Markup {
pub fn lists_page(user: &User, lists: &[GroceryList], csrf_token: &str) -> Markup {
page(
"Your lists",
Some(user),
html! {
div class="page-heading" {
div {
p class="eyebrow" { "YOUR HOUSEHOLD" }
p class="eyebrow" { "SHARED LISTS" }
h1 { "Grocery lists" }
p class="lede" { "Everything you need, in one place." }
}
@@ -104,12 +104,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
}
} @else {
div class="list-cards" {
@for summary in lists {
a class="list-card" href=(format!("/lists/{}", summary.list.id)) {
@for list in lists {
a class="list-card" href=(format!("/lists/{}", list.id)) {
span class="list-card-icon" { "" }
span class="list-card-copy" {
strong { (summary.list.name) }
small { @if summary.role == "owner" { "Owner" } @else { "Member" } }
strong { (list.name) }
}
span class="list-card-arrow" { "" }
}
@@ -126,53 +125,11 @@ pub fn lists_page(user: &User, lists: &[ListSummary], csrf_token: &str) -> Marku
button class="button button-primary" type="submit" { "Create list" }
}
}
}
},
)
}
pub fn list_page(
user: &User,
access: &ListAccess,
items: &[Item],
categories: &[Category],
presence: &[PresenceUser],
csrf_token: &str,
) -> Markup {
let is_owner = access.role == "owner";
page(
&access.list.name,
Some(user),
html! {
div class="list-topbar" {
a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" }
@if is_owner {
a class="button button-small button-quiet" href="#sharing" { "Share list" }
}
}
}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
div {
p class="eyebrow" { "SHARED LIST" }
h1 { (access.list.name) }
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
}
}
(list_content_fragment(&access.list, items, categories, csrf_token, false))
}
aside class="side-column" {
(presence_panel(presence, false))
(categories_panel(&access.list, categories, csrf_token, false))
@if is_owner {
section id="sharing" class="panel sharing-panel" {
div class="panel-heading" { h2 { "Share this list" } }
p { "Create a one-time invite link for someone you shop with." }
div class="panel-heading" { h2 { "Invite someone" } }
p { "Create a one-time invite link so a new person can join." }
form
hx-post=(format!("/lists/{}/invitations", access.list.id))
hx-post="/invitations"
hx-target="#invite-result"
hx-swap="innerHTML"
class="stack"
@@ -183,13 +140,49 @@ pub fn list_page(
div id="invite-result" class="invite-result" {}
}
}
},
)
}
pub fn list_page(
user: &User,
list: &GroceryList,
items: &[Item],
categories: &[Category],
presence: &[PresenceUser],
csrf_token: &str,
) -> Markup {
page(
&list.name,
Some(user),
html! {
div class="list-topbar" {
a class="back-link" href="/lists" { "← All lists" }
div class="list-topbar-actions" {
span class="live-pill" { span class="live-dot" {} "Live" }
}
}
div class="list-layout" {
section class="panel list-panel" {
div class="list-heading" {
div {
p class="eyebrow" { "SHARED LIST" }
h1 { (list.name) }
p class="list-meta" { (items.iter().filter(|item| !item.checked).count()) " items to get" }
}
}
(list_content_fragment(list, items, categories, csrf_token, false))
}
aside class="side-column" {
(presence_panel(presence, false))
(categories_panel(list, categories, csrf_token, false))
section class="panel tip-panel" {
span class="tip-label" { "TIP" }
p { "Check items off as you go. Everyone viewing this list will see it instantly." }
}
}
}
div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", access.list.id)) {}
div id="live-stream" class="live-stream" hx-ext="ws" ws-connect=(format!("/lists/{}/stream", list.id)) {}
},
)
}
@@ -448,26 +441,26 @@ pub fn categories_panel(
}
pub fn live_list_fragments(
access: &ListAccess,
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
(list_content_fragment(&access.list, items, categories, csrf_token, true))
(categories_panel(&access.list, categories, csrf_token, true))
(list_content_fragment(list, items, categories, csrf_token, true))
(categories_panel(list, categories, csrf_token, true))
}
}
pub fn category_created(
access: &ListAccess,
list: &GroceryList,
items: &[Item],
categories: &[Category],
csrf_token: &str,
) -> Markup {
html! {
p class="category-success" { "Category added." }
(live_list_fragments(access, items, categories, csrf_token))
(live_list_fragments(list, items, categories, csrf_token))
}
}
@@ -509,7 +502,6 @@ fn presence_content(presence: &[PresenceUser]) -> Markup {
}
pub fn invite_page(
info: &InvitationInfo,
user: Option<&User>,
token: &str,
error: Option<&str>,
@@ -521,8 +513,8 @@ pub fn invite_page(
html! {
div class="auth-card invite-card" {
p class="eyebrow" { "YOU'RE INVITED" }
h1 { "Join " (info.list_name) }
p class="lede" { "Shop together and keep the list in sync." }
h1 { "Join the shared lists" }
p class="lede" { "Shop together and keep all the lists in sync." }
@if let Some(error) = error {
div class="alert alert-error" role="alert" { (error) }
}
@@ -532,14 +524,15 @@ pub fn invite_page(
@if let Some(csrf_token) = csrf_token {
input type="hidden" name="csrf" value=(csrf_token);
}
button class="button button-primary" type="submit" { "Join list" }
button class="button button-primary" type="submit" { "Join lists" }
}
p class="muted" { "Want to join as a different person? Create a new account below." }
} @else {
p { "Sign in or create an account to accept this invite." }
div class="invite-actions" {
a class="button button-primary" href=(format!("/login?invite={}", token)) { "Sign in" }
a class="button button-secondary" href=(format!("/register?invite={}", token)) { "Create account" }
}
div class="invite-actions" {
a class="button button-primary" href=(format!("/register?invite={}", token)) { "Create account" }
a class="button button-secondary" href=(format!("/login?invite={}", token)) { "Sign in" }
}
}
},