---
title: "User Favorites"
type: brainstorm
date: 2026-06-01
---

# User Favorites

## Problem Statement

ARK Library needs a real user-level favorites feature tied to wallet login. Users should be able to save resources for later, see their own saved resources on `/favorites`, and use favorites as a personal library rather than just incrementing a public counter.

The current implementation only has a public `POST /api/resources/{id}/favorite` counter endpoint. It does not know who favorited a resource, does not prevent duplicate favorites, and cannot power a "My Favorites" page. The frontend `/favorites` page is currently a placeholder.

This feature should support user-bound favorites while preserving existing popularity/favorite count behavior for rankings and admin metrics.

## Context

Existing backend/frontend facts:

- Backend `resources.favorite_count` exists and is used in popularity ordering/admin stats.
- Backend currently exposes `POST /api/resources/{id}/favorite` with `{ add: true/false }`, but it is unauthenticated and only changes a global counter.
- Backend wallet auth exists through `/api/auth/wallet/nonce`, `/api/auth/wallet/verify`, and `/api/auth/wallet/me`.
- Wallet login is being designed separately in `2026-06-01-china-friendly-wallet-login-design.md`.
- Frontend `src/pages/Favorites/index.tsx` is a "Coming Soon" page.
- Resource list endpoints return paginated public resources and support filters such as `q`, `category`, and `sort`.

Product decisions from brainstorming:

- Favorites are user-level and keyed by wallet address.
- Favorite target is only `resources.id`, not posts, collections, or arbitrary entities.
- Favorite buttons appear both on resource cards/lists and resource detail/post pages.
- If an unauthenticated user clicks favorite, the wallet login modal opens; after successful login, the original favorite action completes automatically.
- Favorites page supports sortable, filterable, searchable favorites.
- Sort options: favorited time, resource published time, and hot/popular.
- Favorites page supports category filter and keyword search.
- If a favorited resource later becomes unavailable, the favorites page still shows it as unavailable and lets the user remove it.
- Existing favorite counts should be preserved as historical heat rather than reset.

## Chosen Approach

Use **user favorites + batch favorite state + favorites-page query API**.

Backend adds an authenticated `user_favorites` table and `/api/me/favorites` endpoints. Frontend adds a shared favorite state layer, reusable favorite button, batch status lookup for lists, and a real `/favorites` page.

The old unauthenticated favorite counter endpoint should be deprecated or changed so public users cannot freely mutate `favorite_count` without a wallet identity.

## Why This Approach

This approach balances user experience, backend clarity, and future extensibility.

Accepted trade-offs:

- A batch-state endpoint is added so list pages can show filled/unfilled hearts without N requests.
- Favorites page gets its own query API because it needs wallet scoping, sort, category filter, search, pagination, and unavailable-resource handling.
- Favorite counts remain materialized for ranking/admin performance, but backend must maintain them consistently when user favorites change.
- A pending favorite action must survive the wallet-login modal flow so users do not need to click favorite twice.

Rejected alternatives:

1. **Global counter only**
   - Rejected because it cannot power "My Favorites" and can be spammed.

2. **Minimal add/remove/list only**
   - Rejected because resource lists would not know current favorite state efficiently.

3. **Collections/folders**
   - Rejected as out of scope. The current need is simple resource saving, not multi-folder organization.

4. **Polymorphic favorites (`target_type`, `target_id`)**
   - Rejected because only `resources.id` is needed now. Simpler schema is easier to index and reason about.

5. **Reset all historical favorite counts**
   - Rejected because current counts may already contribute to heat/ranking. Preserve them as historical base values.

## Design

### Backend Data Model

Add a wallet-scoped favorites table:

```sql
CREATE TABLE user_favorites (
    wallet_address TEXT NOT NULL,
    resource_id UUID NOT NULL REFERENCES resources(id) ON DELETE CASCADE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (wallet_address, resource_id)
);

CREATE INDEX idx_user_favorites_wallet_created
    ON user_favorites (wallet_address, created_at DESC);

CREATE INDEX idx_user_favorites_resource
    ON user_favorites (resource_id);
```

