Files
Arkie-Library-Frontend/.unipi/docs/specs/2026-06-01-user-favorites-design.md
2026-06-02 00:14:10 +08:00

17 KiB
Raw Blame History

title, type, date
title type date
User Favorites brainstorm 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:

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:

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:

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:

Authorization: Bearer <wallet-jwt>

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

List current user's favorites

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:

{
  "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:

{
  "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:

(download_count + favorite_count + share_count) DESC, updated_at DESC

For sort=published_at:

published_at DESC NULLS LAST, updated_at DESC

For sort=favorited_at:

user_favorites.created_at DESC

Batch favorite status

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

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

Response:

{
  "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

POST /api/me/favorites/{resourceId}

Response:

{
  "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

DELETE /api/me/favorites/{resourceId}

Response:

{
  "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:

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

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

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

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.