terry-staging #16
194
src/App.tsx
194
src/App.tsx
@@ -3,6 +3,7 @@ import { I18nProvider } from "./i18n";
|
||||
import { MotionProvider } from "./motion";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
||||
import { FavoritesProvider } from "./favorites/FavoritesProvider";
|
||||
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
||||
import { WalletLoginModal } from "./wallet/WalletLoginModal";
|
||||
import { WalletProvider } from "./wallet/WalletProvider";
|
||||
@@ -33,104 +34,111 @@ export default function App() {
|
||||
<ToastProvider>
|
||||
<RainbowWalletProvider>
|
||||
<WalletProvider>
|
||||
<SaveToAlbumGuideProvider>
|
||||
<AdminRouterModeProvider value="absolute">
|
||||
<ImageLightboxProvider>
|
||||
<VideoPlayerProvider>
|
||||
<PageTitleProvider>
|
||||
<WalletLoginModal />
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
{/* English (root, no prefix) */}
|
||||
<Route
|
||||
path="/"
|
||||
element={<LocalizedHomePage targetLang="en" />}
|
||||
/>
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route
|
||||
path="/categories"
|
||||
element={<CategoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route
|
||||
path="/resource/:id"
|
||||
element={<PostRedirect />}
|
||||
/>
|
||||
<Route
|
||||
path="/favorites"
|
||||
element={<Favorites />}
|
||||
/>
|
||||
<FavoritesProvider>
|
||||
<SaveToAlbumGuideProvider>
|
||||
<AdminRouterModeProvider value="absolute">
|
||||
<ImageLightboxProvider>
|
||||
<VideoPlayerProvider>
|
||||
<PageTitleProvider>
|
||||
<WalletLoginModal />
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
{/* English (root, no prefix) */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<LocalizedHomePage targetLang="en" />
|
||||
}
|
||||
/>
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route
|
||||
path="/categories"
|
||||
element={<CategoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/search"
|
||||
element={<SearchPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/resource/:id"
|
||||
element={<PostRedirect />}
|
||||
/>
|
||||
<Route
|
||||
path="/favorites"
|
||||
element={<Favorites />}
|
||||
/>
|
||||
|
||||
{/* Each non-English language gets its own nested tree. */}
|
||||
{localizedHomeRoutes.map((route) => (
|
||||
<Route key={route.path} path={route.path}>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<LocalizedHomePage
|
||||
targetLang={route.lang}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="browse" element={<Browse />} />
|
||||
<Route
|
||||
path="categories"
|
||||
element={<CategoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route
|
||||
path="search"
|
||||
element={<SearchPage />}
|
||||
/>
|
||||
<Route
|
||||
path="resource/:id"
|
||||
element={<PostRedirect />}
|
||||
/>
|
||||
<Route
|
||||
path="favorites"
|
||||
element={<Favorites />}
|
||||
/>
|
||||
</Route>
|
||||
))}
|
||||
</Route>
|
||||
{/* Each non-English language gets its own nested tree. */}
|
||||
{localizedHomeRoutes.map((route) => (
|
||||
<Route key={route.path} path={route.path}>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<LocalizedHomePage
|
||||
targetLang={route.lang}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="browse" element={<Browse />} />
|
||||
<Route
|
||||
path="categories"
|
||||
element={<CategoriesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="official-recommendations"
|
||||
element={<OfficialRecommendationsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="category/:slug"
|
||||
element={<CategoryPage />}
|
||||
/>
|
||||
<Route
|
||||
path="search"
|
||||
element={<SearchPage />}
|
||||
/>
|
||||
<Route
|
||||
path="resource/:id"
|
||||
element={<PostRedirect />}
|
||||
/>
|
||||
<Route
|
||||
path="favorites"
|
||||
element={<Favorites />}
|
||||
/>
|
||||
</Route>
|
||||
))}
|
||||
</Route>
|
||||
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
path="*"
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</PageTitleProvider>
|
||||
</VideoPlayerProvider>
|
||||
</ImageLightboxProvider>
|
||||
</AdminRouterModeProvider>
|
||||
</SaveToAlbumGuideProvider>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</PageTitleProvider>
|
||||
</VideoPlayerProvider>
|
||||
</ImageLightboxProvider>
|
||||
</AdminRouterModeProvider>
|
||||
</SaveToAlbumGuideProvider>
|
||||
</FavoritesProvider>
|
||||
</WalletProvider>
|
||||
</RainbowWalletProvider>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -261,6 +261,8 @@ export type Resource = {
|
||||
isRecommended: boolean;
|
||||
publishedAt?: string;
|
||||
updatedAt: string;
|
||||
favoriteCount?: number;
|
||||
availability?: "available" | "unavailable";
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { Post } from "../types/post";
|
||||
import { downloadAttachment } from "./messageStream/utils/downloadFile";
|
||||
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
|
||||
import { useToast } from "./Toast";
|
||||
import { FavoriteButton } from "../favorites/FavoriteButton";
|
||||
|
||||
const MEDALS = ["🥇", "🥈", "🥉"];
|
||||
const MAX_ITEMS = 5;
|
||||
@@ -174,6 +175,7 @@ function PopularRankRow({
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
||||
<FavoriteButton resourceId={r.id} />
|
||||
{r.isDownloadable ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./messageStream/utils/downloadFile";
|
||||
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
|
||||
import { useToast } from "./Toast";
|
||||
import { FavoriteButton } from "../favorites/FavoriteButton";
|
||||
|
||||
function isPlaceholderAsset(path: string | undefined | null) {
|
||||
return !path || path.includes("placeholder-cover");
|
||||
@@ -133,6 +134,11 @@ export function RecommendedCard({
|
||||
{r.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<FavoriteButton
|
||||
resourceId={r.id}
|
||||
size="sm"
|
||||
className="absolute right-3 top-3 z-20 shadow-lg shadow-black/30"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AlbumBubble } from "./bubbles/AlbumBubble";
|
||||
import { VideoBubble } from "./bubbles/VideoBubble";
|
||||
import { LinkPreviewCard } from "./LinkPreviewCard";
|
||||
import { formatDateTime } from "./utils/formatTime";
|
||||
import { FavoriteButton } from "../../favorites/FavoriteButton";
|
||||
|
||||
type BubbleComponent = ComponentType<{ post: Post }>;
|
||||
|
||||
@@ -53,6 +54,11 @@ export function MessageBubble({
|
||||
isVisual ? "p-0" : "px-4 py-3"
|
||||
}`}
|
||||
>
|
||||
<FavoriteButton
|
||||
resourceId={post.id}
|
||||
size="sm"
|
||||
className="absolute right-3 top-3 z-20 shadow-lg shadow-black/30"
|
||||
/>
|
||||
<Bubble post={post} />
|
||||
{post.linkPreview ? (
|
||||
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { FilterChips } from "./FilterChips";
|
||||
import { MessageBubble } from "./MessageBubble";
|
||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||
import { usePostStream } from "./hooks/usePostStream";
|
||||
import { useFavorites } from "../../favorites/FavoritesProvider";
|
||||
|
||||
export type MessageStreamProps = {
|
||||
scope: PostScope;
|
||||
@@ -30,9 +31,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
||||
|
||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||
usePostStream(params);
|
||||
const { ensureFavoriteIds } = useFavorites();
|
||||
const groups = useGroupedByDay(items, lang);
|
||||
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
||||
|
||||
useEffect(() => {
|
||||
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
|
||||
}, [ensureFavoriteIds, items]);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const filterBarRef = useRef<HTMLDivElement>(null);
|
||||
const hasMoreRef = useRef(hasMore);
|
||||
|
||||
61
src/favorites/FavoriteButton.tsx
Normal file
61
src/favorites/FavoriteButton.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Heart, LoaderCircle } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useFavorites } from "./FavoritesProvider";
|
||||
|
||||
type FavoriteButtonProps = {
|
||||
resourceId: string;
|
||||
className?: string;
|
||||
size?: "sm" | "md";
|
||||
};
|
||||
|
||||
export function FavoriteButton({
|
||||
resourceId,
|
||||
className = "",
|
||||
size = "md",
|
||||
}: FavoriteButtonProps) {
|
||||
const { t } = useI18n();
|
||||
const favorites = useFavorites();
|
||||
const status = favorites.statusFor(resourceId);
|
||||
const pending = favorites.pendingIds.has(resourceId);
|
||||
const isFavorite = status === "favorited";
|
||||
|
||||
useEffect(() => {
|
||||
void favorites.ensureFavoriteIds([resourceId]).catch(() => undefined);
|
||||
}, [favorites, resourceId]);
|
||||
|
||||
const dimension = size === "sm" ? "h-9 w-9" : "h-10 w-10";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void favorites.toggleFavorite(resourceId).catch(() => undefined);
|
||||
}}
|
||||
disabled={pending}
|
||||
aria-pressed={isFavorite}
|
||||
aria-label={isFavorite ? t("favoriteRemove") : t("favoriteAdd")}
|
||||
title={isFavorite ? t("favoriteRemove") : t("favoriteAdd")}
|
||||
className={[
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full border outline-none transition active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait disabled:opacity-70",
|
||||
dimension,
|
||||
isFavorite
|
||||
? "border-ark-gold/60 bg-ark-gold text-black hover:bg-ark-gold2"
|
||||
: "border-white/10 bg-[#191921]/90 text-white hover:border-ark-gold/50 hover:bg-ark-gold/10 hover:text-ark-gold",
|
||||
className,
|
||||
].join(" ")}
|
||||
>
|
||||
{pending ? (
|
||||
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.2} />
|
||||
) : (
|
||||
<Heart
|
||||
className="h-5 w-5"
|
||||
strokeWidth={2.2}
|
||||
fill={isFavorite ? "currentColor" : "none"}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
167
src/favorites/FavoritesProvider.tsx
Normal file
167
src/favorites/FavoritesProvider.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useWallet } from "../wallet/WalletProvider";
|
||||
import { addFavorite, getFavoriteIds, removeFavorite } from "./api";
|
||||
|
||||
type FavoriteStatus = "unknown" | "favorited" | "notFavorited";
|
||||
|
||||
type FavoritesContextValue = {
|
||||
favoriteIds: Set<string>;
|
||||
pendingIds: Set<string>;
|
||||
statusFor: (resourceId: string) => FavoriteStatus;
|
||||
ensureFavoriteIds: (resourceIds: string[]) => Promise<void>;
|
||||
toggleFavorite: (resourceId: string) => Promise<void>;
|
||||
markFavorite: (resourceId: string, favorited: boolean) => void;
|
||||
};
|
||||
|
||||
const FavoritesContext = createContext<FavoritesContextValue | null>(null);
|
||||
|
||||
export function FavoritesProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useI18n();
|
||||
const { showToast } = useToast();
|
||||
const wallet = useWallet();
|
||||
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(() => new Set());
|
||||
const [knownIds, setKnownIds] = useState<Set<string>>(() => new Set());
|
||||
const [pendingIds, setPendingIds] = useState<Set<string>>(() => new Set());
|
||||
const pendingAfterLoginRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (wallet.status === "loggedOut") {
|
||||
setFavoriteIds(new Set());
|
||||
setKnownIds(new Set());
|
||||
setPendingIds(new Set());
|
||||
pendingAfterLoginRef.current = null;
|
||||
}
|
||||
}, [wallet.status]);
|
||||
|
||||
const markFavorite = useCallback((resourceId: string, favorited: boolean) => {
|
||||
setKnownIds((prev) => new Set(prev).add(resourceId));
|
||||
setFavoriteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (favorited) next.add(resourceId);
|
||||
else next.delete(resourceId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const ensureFavoriteIds = useCallback(
|
||||
async (resourceIds: string[]) => {
|
||||
if (!wallet.token || wallet.status !== "loggedIn") return;
|
||||
const missing = [...new Set(resourceIds)].filter(
|
||||
(id) => !knownIds.has(id),
|
||||
);
|
||||
if (missing.length === 0) return;
|
||||
const ids = await getFavoriteIds(wallet.token, missing);
|
||||
setKnownIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
missing.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
setFavoriteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.add(id));
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[knownIds, wallet.status, wallet.token],
|
||||
);
|
||||
|
||||
const runFavoriteMutation = useCallback(
|
||||
async (resourceId: string) => {
|
||||
if (!wallet.token) return;
|
||||
const currentlyFavorite = favoriteIds.has(resourceId);
|
||||
setPendingIds((prev) => new Set(prev).add(resourceId));
|
||||
markFavorite(resourceId, !currentlyFavorite);
|
||||
try {
|
||||
if (currentlyFavorite) await removeFavorite(wallet.token, resourceId);
|
||||
else await addFavorite(wallet.token, resourceId);
|
||||
showToast(
|
||||
currentlyFavorite ? t("favoriteRemoved") : t("favoriteAdded"),
|
||||
);
|
||||
} catch (error) {
|
||||
markFavorite(resourceId, currentlyFavorite);
|
||||
showToast(t("favoriteFailed"), "error");
|
||||
throw error;
|
||||
} finally {
|
||||
setPendingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(resourceId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[favoriteIds, markFavorite, showToast, t, wallet.token],
|
||||
);
|
||||
|
||||
const toggleFavorite = useCallback(
|
||||
async (resourceId: string) => {
|
||||
if (!wallet.token || wallet.status !== "loggedIn") {
|
||||
pendingAfterLoginRef.current = resourceId;
|
||||
wallet.openLoginModal();
|
||||
showToast(t("favoriteLoginRequired"));
|
||||
return;
|
||||
}
|
||||
await runFavoriteMutation(resourceId);
|
||||
},
|
||||
[runFavoriteMutation, showToast, t, wallet],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (wallet.status !== "loggedIn" || !wallet.token) return;
|
||||
const pending = pendingAfterLoginRef.current;
|
||||
if (!pending) return;
|
||||
pendingAfterLoginRef.current = null;
|
||||
void runFavoriteMutation(pending).catch(() => undefined);
|
||||
}, [runFavoriteMutation, wallet.status, wallet.token]);
|
||||
|
||||
const statusFor = useCallback(
|
||||
(resourceId: string): FavoriteStatus => {
|
||||
if (favoriteIds.has(resourceId)) return "favorited";
|
||||
if (knownIds.has(resourceId)) return "notFavorited";
|
||||
return "unknown";
|
||||
},
|
||||
[favoriteIds, knownIds],
|
||||
);
|
||||
|
||||
const value = useMemo<FavoritesContextValue>(
|
||||
() => ({
|
||||
favoriteIds,
|
||||
pendingIds,
|
||||
statusFor,
|
||||
ensureFavoriteIds,
|
||||
toggleFavorite,
|
||||
markFavorite,
|
||||
}),
|
||||
[
|
||||
ensureFavoriteIds,
|
||||
favoriteIds,
|
||||
markFavorite,
|
||||
pendingIds,
|
||||
statusFor,
|
||||
toggleFavorite,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<FavoritesContext.Provider value={value}>
|
||||
{children}
|
||||
</FavoritesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFavorites() {
|
||||
const ctx = useContext(FavoritesContext);
|
||||
if (!ctx)
|
||||
throw new Error("useFavorites must be used within FavoritesProvider");
|
||||
return ctx;
|
||||
}
|
||||
98
src/favorites/api.ts
Normal file
98
src/favorites/api.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { apiBase, type Resource } from "../api";
|
||||
|
||||
export type FavoriteSort = "favorited_at" | "published_at" | "hot";
|
||||
|
||||
export type FavoriteItem = {
|
||||
favoritedAt: string;
|
||||
resource: Resource;
|
||||
};
|
||||
|
||||
export type FavoriteListResponse = {
|
||||
items: FavoriteItem[];
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type FavoriteIdsResponse = {
|
||||
ids: string[];
|
||||
};
|
||||
|
||||
export type FavoriteMutationResponse = {
|
||||
ok: boolean;
|
||||
resourceId: string;
|
||||
favorited: boolean;
|
||||
favoritedAt?: string;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
function authHeaders(token: string): HeadersInit {
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
async function parseJSON<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listFavorites(
|
||||
token: string,
|
||||
params: {
|
||||
sort?: FavoriteSort;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
q?: string;
|
||||
includeUnavailable?: boolean;
|
||||
lang?: string;
|
||||
} = {},
|
||||
): Promise<FavoriteListResponse> {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") return;
|
||||
sp.set(key, String(value));
|
||||
});
|
||||
const suffix = sp.toString() ? `?${sp}` : "";
|
||||
const res = await fetch(`${apiBase}/api/me/favorites${suffix}`, {
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
return parseJSON<FavoriteListResponse>(res);
|
||||
}
|
||||
|
||||
export async function getFavoriteIds(
|
||||
token: string,
|
||||
resourceIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (resourceIds.length === 0) return [];
|
||||
const uniqueIds = [...new Set(resourceIds)].slice(0, 100);
|
||||
const res = await fetch(
|
||||
`${apiBase}/api/me/favorites/ids?resourceIds=${encodeURIComponent(
|
||||
uniqueIds.join(","),
|
||||
)}`,
|
||||
{ headers: authHeaders(token) },
|
||||
);
|
||||
const data = await parseJSON<FavoriteIdsResponse>(res);
|
||||
return data.ids;
|
||||
}
|
||||
|
||||
export async function addFavorite(
|
||||
token: string,
|
||||
resourceId: string,
|
||||
): Promise<FavoriteMutationResponse> {
|
||||
const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
return parseJSON<FavoriteMutationResponse>(res);
|
||||
}
|
||||
|
||||
export async function removeFavorite(
|
||||
token: string,
|
||||
resourceId: string,
|
||||
): Promise<FavoriteMutationResponse> {
|
||||
const res = await fetch(`${apiBase}/api/me/favorites/${resourceId}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(token),
|
||||
});
|
||||
return parseJSON<FavoriteMutationResponse>(res);
|
||||
}
|
||||
@@ -139,6 +139,12 @@ export const enDict: Dict = {
|
||||
adminSearchQuery: "Query",
|
||||
adminSearchTime: "Time",
|
||||
adminSearchId: "ID",
|
||||
favoriteAdd: "Add to favorites",
|
||||
favoriteRemove: "Remove from favorites",
|
||||
favoriteAdded: "Added to favorites",
|
||||
favoriteRemoved: "Removed from favorites",
|
||||
favoriteFailed: "Could not update favorites",
|
||||
favoriteLoginRequired: "Connect your wallet to save favorites",
|
||||
favorites: "My Favorites",
|
||||
favoritesComingSoon: "Coming Soon",
|
||||
favoritesComingSoonDesc:
|
||||
|
||||
@@ -139,6 +139,12 @@ export const idDict: Dict = {
|
||||
adminSearchQuery: "Kueri",
|
||||
adminSearchTime: "Waktu",
|
||||
adminSearchId: "ID",
|
||||
favoriteAdd: "Tambah ke favorit",
|
||||
favoriteRemove: "Hapus dari favorit",
|
||||
favoriteAdded: "Ditambahkan ke favorit",
|
||||
favoriteRemoved: "Dihapus dari favorit",
|
||||
favoriteFailed: "Tidak dapat memperbarui favorit",
|
||||
favoriteLoginRequired: "Hubungkan dompet untuk menyimpan favorit",
|
||||
favorites: "Favorit Saya",
|
||||
favoritesComingSoon: "Segera Hadir",
|
||||
favoritesComingSoonDesc:
|
||||
|
||||
@@ -140,6 +140,12 @@ export const jaDict: Dict = {
|
||||
adminSearchQuery: "検索キーワード",
|
||||
adminSearchTime: "時刻",
|
||||
adminSearchId: "ID",
|
||||
favoriteAdd: "お気に入りに追加",
|
||||
favoriteRemove: "お気に入りから削除",
|
||||
favoriteAdded: "お気に入りに追加しました",
|
||||
favoriteRemoved: "お気に入りから削除しました",
|
||||
favoriteFailed: "お気に入りを更新できませんでした",
|
||||
favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です",
|
||||
favorites: "お気に入り",
|
||||
favoritesComingSoon: "近日公開",
|
||||
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
||||
|
||||
@@ -139,6 +139,12 @@ export const koDict: Dict = {
|
||||
adminSearchQuery: "검색어",
|
||||
adminSearchTime: "시간",
|
||||
adminSearchId: "ID",
|
||||
favoriteAdd: "즐겨찾기에 추가",
|
||||
favoriteRemove: "즐겨찾기에서 제거",
|
||||
favoriteAdded: "즐겨찾기에 추가되었습니다",
|
||||
favoriteRemoved: "즐겨찾기에서 제거되었습니다",
|
||||
favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다",
|
||||
favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요",
|
||||
favorites: "내 즐겨찾기",
|
||||
favoritesComingSoon: "출시 예정",
|
||||
favoritesComingSoonDesc:
|
||||
|
||||
@@ -139,6 +139,12 @@ export const msDict: Dict = {
|
||||
adminSearchQuery: "Kata kunci",
|
||||
adminSearchTime: "Masa",
|
||||
adminSearchId: "ID",
|
||||
favoriteAdd: "Tambah ke kegemaran",
|
||||
favoriteRemove: "Buang daripada kegemaran",
|
||||
favoriteAdded: "Ditambah ke kegemaran",
|
||||
favoriteRemoved: "Dibuang daripada kegemaran",
|
||||
favoriteFailed: "Tidak dapat mengemas kini kegemaran",
|
||||
favoriteLoginRequired: "Sambung dompet untuk menyimpan kegemaran",
|
||||
favorites: "Kegemaran Saya",
|
||||
favoritesComingSoon: "Akan Hadir",
|
||||
favoritesComingSoonDesc:
|
||||
|
||||
@@ -139,6 +139,12 @@ export const viDict: Dict = {
|
||||
adminSearchQuery: "Từ khóa",
|
||||
adminSearchTime: "Thời gian",
|
||||
adminSearchId: "ID",
|
||||
favoriteAdd: "Thêm vào yêu thích",
|
||||
favoriteRemove: "Xóa khỏi yêu thích",
|
||||
favoriteAdded: "Đã thêm vào yêu thích",
|
||||
favoriteRemoved: "Đã xóa khỏi yêu thích",
|
||||
favoriteFailed: "Không thể cập nhật yêu thích",
|
||||
favoriteLoginRequired: "Kết nối ví để lưu yêu thích",
|
||||
favorites: "Yêu thích của tôi",
|
||||
favoritesComingSoon: "Sắp ra mắt",
|
||||
favoritesComingSoonDesc:
|
||||
|
||||
@@ -137,6 +137,12 @@ export const zhDict: Dict = {
|
||||
adminSearchQuery: "查询词",
|
||||
adminSearchTime: "时间",
|
||||
adminSearchId: "编号",
|
||||
favoriteAdd: "加入收藏",
|
||||
favoriteRemove: "取消收藏",
|
||||
favoriteAdded: "已加入收藏",
|
||||
favoriteRemoved: "已取消收藏",
|
||||
favoriteFailed: "无法更新收藏",
|
||||
favoriteLoginRequired: "请先连接钱包再收藏",
|
||||
favorites: "我的收藏",
|
||||
favoritesComingSoon: "功能即将推出",
|
||||
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
||||
|
||||
Reference in New Issue
Block a user