505 lines
17 KiB
Markdown
505 lines
17 KiB
Markdown
---
|
||
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.
|