terry-wallet-login #15
12
src/App.tsx
12
src/App.tsx
@@ -3,6 +3,7 @@ import { I18nProvider } from "./i18n";
|
|||||||
import { MotionProvider } from "./motion";
|
import { MotionProvider } from "./motion";
|
||||||
import { ToastProvider } from "./components/Toast";
|
import { ToastProvider } from "./components/Toast";
|
||||||
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
import { SaveToAlbumGuideProvider } from "./components/SaveToAlbumGuide";
|
||||||
|
import { FavoritesProvider } from "./favorites/FavoritesProvider";
|
||||||
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
import { RainbowWalletProvider } from "./wallet/RainbowWalletProvider";
|
||||||
import { WalletLoginModal } from "./wallet/WalletLoginModal";
|
import { WalletLoginModal } from "./wallet/WalletLoginModal";
|
||||||
import { WalletProvider } from "./wallet/WalletProvider";
|
import { WalletProvider } from "./wallet/WalletProvider";
|
||||||
@@ -33,6 +34,7 @@ export default function App() {
|
|||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RainbowWalletProvider>
|
<RainbowWalletProvider>
|
||||||
<WalletProvider>
|
<WalletProvider>
|
||||||
|
<FavoritesProvider>
|
||||||
<SaveToAlbumGuideProvider>
|
<SaveToAlbumGuideProvider>
|
||||||
<AdminRouterModeProvider value="absolute">
|
<AdminRouterModeProvider value="absolute">
|
||||||
<ImageLightboxProvider>
|
<ImageLightboxProvider>
|
||||||
@@ -46,7 +48,9 @@ export default function App() {
|
|||||||
{/* English (root, no prefix) */}
|
{/* English (root, no prefix) */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={<LocalizedHomePage targetLang="en" />}
|
element={
|
||||||
|
<LocalizedHomePage targetLang="en" />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route
|
<Route
|
||||||
@@ -61,7 +65,10 @@ export default function App() {
|
|||||||
path="/category/:slug"
|
path="/category/:slug"
|
||||||
element={<CategoryPage />}
|
element={<CategoryPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route
|
||||||
|
path="/search"
|
||||||
|
element={<SearchPage />}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/resource/:id"
|
path="/resource/:id"
|
||||||
element={<PostRedirect />}
|
element={<PostRedirect />}
|
||||||
@@ -131,6 +138,7 @@ export default function App() {
|
|||||||
</ImageLightboxProvider>
|
</ImageLightboxProvider>
|
||||||
</AdminRouterModeProvider>
|
</AdminRouterModeProvider>
|
||||||
</SaveToAlbumGuideProvider>
|
</SaveToAlbumGuideProvider>
|
||||||
|
</FavoritesProvider>
|
||||||
</WalletProvider>
|
</WalletProvider>
|
||||||
</RainbowWalletProvider>
|
</RainbowWalletProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -261,6 +261,8 @@ export type Resource = {
|
|||||||
isRecommended: boolean;
|
isRecommended: boolean;
|
||||||
publishedAt?: string;
|
publishedAt?: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
favoriteCount?: number;
|
||||||
|
availability?: "available" | "unavailable";
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { Post } from "../types/post";
|
|||||||
import { downloadAttachment } from "./messageStream/utils/downloadFile";
|
import { downloadAttachment } from "./messageStream/utils/downloadFile";
|
||||||
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
|
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
import { FavoriteButton } from "../favorites/FavoriteButton";
|
||||||
|
|
||||||
const MEDALS = ["🥇", "🥈", "🥉"];
|
const MEDALS = ["🥇", "🥈", "🥉"];
|
||||||
const MAX_ITEMS = 5;
|
const MAX_ITEMS = 5;
|
||||||
@@ -174,6 +175,7 @@ function PopularRankRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
<div className="relative z-10 flex shrink-0 items-center gap-1">
|
||||||
|
<FavoriteButton resourceId={r.id} />
|
||||||
{r.isDownloadable ? (
|
{r.isDownloadable ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "./messageStream/utils/downloadFile";
|
} from "./messageStream/utils/downloadFile";
|
||||||
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
|
import { mediaSaveKindFromType, useSaveToAlbumGuide } from "./SaveToAlbumGuide";
|
||||||
import { useToast } from "./Toast";
|
import { useToast } from "./Toast";
|
||||||
|
import { FavoriteButton } from "../favorites/FavoriteButton";
|
||||||
|
|
||||||
function isPlaceholderAsset(path: string | undefined | null) {
|
function isPlaceholderAsset(path: string | undefined | null) {
|
||||||
return !path || path.includes("placeholder-cover");
|
return !path || path.includes("placeholder-cover");
|
||||||
@@ -133,6 +134,11 @@ export function RecommendedCard({
|
|||||||
{r.badgeLabel}
|
{r.badgeLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
<FavoriteButton
|
||||||
|
resourceId={r.id}
|
||||||
|
size="sm"
|
||||||
|
className="absolute right-3 top-3 z-20 shadow-lg shadow-black/30"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AlbumBubble } from "./bubbles/AlbumBubble";
|
|||||||
import { VideoBubble } from "./bubbles/VideoBubble";
|
import { VideoBubble } from "./bubbles/VideoBubble";
|
||||||
import { LinkPreviewCard } from "./LinkPreviewCard";
|
import { LinkPreviewCard } from "./LinkPreviewCard";
|
||||||
import { formatDateTime } from "./utils/formatTime";
|
import { formatDateTime } from "./utils/formatTime";
|
||||||
|
import { FavoriteButton } from "../../favorites/FavoriteButton";
|
||||||
|
|
||||||
type BubbleComponent = ComponentType<{ post: Post }>;
|
type BubbleComponent = ComponentType<{ post: Post }>;
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ export function MessageBubble({
|
|||||||
isVisual ? "p-0" : "px-4 py-3"
|
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} />
|
<Bubble post={post} />
|
||||||
{post.linkPreview ? (
|
{post.linkPreview ? (
|
||||||
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
|
<div className={isVisual ? "px-4 pt-3" : "mt-3"}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FilterChips } from "./FilterChips";
|
|||||||
import { MessageBubble } from "./MessageBubble";
|
import { MessageBubble } from "./MessageBubble";
|
||||||
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
import { useGroupedByDay } from "./hooks/useGroupedByDay";
|
||||||
import { usePostStream } from "./hooks/usePostStream";
|
import { usePostStream } from "./hooks/usePostStream";
|
||||||
|
import { useFavorites } from "../../favorites/FavoritesProvider";
|
||||||
|
|
||||||
export type MessageStreamProps = {
|
export type MessageStreamProps = {
|
||||||
scope: PostScope;
|
scope: PostScope;
|
||||||
@@ -30,9 +31,14 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||||
usePostStream(params);
|
usePostStream(params);
|
||||||
|
const { ensureFavoriteIds } = useFavorites();
|
||||||
const groups = useGroupedByDay(items, lang);
|
const groups = useGroupedByDay(items, lang);
|
||||||
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void ensureFavoriteIds(items.map((item) => item.id)).catch(() => undefined);
|
||||||
|
}, [ensureFavoriteIds, items]);
|
||||||
|
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const filterBarRef = useRef<HTMLDivElement>(null);
|
const filterBarRef = useRef<HTMLDivElement>(null);
|
||||||
const hasMoreRef = useRef(hasMore);
|
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",
|
adminSearchQuery: "Query",
|
||||||
adminSearchTime: "Time",
|
adminSearchTime: "Time",
|
||||||
adminSearchId: "ID",
|
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",
|
favorites: "My Favorites",
|
||||||
favoritesComingSoon: "Coming Soon",
|
favoritesComingSoon: "Coming Soon",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ export const idDict: Dict = {
|
|||||||
adminSearchQuery: "Kueri",
|
adminSearchQuery: "Kueri",
|
||||||
adminSearchTime: "Waktu",
|
adminSearchTime: "Waktu",
|
||||||
adminSearchId: "ID",
|
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",
|
favorites: "Favorit Saya",
|
||||||
favoritesComingSoon: "Segera Hadir",
|
favoritesComingSoon: "Segera Hadir",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ export const jaDict: Dict = {
|
|||||||
adminSearchQuery: "検索キーワード",
|
adminSearchQuery: "検索キーワード",
|
||||||
adminSearchTime: "時刻",
|
adminSearchTime: "時刻",
|
||||||
adminSearchId: "ID",
|
adminSearchId: "ID",
|
||||||
|
favoriteAdd: "お気に入りに追加",
|
||||||
|
favoriteRemove: "お気に入りから削除",
|
||||||
|
favoriteAdded: "お気に入りに追加しました",
|
||||||
|
favoriteRemoved: "お気に入りから削除しました",
|
||||||
|
favoriteFailed: "お気に入りを更新できませんでした",
|
||||||
|
favoriteLoginRequired: "お気に入り保存にはウォレット接続が必要です",
|
||||||
favorites: "お気に入り",
|
favorites: "お気に入り",
|
||||||
favoritesComingSoon: "近日公開",
|
favoritesComingSoon: "近日公開",
|
||||||
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ export const koDict: Dict = {
|
|||||||
adminSearchQuery: "검색어",
|
adminSearchQuery: "검색어",
|
||||||
adminSearchTime: "시간",
|
adminSearchTime: "시간",
|
||||||
adminSearchId: "ID",
|
adminSearchId: "ID",
|
||||||
|
favoriteAdd: "즐겨찾기에 추가",
|
||||||
|
favoriteRemove: "즐겨찾기에서 제거",
|
||||||
|
favoriteAdded: "즐겨찾기에 추가되었습니다",
|
||||||
|
favoriteRemoved: "즐겨찾기에서 제거되었습니다",
|
||||||
|
favoriteFailed: "즐겨찾기를 업데이트할 수 없습니다",
|
||||||
|
favoriteLoginRequired: "즐겨찾기를 저장하려면 지갑을 연결하세요",
|
||||||
favorites: "내 즐겨찾기",
|
favorites: "내 즐겨찾기",
|
||||||
favoritesComingSoon: "출시 예정",
|
favoritesComingSoon: "출시 예정",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ export const msDict: Dict = {
|
|||||||
adminSearchQuery: "Kata kunci",
|
adminSearchQuery: "Kata kunci",
|
||||||
adminSearchTime: "Masa",
|
adminSearchTime: "Masa",
|
||||||
adminSearchId: "ID",
|
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",
|
favorites: "Kegemaran Saya",
|
||||||
favoritesComingSoon: "Akan Hadir",
|
favoritesComingSoon: "Akan Hadir",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
|
|||||||
@@ -139,6 +139,12 @@ export const viDict: Dict = {
|
|||||||
adminSearchQuery: "Từ khóa",
|
adminSearchQuery: "Từ khóa",
|
||||||
adminSearchTime: "Thời gian",
|
adminSearchTime: "Thời gian",
|
||||||
adminSearchId: "ID",
|
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",
|
favorites: "Yêu thích của tôi",
|
||||||
favoritesComingSoon: "Sắp ra mắt",
|
favoritesComingSoon: "Sắp ra mắt",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ export const zhDict: Dict = {
|
|||||||
adminSearchQuery: "查询词",
|
adminSearchQuery: "查询词",
|
||||||
adminSearchTime: "时间",
|
adminSearchTime: "时间",
|
||||||
adminSearchId: "编号",
|
adminSearchId: "编号",
|
||||||
|
favoriteAdd: "加入收藏",
|
||||||
|
favoriteRemove: "取消收藏",
|
||||||
|
favoriteAdded: "已加入收藏",
|
||||||
|
favoriteRemoved: "已取消收藏",
|
||||||
|
favoriteFailed: "无法更新收藏",
|
||||||
|
favoriteLoginRequired: "请先连接钱包再收藏",
|
||||||
favorites: "我的收藏",
|
favorites: "我的收藏",
|
||||||
favoritesComingSoon: "功能即将推出",
|
favoritesComingSoon: "功能即将推出",
|
||||||
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
||||||
|
|||||||
Reference in New Issue
Block a user