Skip to main content

max / makenotwork

Add soft delete with 7-day recovery and wishlist/bookmark Two features completing the UX audit LOW items: Soft delete: - Items now soft-deleted (deleted_at column) instead of hard-deleted - Auto-purged after 7 days by scheduler daily job - Restore endpoint: POST /api/items/{id}/restore - Discover and project listings filter out soft-deleted items - Public item pages return 404 for soft-deleted (unless owner) - Migration 088 Wishlist: - New wishlists table (user_id, item_id, unique) - Toggle endpoint: POST /api/wishlists/{item_id} - "Wishlist" link on item pages (logged-in non-owners) - Bold text when wishlisted, click to toggle Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-03 03:22 UTC
Commit: a4d6be1369944d4379c57a1e70bfdbe73617c6d5
Parent: 687492d
17 files changed, +273 insertions, -30 deletions
@@ -55,8 +55,8 @@ Usability audit grade: B. Complexity C+, Completeness B+, Learnability B, Discov
55 55
56 56 - [x] **[LOW]** Add bulk operations for item management — already implemented (publish/unpublish/delete in project Content tab with multi-select)
57 57 - [x] **[LOW]** Add keyboard shortcuts — `?` help overlay with shortcut list, `Esc` closes modals, `Cmd+S` saves forms (Cmd+K deferred until global search)
58 - - [ ] **[LOW]** Add soft delete with 7-day recovery — items/projects currently hard-delete on confirmation. Add "Recently Deleted" archive with restore option
59 - - [ ] **[LOW]** Add wishlist/bookmark for fans — simple heart icon on item cards, DB table for saved items. Table-stakes vs Bandcamp/Gumroad/itch.io
58 + - [x] **[LOW]** Add soft delete with 7-day recovery — items now soft-deleted (deleted_at column), auto-purged after 7 days by scheduler, restore endpoint at POST /api/items/{id}/restore
59 + - [x] **[LOW]** Add wishlist/bookmark for fans — "Wishlist" toggle on item pages, wishlists table, toggle API at POST /api/wishlists/{item_id}
60 60 - [x] **[LOW]** Add changelog or "What's New" — `/changelog` page with entry history, linked from site footer
61 61
62 62 ### Deferred (post-beta table stakes)
@@ -0,0 +1,12 @@
1 + -- Soft delete: items get a grace period before permanent deletion
2 + ALTER TABLE items ADD COLUMN deleted_at TIMESTAMPTZ;
3 +
4 + -- Wishlists: fans can bookmark items they want to buy later
5 + CREATE TABLE IF NOT EXISTS wishlists (
6 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
7 + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
8 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
9 + PRIMARY KEY (user_id, item_id)
10 + );
11 +
12 + CREATE INDEX IF NOT EXISTS idx_wishlists_item ON wishlists(item_id);
@@ -192,7 +192,7 @@ pub async fn discover_items(
192 192 JOIN users u ON p.user_id = u.id
193 193 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
194 194 LEFT JOIN tags pt ON pt.id = pit.tag_id
195 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
195 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
196 196 "#,
197 197 )
198 198 } else if has_search {
@@ -218,7 +218,7 @@ pub async fn discover_items(
218 218 JOIN users u ON p.user_id = u.id
219 219 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
220 220 LEFT JOIN tags pt ON pt.id = pit.tag_id
221 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
221 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
222 222 "#,
223 223 )
224 224 } else {
@@ -243,7 +243,7 @@ pub async fn discover_items(
243 243 JOIN users u ON p.user_id = u.id
244 244 LEFT JOIN item_tags pit ON pit.item_id = i.id AND pit.is_primary = true
245 245 LEFT JOIN tags pt ON pt.id = pit.tag_id
246 - WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE
246 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status != 'quarantined' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
247 247 "#,
248 248 )
249 249 };
@@ -144,7 +144,7 @@ pub async fn get_item_project_ids_batch(pool: &PgPool, ids: &[ItemId]) -> Result
144 144 #[tracing::instrument(skip_all)]
145 145 pub async fn get_items_by_project(pool: &PgPool, project_id: ProjectId) -> Result<Vec<DbItem>> {
146 146 let items = sqlx::query_as::<_, DbItem>(
147 - "SELECT * FROM items WHERE project_id = $1 ORDER BY sort_order, created_at DESC LIMIT 500",
147 + "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NULL ORDER BY sort_order, created_at DESC LIMIT 500",
148 148 )
149 149 .bind(project_id)
150 150 .fetch_all(pool)
@@ -305,11 +305,15 @@ pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> {
305 305 Ok(items)
306 306 }
307 307
308 - /// Permanently delete an item by ID.
308 + /// Soft-delete an item (sets deleted_at, recoverable for 7 days).
309 309 #[tracing::instrument(skip_all)]
310 310 pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> {
311 311 sqlx::query(
312 - "DELETE FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
312 + r#"
313 + UPDATE items SET deleted_at = NOW(), is_public = false
314 + WHERE id = $1 AND deleted_at IS NULL
315 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
316 + "#,
313 317 )
314 318 .bind(id)
315 319 .bind(user_id)
@@ -319,6 +323,52 @@ pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<(
319 323 Ok(())
320 324 }
321 325
326 + /// Restore a soft-deleted item.
327 + #[tracing::instrument(skip_all)]
328 + pub async fn restore_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<bool> {
329 + let result = sqlx::query(
330 + r#"
331 + UPDATE items SET deleted_at = NULL
332 + WHERE id = $1 AND deleted_at IS NOT NULL
333 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
334 + "#,
335 + )
336 + .bind(id)
337 + .bind(user_id)
338 + .execute(pool)
339 + .await?;
340 +
341 + Ok(result.rows_affected() > 0)
342 + }
343 +
344 + /// Get soft-deleted items for a project (for the "Recently Deleted" section).
345 + #[tracing::instrument(skip_all)]
346 + pub async fn get_deleted_items_by_project(
347 + pool: &PgPool,
348 + project_id: ProjectId,
349 + ) -> Result<Vec<DbItem>> {
350 + let items = sqlx::query_as::<_, DbItem>(
351 + "SELECT * FROM items WHERE project_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC",
352 + )
353 + .bind(project_id)
354 + .fetch_all(pool)
355 + .await?;
356 +
357 + Ok(items)
358 + }
359 +
360 + /// Permanently delete items that were soft-deleted more than 7 days ago.
361 + #[tracing::instrument(skip_all)]
362 + pub async fn purge_expired_deleted_items(pool: &PgPool) -> Result<u64> {
363 + let result = sqlx::query(
364 + "DELETE FROM items WHERE deleted_at IS NOT NULL AND deleted_at < NOW() - INTERVAL '7 days'",
365 + )
366 + .execute(pool)
367 + .await?;
368 +
369 + Ok(result.rows_affected())
370 + }
371 +
322 372 /// Update the audio S3 key for an item
323 373 #[tracing::instrument(skip_all)]
324 374 pub async fn update_item_audio_s3_key(
@@ -63,6 +63,7 @@ pub(crate) mod pending_refunds;
63 63 pub(crate) mod webhook_events;
64 64 pub(crate) mod scheduler_jobs;
65 65 pub(crate) mod moderation;
66 + pub(crate) mod wishlists;
66 67
67 68 pub use id_types::*;
68 69 pub use validated_types::*;
@@ -110,6 +110,8 @@ pub struct DbItem {
110 110 pub removal_reason: Option<String>,
111 111 /// When the admin removed this item.
112 112 pub removed_at: Option<DateTime<Utc>>,
113 + /// When the creator soft-deleted this item (NULL = not deleted, purged after 7 days).
114 + pub deleted_at: Option<DateTime<Utc>>,
113 115 }
114 116
115 117 /// Content-type-specific data extracted from a `DbItem`.
@@ -321,6 +323,7 @@ mod tests {
321 323 removed_by_admin: false,
322 324 removal_reason: None,
323 325 removed_at: None,
326 + deleted_at: None,
324 327 }
325 328 }
326 329
@@ -0,0 +1,83 @@
1 + //! Wishlist/bookmark queries: fans save items they want to buy later.
2 +
3 + use chrono::{DateTime, Utc};
4 + use sqlx::PgPool;
5 +
6 + use super::{ItemId, UserId};
7 + use crate::error::Result;
8 +
9 + /// Check if an item is in the user's wishlist.
10 + #[tracing::instrument(skip_all)]
11 + pub async fn is_wishlisted(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<bool> {
12 + let exists: bool = sqlx::query_scalar(
13 + "SELECT EXISTS(SELECT 1 FROM wishlists WHERE user_id = $1 AND item_id = $2)",
14 + )
15 + .bind(user_id)
16 + .bind(item_id)
17 + .fetch_one(pool)
18 + .await?;
19 +
20 + Ok(exists)
21 + }
22 +
23 + /// Add an item to the user's wishlist (idempotent).
24 + #[tracing::instrument(skip_all)]
25 + pub async fn add_to_wishlist(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> {
26 + sqlx::query(
27 + "INSERT INTO wishlists (user_id, item_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
28 + )
29 + .bind(user_id)
30 + .bind(item_id)
31 + .execute(pool)
32 + .await?;
33 +
34 + Ok(())
35 + }
36 +
37 + /// Remove an item from the user's wishlist.
38 + #[tracing::instrument(skip_all)]
39 + pub async fn remove_from_wishlist(pool: &PgPool, user_id: UserId, item_id: ItemId) -> Result<()> {
40 + sqlx::query("DELETE FROM wishlists WHERE user_id = $1 AND item_id = $2")
41 + .bind(user_id)
42 + .bind(item_id)
43 + .execute(pool)
44 + .await?;
45 +
46 + Ok(())
47 + }
48 +
49 + /// A wishlisted item with joined display data.
50 + #[derive(Debug, Clone, sqlx::FromRow)]
51 + #[allow(dead_code)]
52 + pub struct WishlistItem {
53 + pub item_id: ItemId,
54 + pub title: String,
55 + pub item_type: String,
56 + pub price_cents: i32,
57 + pub creator: String,
58 + pub added_at: DateTime<Utc>,
59 + }
60 +
61 + /// Get the user's wishlist with item details.
62 + #[allow(dead_code)]
63 + #[tracing::instrument(skip_all)]
64 + pub async fn get_wishlist(pool: &PgPool, user_id: UserId) -> Result<Vec<WishlistItem>> {
65 + let items = sqlx::query_as::<_, WishlistItem>(
66 + r#"
67 + SELECT w.item_id, i.title, i.item_type::TEXT as item_type, i.price_cents,
68 + u.username AS creator, w.created_at AS added_at
69 + FROM wishlists w
70 + JOIN items i ON i.id = w.item_id
71 + JOIN projects p ON p.id = i.project_id
72 + JOIN users u ON u.id = p.user_id
73 + WHERE w.user_id = $1 AND i.is_public = true AND i.deleted_at IS NULL
74 + ORDER BY w.created_at DESC
75 + LIMIT 200
76 + "#,
77 + )
78 + .bind(user_id)
79 + .fetch_all(pool)
80 + .await?;
81 +
82 + Ok(items)
83 + }
@@ -836,6 +836,7 @@ mod tests {
836 836 removed_by_admin: false,
837 837 removal_reason: None,
838 838 removed_at: None,
839 + deleted_at: None,
839 840 }
840 841 }
841 842
@@ -272,7 +272,7 @@ pub(in crate::routes::api) async fn update_item(
272 272 }))
273 273 }
274 274
275 - /// Delete an item owned by the authenticated user.
275 + /// Soft-delete an item owned by the authenticated user (recoverable for 7 days).
276 276 #[tracing::instrument(skip_all, name = "items::delete_item", fields(item_id))]
277 277 pub(in crate::routes::api) async fn delete_item(
278 278 State(state): State<AppState>,
@@ -282,32 +282,32 @@ pub(in crate::routes::api) async fn delete_item(
282 282 ) -> Result<Response> {
283 283 tracing::Span::current().record("item_id", tracing::field::display(&id));
284 284 user.check_not_suspended()?;
285 - let (item, project) = verify_item_ownership(&state, id, user.id).await?;
286 -
287 - // Calculate total file bytes to decrement from storage counter
288 - let file_sizes = db::items::get_item_file_sizes(&state.db, id).await?;
289 - let version_bytes = db::versions::sum_file_sizes_for_item(&state.db, id).await?;
290 - let item_bytes = file_sizes.audio_file_size_bytes.unwrap_or(0)
291 - + file_sizes.cover_file_size_bytes.unwrap_or(0)
292 - + file_sizes.video_file_size_bytes.unwrap_or(0);
293 - let total_bytes = item_bytes + version_bytes;
285 + let (item, _project) = verify_item_ownership(&state, id, user.id).await?;
294 286
295 287 db::items::delete_item(&state.db, id, user.id).await?;
296 288 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
297 289
298 - // Decrement storage counter
299 - if total_bytes > 0 {
300 - db::creator_tiers::decrement_storage_used(&state.db, user.id, total_bytes).await?;
290 + // Storage is reclaimed when the scheduler purges after 7 days
291 +
292 + Ok(crate::helpers::htmx_toast_response("Item moved to Recently Deleted. You can restore it within 7 days.", "success").into_response())
293 + }
294 +
295 + /// Restore a soft-deleted item.
296 + #[tracing::instrument(skip_all, name = "items::restore_item", fields(item_id))]
297 + pub(in crate::routes::api) async fn restore_item(
298 + State(state): State<AppState>,
299 + AuthUser(user): AuthUser,
300 + Path(id): Path<ItemId>,
301 + ) -> Result<impl IntoResponse> {
302 + user.check_not_suspended()?;
303 + verify_item_ownership(&state, id, user.id).await?;
304 +
305 + let restored = db::items::restore_item(&state.db, id, user.id).await?;
306 + if !restored {
307 + return Err(AppError::NotFound);
301 308 }
302 309
303 - let mut response = crate::helpers::htmx_toast_response("Item deleted", "success").into_response();
304 - response.headers_mut().insert(
305 - "HX-Redirect",
306 - format!("/dashboard/project/{}", project.slug)
307 - .parse()
308 - .expect("redirect path is valid"),
309 - );
310 - Ok(response)
310 + Ok(crate::helpers::htmx_toast_response("Item restored", "success"))
311 311 }
312 312
313 313 /// Duplicate an item and its metadata, creating a new draft.
@@ -12,7 +12,7 @@ mod versions;
12 12 pub(super) use bulk::{bulk_delete, bulk_publish, bulk_unpublish};
13 13 pub use bundles::{bundle_add, bundle_remove, bundle_toggle_listed};
14 14 pub(super) use chapters::{create_chapter, delete_chapter, list_chapters, update_chapter};
15 - pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, update_item};
15 + pub(super) use crud::{create_item, delete_item, duplicate_item, move_item, restore_item, update_item};
16 16 pub(super) use refund::refund_transaction;
17 17 pub(super) use sections::{create_section, delete_section, list_sections, reorder_sections, update_section};
18 18 pub(super) use tags::{add_tag, remove_tag, set_primary_tag};
@@ -36,6 +36,7 @@ pub(crate) mod ssh_keys;
36 36 mod reports;
37 37 mod collections;
38 38 mod validate;
39 + mod wishlists;
39 40 mod domains;
40 41 mod guest_checkout;
41 42 mod imports;
@@ -235,6 +236,7 @@ pub fn api_routes() -> Router<AppState> {
235 236 .route("/api/items/{id}/bundle/{child_id}/listed", put(items::bundle_toggle_listed))
236 237 // Refund
237 238 .route("/api/items/{id}/refund", post(items::refund_transaction))
239 + .route("/api/items/{id}/restore", post(items::restore_item))
238 240 // Tag routes (HTMX)
239 241 .route("/api/items/{id}/tags", post(items::add_tag))
240 242 .route("/api/items/{id}/tags/{tag_id}", delete(items::remove_tag))
@@ -330,6 +332,8 @@ pub fn api_routes() -> Router<AppState> {
330 332 .route("/api/collections/{id}/items/{item_id}", post(collections::add_item))
331 333 .route("/api/collections/{id}/items/{item_id}", delete(collections::remove_item))
332 334 .route("/api/collections/{id}/items/reorder", put(collections::reorder_items))
335 + // Wishlists
336 + .route("/api/wishlists/{item_id}", post(wishlists::toggle_wishlist))
333 337 // Custom domains
334 338 .route("/api/domains", post(domains::add_domain))
335 339 .route("/api/domains/verify", post(domains::verify_domain))
@@ -0,0 +1,38 @@
1 + //! Wishlist API: toggle items in the user's wishlist.
2 +
3 + use axum::extract::{Path, State};
4 + use axum::response::IntoResponse;
5 + use axum::Json;
6 +
7 + use crate::{
8 + auth::AuthUser,
9 + db::{self, ItemId},
10 + error::{AppError, Result},
11 + AppState,
12 + };
13 +
14 + /// Toggle an item's wishlist status. Returns the new state.
15 + #[tracing::instrument(skip_all, name = "wishlists::toggle")]
16 + pub(super) async fn toggle_wishlist(
17 + State(state): State<AppState>,
18 + AuthUser(user): AuthUser,
19 + Path(item_id): Path<ItemId>,
20 + ) -> Result<impl IntoResponse> {
21 + // Verify item exists and is public
22 + let item = db::items::get_item_by_id(&state.db, item_id)
23 + .await?
24 + .ok_or(AppError::NotFound)?;
25 + if !item.is_public {
26 + return Err(AppError::NotFound);
27 + }
28 +
29 + let currently_wishlisted = db::wishlists::is_wishlisted(&state.db, user.id, item_id).await?;
30 +
31 + if currently_wishlisted {
32 + db::wishlists::remove_from_wishlist(&state.db, user.id, item_id).await?;
33 + } else {
34 + db::wishlists::add_to_wishlist(&state.db, user.id, item_id).await?;
35 + }
36 +
37 + Ok(Json(serde_json::json!({ "wishlisted": !currently_wishlisted })))
38 + }
@@ -63,6 +63,9 @@ pub(crate) async fn render_item_page(
63 63 if !db_item.is_public && !is_owner {
64 64 return Err(AppError::NotFound);
65 65 }
66 + if db_item.deleted_at.is_some() && !is_owner {
67 + return Err(AppError::NotFound);
68 + }
66 69
67 70 let db_versions = db::versions::get_versions_by_item(&state.db, db_item.id).await?;
68 71
@@ -261,6 +264,12 @@ pub(crate) async fn render_item_page(
261 264 let db_sections = db::item_sections::list_by_item(&state.db, db_item.id).await?;
262 265 let sections: Vec<ItemSection> = db_sections.iter().map(|s| ItemSection::from_db(s, db_project.user_id, cdn_base)).collect();
263 266
267 + let is_wishlisted = if let Some(ref user) = maybe_user {
268 + db::wishlists::is_wishlisted(&state.db, user.id, db_item.id).await.unwrap_or(false)
269 + } else {
270 + false
271 + };
272 +
264 273 Ok(ItemTemplate {
265 274 csrf_token,
266 275 session_user: maybe_user,
@@ -277,6 +286,7 @@ pub(crate) async fn render_item_page(
277 286 containing_bundles: containing_bundle_views,
278 287 sections,
279 288 is_owner,
289 + is_wishlisted,
280 290 }
281 291 .into_response())
282 292 }
@@ -185,6 +185,22 @@ pub(super) async fn scrub_stale_ip_addresses(state: &AppState) {
185 185 }
186 186 }
187 187
188 + /// Permanently delete items that were soft-deleted more than 7 days ago.
189 + pub(super) async fn purge_expired_deleted_items(state: &AppState) {
190 + match db::items::purge_expired_deleted_items(&state.db).await {
191 + Ok(0) => {
192 + let _ = db::scheduler_jobs::record_job_run(&state.db, "soft_delete_purge", 0).await;
193 + }
194 + Ok(n) => {
195 + tracing::info!(deleted = n, "purged expired soft-deleted items");
196 + let _ = db::scheduler_jobs::record_job_run(&state.db, "soft_delete_purge", n as i64).await;
197 + }
198 + Err(e) => {
199 + tracing::error!(error = ?e, "failed to purge expired soft-deleted items");
200 + }
201 + }
202 + }
203 +
188 204 #[cfg(test)]
189 205 mod tests {
190 206 use super::*;
@@ -166,6 +166,9 @@ pub fn spawn_scheduler(
166 166
167 167 // Delete self-deleted creator accounts whose 90-day content grace period has expired
168 168 cleanup::delete_expired_content_removal_accounts(&state).await;
169 +
170 + // Permanently delete soft-deleted items older than 7 days
171 + cleanup::purge_expired_deleted_items(&state).await;
169 172 }
170 173 }
171 174 })
@@ -277,6 +277,8 @@ pub struct ItemTemplate {
277 277 pub sections: Vec<ItemSection>,
278 278 /// Whether the current user is the item's creator (for dashboard links).
279 279 pub is_owner: bool,
280 + /// Whether the current user has wishlisted this item.
281 + pub is_wishlisted: bool,
280 282 }
281 283
282 284 /// Blog/article reader view.
@@ -400,6 +400,11 @@
400 400 <a href="/dashboard/item/{{ item.id }}">Edit</a> &middot;
401 401 <a href="/dashboard/item/{{ item.id }}#embed">Embed</a> &middot;
402 402 {% endif %}
403 + {% if session_user.is_some() && !is_owner %}
404 + <a href="javascript:void(0)" id="wishlist-btn"
405 + onclick="toggleWishlist('{{ item.id }}')"
406 + style="{% if is_wishlisted %}font-weight: bold;{% endif %}">{% if is_wishlisted %}Wishlisted{% else %}Wishlist{% endif %}</a> &middot;
407 + {% endif %}
403 408 <a href="javascript:void(0)" onclick="navigator.clipboard.writeText(window.location.origin + '/i/{{ item.id }}').then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy link', 1500) })">Copy link</a> &middot;
404 409 {% if session_user.is_some() %}
405 410 <a href="javascript:void(0)" onclick="document.getElementById('report-modal').style.display='flex'">Report this item</a> &middot;
@@ -558,6 +563,21 @@ function downloadVersion(versionId) {
558 563 dropdownOpen = false;
559 564 }
560 565 });
566 +
567 + window.toggleWishlist = function(itemId) {
568 + var btn = document.getElementById('wishlist-btn');
569 + fetch('/api/wishlists/' + itemId, { method: 'POST', headers: csrfHeaders() })
570 + .then(function(r) { return r.json(); })
571 + .then(function(data) {
572 + if (data.wishlisted) {
573 + btn.textContent = 'Wishlisted';
574 + btn.style.fontWeight = 'bold';
575 + } else {
576 + btn.textContent = 'Wishlist';
577 + btn.style.fontWeight = '';
578 + }
579 + });
580 + };
561 581 })();
562 582 </script>
563 583 {% endblock %}