Wallet addresses should be stored in canonical checksum form if the backend already normalizes wallet auth to checksum addresses. Queries should compare using the same normalized representation.

To preserve historical favorites, add a base count field or equivalent migration strategy:

```sql
ALTER TABLE resources
    ADD COLUMN favorite_base_count INT NOT NULL DEFAULT 0;

UPDATE resources SET favorite_base_count = favorite_count;
```

Then define the visible favorite count as:

```text
visibleFavoriteCount = favorite_base_count + count(user_favorites for resource)
```

Implementation options:

1. Keep `resources.favorite_count` materialized and update it on add/remove:
   - Migration sets `favorite_base_count = favorite_count`.
   - `favorite_count` starts as the existing historical value.
   - Each new user favorite increments/decrements `favorite_count` exactly once.
   - Fast reads, but backend must maintain consistency carefully.

2. Compute `favorite_base_count + COUNT(user_favorites)` in queries:
   - Most accurate by construction.
   - May require careful indexing or view/materialization for popular sorting.

Recommended: keep `resources.favorite_count` materialized for existing popularity/admin queries, but make all future changes go through authenticated user-favorite endpoints. Add a periodic or admin-only consistency check later if needed.

### Backend API Contract

All `/api/me/favorites*` endpoints require wallet JWT:

```http
Authorization: Bearer <wallet-jwt>
```

The backend identifies the wallet address from the JWT, not from request body.

#### List current user's favorites

```http
GET /api/me/favorites?sort=favorited_at&page=1&limit=24&category=project-ppt&q=ark&includeUnavailable=true
```

Query params:

| Param | Values | Default | Notes |
|---|---|---|---|
| `page` | positive integer | `1` | Page number |
| `limit` | `1..100` | `24` | Page size |
| `sort` | `favorited_at`, `published_at`, `hot` | `favorited_at` | `hot` uses popularity score |
| `category` | category slug | none | Optional category filter |
| `q` | text | none | Search title/description/tag/body as appropriate |
| `includeUnavailable` | `true`, `false` | `true` | Whether to include unpublished/private/deleted-like resources still referenced by favorites |
| `lang` | UI language code | optional | Category display language, matching existing resources endpoints |

Response:

```json
{
  "items": [
    {
      "favoritedAt": "2026-06-01T12:00:00Z",
      "resource": {
        "id": "uuid",
        "title": "ARK resource title",
        "description": "...",
        "type": "video",
        "language": "zh-TW",
        "categoryId": 1,
        "categorySlug": "project-ppt",
        "categoryName": "項目資料（PPT）",
        "coverImage": "/uploads/cover.png",
        "fileUrl": "/uploads/file.pdf",
        "previewUrl": "/uploads/preview.mp4",
        "externalUrl": null,
        "isDownloadable": true,
        "isRecommended": false,
        "publishedAt": "2026-05-01T12:00:00Z",
        "updatedAt": "2026-05-02T12:00:00Z",
        "tags": ["官方推薦"],
        "favoriteCount": 12,
        "availability": "available"
      }
    }
  ],
  "page": 1,
  "limit": 24,
  "total": 1
}
```

Unavailable resources should return enough metadata for the favorites page to show the item and allow removal. Suggested shape:

```json
{
  "favoritedAt": "2026-06-01T12:00:00Z",
  "resource": {
    "id": "uuid",
    "title": "Previously favorited resource",
    "categoryName": "...",
    "updatedAt": "2026-05-02T12:00:00Z",
    "favoriteCount": 12,
    "availability": "unavailable",
    "unavailableReason": "unpublished"
  }
}
```

For `sort=hot`, use the same general popularity concept as existing popular resources, for example:

```sql
(download_count + favorite_count + share_count) DESC, updated_at DESC
```

For `sort=published_at`:

```sql
published_at DESC NULLS LAST, updated_at DESC
```

For `sort=favorited_at`:

```sql
user_favorites.created_at DESC
```

#### Batch favorite status

```http
GET /api/me/favorites/ids?resourceIds=id1,id2,id3
```

Returns which of the provided resource IDs are favorited by the authenticated wallet.

Response:

```json
{
  "ids": ["id1", "id3"]
}
```

Rules:

- `resourceIds` may be comma-separated.
- Backend should cap number of IDs, e.g. max 100.
- Unknown IDs are ignored.
- Requires wallet JWT.

#### Add favorite

```http
POST /api/me/favorites/{resourceId}
```

Response:

```json
{
  "ok": true,
  "resourceId": "uuid",
  "favorited": true,
  "favoritedAt": "2026-06-01T12:00:00Z",
  "favoriteCount": 13
}
```

Rules:

- Requires wallet JWT.
- Idempotent: if already favorited, return success without double incrementing count.
- Should allow favoriting only existing resources.
- Product preference: favoriting unavailable/private resources from public UI should not normally happen; backend may reject unavailable resources for new favorites with `404` or `409`.

#### Remove favorite

```http
DELETE /api/me/favorites/{resourceId}
```

Response:

```json
{
  "ok": true,
  "resourceId": "uuid",
  "favorited": false,
  "favoriteCount": 12
}
```

Rules:

- Requires wallet JWT.
- Idempotent: if not favorited, return success without decrementing count.
- If resource is unavailable but favorite row exists, removal should still work.

#### Legacy counter endpoint

Existing endpoint:

```http
POST /api/resources/{id}/favorite
```

Should be deprecated for public use. Options:

1. Return `410 Gone` or `405 Method Not Allowed` once the new feature ships.
2. Keep it temporarily but route authenticated requests to `POST/DELETE /api/me/favorites/{resourceId}` semantics.
3. Keep only for backwards compatibility during deploy, then remove from docs.

Recommended: deprecate it in docs and stop frontend usage. Do not allow unauthenticated clients to mutate user-visible favorite counts.

### Frontend Components and State

Add a shared favorites layer:

1. **Favorites API client**
   - `listFavorites(params, token)`
   - `getFavoriteIds(resourceIds, token)`
   - `addFavorite(resourceId, token)`
   - `removeFavorite(resourceId, token)`

2. **Favorites state/provider or hook**
   - Tracks favorite IDs for currently visible resources.
   - Provides `isFavorite(resourceId)`.
   - Provides `toggleFavorite(resourceId)`.
   - Handles pending actions while wallet login is in progress.
   - Clears state on wallet logout.

3. **FavoriteButton**
   - Reusable heart button for cards and detail pages.
   - Supports states: idle, favorited, loading, disabled/unavailable.
   - Has localized accessible labels:
     - Add to favorites
     - Remove from favorites
     - Login to favorite

4. **Favorites page**
   - Replaces Coming Soon placeholder.
   - Shows list/grid of favorited resources.
   - Supports sort tabs/dropdown: favorited time, published time, hot.
   - Supports category filter.
   - Supports search input scoped to current user's favorites.
   - Shows empty states:
     - Not logged in: prompt to connect wallet.
     - Logged in but no favorites: prompt to browse resources.
     - Filter/search no results: prompt to clear filters.
   - Shows unavailable items with clear badge and remove action.

### Frontend Data Flow

#### Resource list pages

```text
Resource list endpoint returns items
↓
If wallet logged in, call /api/me/favorites/ids with visible resource IDs
↓
FavoriteButton receives favorited state
↓
User toggles favorite
↓
Optimistically update UI
↓
POST/DELETE backend
↓
On success, reconcile favoriteCount if returned
↓
On failure, rollback and show error
```

#### Unauthenticated favorite click

```text
User clicks FavoriteButton while logged out
↓
Store pending action: { type: "favorite", resourceId }
↓
Open wallet login modal
↓
Wallet login succeeds
↓
Run pending favorite action with new token
↓
Update button state and count
```

If login is cancelled, the pending action is cleared and no favorite is added.

#### Favorites page

```text
User opens /favorites
↓
If logged out, show login prompt
↓
If logged in, call /api/me/favorites with sort/filter/search/page
↓
Render resources with favorited=true
↓
Removing an item updates list immediately
```

### Localization

New UI copy must be added to all supported locale files:

- `zh-CN`
- `en`
- `ko`
- `ja`
- `vi`
- `id`
- `ms`

Suggested keys:

- `favoriteAdd`
- `favoriteRemove`
- `favoriteLoginRequired`
- `favoriteAdded`
- `favoriteRemoved`
- `favoritesEmptyTitle`
- `favoritesEmptyDesc`
- `favoritesFilterAllCategories`
- `favoritesSortFavoritedAt`
- `favoritesSortPublishedAt`
- `favoritesSortHot`
- `favoritesSearchPlaceholder`
- `favoritesUnavailable`
- `favoritesClearFilters`

### Error Handling

- `401` from favorites API: clear wallet session or prompt re-login.
- `404` add favorite: resource no longer available; show message and refresh list.
- Network error during toggle: rollback optimistic state and show retryable error.
- Login cancelled after favorite click: do nothing and keep resource un-favorited.
- Batch favorite IDs fails on list pages: leave buttons unfilled but clickable; clicking can still prompt login or retry.
- Remove unavailable favorite fails: keep item visible and show retryable error.

### Testing

Frontend tests should cover:

- FavoriteButton renders add/remove/loading states.
- Unauthenticated click opens wallet login and completes pending favorite after login.
- Toggle favorite performs optimistic update and rollback on error.
- Batch favorite IDs marks visible resources correctly.
- Favorites page handles logged-out, empty, results, unavailable, filtered, and error states.
- Logout clears favorite state.

Backend tests should cover:

- Add favorite creates exactly one row and increments count once.
- Re-adding existing favorite is idempotent and does not double-count.
- Remove favorite deletes row and decrements count once.
- Removing missing favorite is idempotent and does not decrement.
- Batch IDs returns only IDs favorited by the current wallet.
- Favorites list respects wallet scoping, sort, category, search, pagination, and includeUnavailable.
- Legacy public counter endpoint no longer allows unauthenticated count manipulation.

## Implementation Checklist

- [ ] Confirm backend wallet JWT middleware can protect `/api/me/*` routes.
- [ ] Add backend migration for `user_favorites` and favorite count preservation.
- [ ] Decide exact count maintenance strategy: materialized `resources.favorite_count` vs computed count.
- [ ] Add `GET /api/me/favorites` with sort/filter/search/pagination/unavailable support.
- [ ] Add `GET /api/me/favorites/ids` batch status endpoint.
- [ ] Add `POST /api/me/favorites/{resourceId}` idempotent add endpoint.
- [ ] Add `DELETE /api/me/favorites/{resourceId}` idempotent remove endpoint.
- [ ] Deprecate or disable unauthenticated `POST /api/resources/{id}/favorite`.
- [ ] Update backend API docs for favorites and legacy endpoint behavior.
- [ ] Add frontend favorites API client.
- [ ] Add frontend favorites state/hook with pending post-login action support.
- [ ] Add reusable `FavoriteButton` component.
- [ ] Add favorite buttons to resource cards/list components.
- [ ] Add favorite button to detail/post page UI.
- [ ] Replace `/favorites` Coming Soon page with real favorites list UI.
- [ ] Add sorting, category filter, and scoped search to favorites page.
- [ ] Add unavailable-resource display and remove action.
- [ ] Add localized copy for all supported languages.
- [ ] Add frontend tests for favorite button, pending login action, batch state, and favorites page states.
- [ ] Add backend tests for add/remove/list/batch/count/deprecated endpoint behavior.

## Open Questions

1. Should unavailable resources expose title/category only, or also old cover/description if still present in the database?
2. Should newly adding a favorite be allowed for draft/private resources if a logged-in user somehow knows the ID? Recommendation: no.
3. Should favorite counts update immediately in all visible lists after toggle, or only the clicked card? Recommendation: clicked card immediately; other instances can update when state is shared.
4. Should wallet address casing be stored as checksum exactly or lowercase canonical form? It must match wallet auth claims consistently.
5. Should the legacy public favorite endpoint be removed immediately or kept temporarily during deploy for backwards compatibility?

## Out of Scope

- Wallet login implementation details.
- TokenPocket/RainbowKit login flows.
- Collections/folders for favorites.
- Sharing favorites publicly.
- Admin editing of user favorites.
- Import/export of favorites.
- Notifications when favorited resources update.
- Translating backend-returned resource content.
