-
No content yet
+
+
+
+ No followed articles yet
+
+
+ Follow some authors and spaces to see their latest articles here
+
+
+
) : (
@@ -386,6 +423,10 @@ export const FollowingAuthorSection = ({ showSubscriptionsPopup, setShowSubscrip
? followedArticles.filter(a => a.userName === selectedAuthorFilter)
: followedArticles;
+ console.log('🔍 Rendering articles - followedArticles.length:', followedArticles.length);
+ console.log('🔍 Filtered articles for display:', filteredArticles.length);
+ console.log('🔍 Selected author filter:', selectedAuthorFilter);
+
return filteredArticles.map((article) => (
))}
- {/* Show message if no followed spaces */}
- {followedSpaces.length === 0 && (
-
-
- No subscribed treasuries yet
-
-
+ {/* Show message if no followed spaces */}
+ {followedSpaces.length === 0 && (
+
+
+ No followed treasuries yet
+
+
+ )}
+ >
)}
>
)}
diff --git a/src/screens/Following/sections/FollowingContentSection.tsx b/src/screens/Following/sections/FollowingContentSection.tsx
index 47288bd..7e48ff1 100644
--- a/src/screens/Following/sections/FollowingContentSection.tsx
+++ b/src/screens/Following/sections/FollowingContentSection.tsx
@@ -7,22 +7,35 @@ import { AuthService } from "../../../services/authService";
import { CollectTreasureModal } from "../../../components/CollectTreasureModal";
import profileDefaultAvatar from "../../../assets/images/profile-default.svg";
-// Interface for followed space
+// Interface for followed space - matches updated API spec
interface FollowedSpace {
- id: number;
- name: string;
- namespace: string;
- spaceType?: number; // 1 = Treasury, 2 = Curations (default spaces)
- userId?: number;
- ownerInfo?: {
+ articleCount?: number;
+ coverUrl?: string;
+ data?: Array<{
+ coverUrl?: string;
+ targetUrl?: string;
+ title?: string;
+ }>;
+ description?: string;
+ faceUrl?: string;
+ followerCount?: number;
+ id?: number;
+ isAdmin?: boolean;
+ isBind?: boolean;
+ isFollowed?: boolean;
+ name?: string;
+ namespace?: string;
+ seoDataByAi?: string;
+ spaceType?: number; // 0 = normal space, 1 = Treasury, 2 = Curations
+ userInfo?: {
+ bio?: string;
+ coverUrl?: string;
+ faceUrl?: string;
id?: number;
- username?: string;
namespace?: string;
- };
- authorInfo?: {
- id?: number;
username?: string;
};
+ visibility?: number; // 0:公开 1:登录可见 2:付费可见
}
// Interface for space with resolved username
@@ -56,15 +69,9 @@ export const FollowingContentSection = (): JSX.Element => {
const response = await AuthService.getFollowedSpaces();
console.log('Followed spaces response:', response);
- // Parse the response - handle different response formats
- let spacesArray: FollowedSpace[] = [];
- if (response?.data?.data && Array.isArray(response.data.data)) {
- spacesArray = response.data.data;
- } else if (response?.data && Array.isArray(response.data)) {
- spacesArray = response.data;
- } else if (Array.isArray(response)) {
- spacesArray = response;
- }
+ // Parse the response - service already handles data extraction
+ let spacesArray: FollowedSpace[] = Array.isArray(response) ? response : [];
+ console.log('✅ Transformed spaces array:', spacesArray);
// Resolve display names for default spaces using existing data (no extra API calls)
const spacesWithDisplayNames = spacesArray.map(space => {
@@ -74,12 +81,8 @@ export const FollowingContentSection = (): JSX.Element => {
space.name?.toLowerCase().includes('default');
if (isDefaultSpace) {
- // Try to get username from various possible fields in the response
- const username = space.ownerInfo?.username
- || space.authorInfo?.username
- || (space as any).userInfo?.username
- || (space as any).userName
- || (space as any).ownerName;
+ // Get username from the new userInfo structure
+ const username = space.userInfo?.username;
if (username) {
let displayName: string;
@@ -115,18 +118,12 @@ export const FollowingContentSection = (): JSX.Element => {
try {
setLoadingArticles(true);
- const response = await AuthService.getFollowedArticles(1, 50);
+ const response = await AuthService.getPageMyFollowedArticle(1, 50);
console.log('Followed articles response:', response);
- // Parse the response
- let articlesArray: any[] = [];
- if (response?.data?.data && Array.isArray(response.data.data)) {
- articlesArray = response.data.data;
- } else if (response?.data && Array.isArray(response.data)) {
- articlesArray = response.data;
- } else if (Array.isArray(response)) {
- articlesArray = response;
- }
+ // Parse the response - service handles data extraction
+ let articlesArray: any[] = Array.isArray(response) ? response : response?.data || [];
+ console.log('✅ Transformed articles array:', articlesArray);
setAllArticles(articlesArray);
} catch (err) {
diff --git a/src/screens/Login/Login.tsx b/src/screens/Login/Login.tsx
index 9f786b6..80d2b33 100644
--- a/src/screens/Login/Login.tsx
+++ b/src/screens/Login/Login.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, startTransition } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useUser } from "../../contexts/UserContext";
import { useToast } from "../../components/ui/toast";
@@ -518,7 +518,9 @@ export const Login = (): JSX.Element => {
}, 1000);
}
- navigate('/', { replace: true });
+ startTransition(() => {
+ navigate('/', { replace: true });
+ });
} else if (provider === 'x') {
// X (Twitter) login handling
response = await AuthService.xLogin(code, state, hasExistingToken);
@@ -560,7 +562,9 @@ export const Login = (): JSX.Element => {
await fetchUserInfo(response.token);
- navigate('/', { replace: true });
+ startTransition(() => {
+ navigate('/', { replace: true });
+ });
} else {
// Default fallback (for backward compatibility)
response = await AuthService.xLogin(code, state, hasExistingToken);
@@ -569,7 +573,9 @@ export const Login = (): JSX.Element => {
showToast('Login successful! Welcome back 🎉', 'success');
const tokenToUse = response.token || response.data?.token;
await fetchUserInfo(tokenToUse);
- navigate('/', { replace: true });
+ startTransition(() => {
+ navigate('/', { replace: true });
+ });
} else {
throw new Error('No authentication token received');
}
@@ -805,7 +811,9 @@ export const Login = (): JSX.Element => {
}
showToast('Login successful! Welcome back 🎉', 'success');
- navigate('/');
+ startTransition(() => {
+ navigate('/');
+ });
} else {
// Handle wallet address not registered case
if (response.msg && (
@@ -940,7 +948,9 @@ export const Login = (): JSX.Element => {
}
showToast('Login successful! Welcome back 🎉', 'success');
- navigate('/');
+ startTransition(() => {
+ navigate('/');
+ });
} else {
// Handle wallet address not registered case
if (response.msg && (
@@ -1080,7 +1090,9 @@ export const Login = (): JSX.Element => {
}
showToast('Login successful! Welcome back 🎉', 'success');
- navigate('/');
+ startTransition(() => {
+ navigate('/');
+ });
} else {
// Handle wallet address not registered case
if (response.msg && (
@@ -1235,7 +1247,9 @@ export const Login = (): JSX.Element => {
}
showToast('Login successful! Welcome back 🎉', 'success');
- navigate('/');
+ startTransition(() => {
+ navigate('/');
+ });
} else {
// Translate error messages
let errorMessage = 'Login failed';
diff --git a/src/screens/Notification/sections/NotificationListSection/NotificationListSection.tsx b/src/screens/Notification/sections/NotificationListSection/NotificationListSection.tsx
index ec0ce67..7df224b 100644
--- a/src/screens/Notification/sections/NotificationListSection/NotificationListSection.tsx
+++ b/src/screens/Notification/sections/NotificationListSection/NotificationListSection.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback, useRef } from "react";
+import React, { useEffect, useState, useCallback, useRef, startTransition } from "react";
import {
Avatar,
AvatarFallback,
@@ -646,10 +646,10 @@ export const NotificationListSection = (): JSX.Element => {
// Prefer namespace, fallback to userId
if (notification.namespace) {
// Use short link format to navigate to user profile page
- navigate(`/u/${notification.namespace}`);
+ startTransition(() => navigate(`/u/${notification.namespace}`));
} else if (notification.userId) {
// Fallback: use userId
- navigate(`/user/${notification.userId}/treasury`);
+ startTransition(() => navigate(`/user/${notification.userId}/treasury`));
}
};
@@ -665,10 +665,10 @@ export const NotificationListSection = (): JSX.Element => {
console.log('[Follow Treasury Avatar Click]', { spaceNamespace, spaceId, articleUuid, articleId });
if (spaceNamespace) {
- navigate(`/treasury/${spaceNamespace}`);
+ startTransition(() => navigate(`/treasury/${spaceNamespace}`));
} else if (articleUuid || articleId) {
// Fallback to article page
- navigate(`/work/${articleUuid || articleId}`);
+ startTransition(() => navigate(`/work/${articleUuid || articleId}`));
}
};
@@ -725,7 +725,7 @@ export const NotificationListSection = (): JSX.Element => {
notification.articleId;
console.log('[Follow Treasury Click] Navigating to article:', articleId);
if (articleId) {
- navigate(`/work/${articleId}`);
+ startTransition(() => navigate(`/work/${articleId}`));
}
} else {
// First bracket is space name - navigate to the space
@@ -737,11 +737,11 @@ export const NotificationListSection = (): JSX.Element => {
console.log('[Follow Treasury Click] Space info:', { spaceNamespace, spaceId, articleUuid, articleId });
if (spaceNamespace) {
- navigate(`/treasury/${spaceNamespace}`);
+ startTransition(() => navigate(`/treasury/${spaceNamespace}`));
} else if (articleUuid || articleId) {
// Fallback: if no space namespace, navigate to the article which shows the space
console.log('[Follow Treasury Click] No namespace, navigating to article instead');
- navigate(`/work/${articleUuid || articleId}`);
+ startTransition(() => navigate(`/work/${articleUuid || articleId}`));
} else {
console.warn('[Follow Treasury Click] No space namespace or article found! Extra:', notification.metadata?.extra);
}
@@ -773,14 +773,14 @@ export const NotificationListSection = (): JSX.Element => {
notification.metadata?.extra?.articleId ||
notification.articleId;
if (articleId) {
- navigate(`/work/${articleId}`);
+ startTransition(() => navigate(`/work/${articleId}`));
}
} else if (isFirstBracket) {
// Click on username - navigate to user profile
const senderNamespace = notification.metadata?.senderNamespace ||
notification.namespace;
if (senderNamespace) {
- navigate(`/u/${senderNamespace}`);
+ startTransition(() => navigate(`/u/${senderNamespace}`));
}
} else if (isSpaceName) {
// Click on space name - find the corresponding space and navigate
@@ -788,7 +788,7 @@ export const NotificationListSection = (): JSX.Element => {
const clickedSpace = spaces.find((space: any) => space.name === linkText);
if (clickedSpace && clickedSpace.namespace) {
- navigate(`/treasury/${clickedSpace.namespace}`);
+ startTransition(() => navigate(`/treasury/${clickedSpace.namespace}`));
} else {
console.warn('Could not find space namespace for:', linkText, spaces);
// Fallback to article
@@ -796,7 +796,7 @@ export const NotificationListSection = (): JSX.Element => {
notification.metadata?.extra?.articleId ||
notification.articleId;
if (articleId) {
- navigate(`/work/${articleId}`);
+ startTransition(() => navigate(`/work/${articleId}`));
}
}
}
@@ -843,7 +843,7 @@ export const NotificationListSection = (): JSX.Element => {
notification.metadata?.extra?.namespace;
console.log('[Follow Click] Using space namespace:', spaceNamespace);
if (spaceNamespace) {
- navigate(`/treasury/${spaceNamespace}`);
+ startTransition(() => navigate(`/treasury/${spaceNamespace}`));
} else {
console.warn('[Follow Click] No space namespace found in metadata. Checking alternative locations...');
console.log('[Follow Click] Full extra object:', JSON.stringify(notification.metadata?.extra, null, 2));
@@ -852,7 +852,7 @@ export const NotificationListSection = (): JSX.Element => {
const senderNamespace = notification.metadata?.senderNamespace ||
notification.namespace;
if (senderNamespace) {
- navigate(`/u/${senderNamespace}`);
+ startTransition(() => navigate(`/u/${senderNamespace}`));
}
}
}}
@@ -885,7 +885,7 @@ export const NotificationListSection = (): JSX.Element => {
e.stopPropagation();
const articleId = notification.articleId || notification.metadata?.targetUuid || notification.metadata?.targetId;
if (articleId) {
- navigate(`/work/${articleId}`);
+ startTransition(() => navigate(`/work/${articleId}`));
}
}}
title="Click to view content"
@@ -903,7 +903,7 @@ export const NotificationListSection = (): JSX.Element => {
e.stopPropagation();
const namespace = notification.namespace || notification.metadata?.senderNamespace;
if (namespace) {
- navigate(`/u/${namespace}`);
+ startTransition(() => navigate(`/u/${namespace}`));
}
}}
title="Click to view user profile"
@@ -926,7 +926,7 @@ export const NotificationListSection = (): JSX.Element => {
if (articleId) {
// 使用新的评论参数格式打开评论区
- navigate(`/work/${articleId}?comments=open`);
+ startTransition(() => navigate(`/work/${articleId}?comments=open`));
}
}}
title="Click to view comment"
@@ -944,7 +944,7 @@ export const NotificationListSection = (): JSX.Element => {
e.stopPropagation();
const articleId = notification.articleId || notification.metadata?.targetUuid || notification.metadata?.targetId;
if (articleId) {
- navigate(`/work/${articleId}`);
+ startTransition(() => navigate(`/work/${articleId}`));
}
}}
title="Click to view content"
@@ -962,7 +962,7 @@ export const NotificationListSection = (): JSX.Element => {
e.stopPropagation();
const senderNamespace = notification.metadata?.senderNamespace;
if (senderNamespace) {
- navigate(`/u/${senderNamespace}`);
+ startTransition(() => navigate(`/u/${senderNamespace}`));
}
}}
title="Click to view user profile"
@@ -981,7 +981,7 @@ export const NotificationListSection = (): JSX.Element => {
e.stopPropagation();
const articleId = notification.articleId || notification.metadata?.targetUuid || notification.metadata?.targetId;
if (articleId) {
- navigate(`/work/${articleId}`);
+ startTransition(() => navigate(`/work/${articleId}`));
}
}}
title="Click to view content"
diff --git a/src/screens/Space/sections/SpaceContentSection.tsx b/src/screens/Space/sections/SpaceContentSection.tsx
index 319cdeb..77b6325 100644
--- a/src/screens/Space/sections/SpaceContentSection.tsx
+++ b/src/screens/Space/sections/SpaceContentSection.tsx
@@ -38,6 +38,7 @@ const SpaceInfoSection = ({
authorName,
authorAvatar,
authorNamespace,
+ spaceNamespace,
spaceDescription,
spaceCoverUrl,
spaceFaceUrl,
@@ -54,6 +55,7 @@ const SpaceInfoSection = ({
onEdit,
onImportCSV,
onSubscriberCountLoaded,
+ onSubscriptionChange,
}: {
spaceName: string;
treasureCount: number;
@@ -61,6 +63,7 @@ const SpaceInfoSection = ({
authorName: string;
authorAvatar?: string;
authorNamespace?: string;
+ spaceNamespace?: string;
spaceDescription?: string;
spaceCoverUrl?: string;
spaceFaceUrl?: string;
@@ -77,6 +80,7 @@ const SpaceInfoSection = ({
onEdit?: () => void;
onImportCSV?: () => void;
onSubscriberCountLoaded?: (count: number) => void;
+ onSubscriptionChange?: (isSubscribed: boolean) => void;
}): JSX.Element => {
const canEdit = isOwner;
const [showShareDropdown, setShowShareDropdown] = useState(false);
@@ -219,17 +223,14 @@ const SpaceInfoSection = ({
{
- if (isSubscribed) {
- onFollow();
- } else {
- onUnfollow();
- }
- }}
+ onSubscriptionChange={onSubscriptionChange}
/>
)}
@@ -670,18 +671,36 @@ export const SpaceContentSection = (): JSX.Element => {
return;
}
try {
- await AuthService.followSpace(spaceId);
- setIsFollowing(true);
- showToast('Subscribed to space', 'success');
+ // Get user's email for subscription
+ const userInfo = await AuthService.getUserInfo();
- // Update cache with new subscription status
- const cacheKey = namespace ? `namespace:${decodeURIComponent(spaceIdentifier || '')}` : `category:${decodeURIComponent(spaceIdentifier || '')}`;
- const cached = spaceFetchCache.get(cacheKey);
- if (cached && cached.data) {
- spaceFetchCache.set(cacheKey, {
- ...cached,
- data: { ...cached.data, isFollowing: true }
- });
+ if (!userInfo.email || userInfo.email.trim() === '') {
+ showToast('Please set your email in profile to subscribe to spaces', 'error');
+ return;
+ }
+
+ // Use new email subscription API for spaces
+ const success = await AuthService.emailSubscribe({
+ email: userInfo.email,
+ targetId: spaceId,
+ targetType: 2 // 2 for space
+ });
+
+ if (success) {
+ setIsFollowing(true);
+ showToast(`Successfully subscribed to space! Notifications will be sent to ${userInfo.email}`, 'success');
+
+ // Update cache with new subscription status
+ const cacheKey = namespace ? `namespace:${decodeURIComponent(spaceIdentifier || '')}` : `category:${decodeURIComponent(spaceIdentifier || '')}`;
+ const cached = spaceFetchCache.get(cacheKey);
+ if (cached && cached.data) {
+ spaceFetchCache.set(cacheKey, {
+ ...cached,
+ data: { ...cached.data, isFollowing: true }
+ });
+ }
+ } else {
+ showToast('Failed to subscribe to space', 'error');
}
} catch (err) {
console.error('Failed to subscribe to space:', err);
@@ -700,18 +719,37 @@ export const SpaceContentSection = (): JSX.Element => {
return;
}
try {
- await AuthService.followSpace(spaceId); // Same API toggles subscribe/unsubscribe
- setIsFollowing(false);
- showToast('Unsubscribed from space', 'success');
+ // TODO: Implement unsubscribe API when backend provides it
+ // For now, we'll use the same emailSubscribe API if it toggles
+ const userInfo = await AuthService.getUserInfo();
- // Update cache with new subscription status
- const cacheKey = namespace ? `namespace:${decodeURIComponent(spaceIdentifier || '')}` : `category:${decodeURIComponent(spaceIdentifier || '')}`;
- const cached = spaceFetchCache.get(cacheKey);
- if (cached && cached.data) {
- spaceFetchCache.set(cacheKey, {
- ...cached,
- data: { ...cached.data, isFollowing: false }
- });
+ if (!userInfo.email || userInfo.email.trim() === '') {
+ showToast('Unable to unsubscribe - email not found', 'error');
+ return;
+ }
+
+ // Try using the same API - if it toggles, this should unsubscribe
+ const success = await AuthService.emailSubscribe({
+ email: userInfo.email,
+ targetId: spaceId,
+ targetType: 2 // 2 for space
+ });
+
+ if (success) {
+ setIsFollowing(false);
+ showToast('Unsubscribed from space', 'success');
+
+ // Update cache with new subscription status
+ const cacheKey = namespace ? `namespace:${decodeURIComponent(spaceIdentifier || '')}` : `category:${decodeURIComponent(spaceIdentifier || '')}`;
+ const cached = spaceFetchCache.get(cacheKey);
+ if (cached && cached.data) {
+ spaceFetchCache.set(cacheKey, {
+ ...cached,
+ data: { ...cached.data, isFollowing: false }
+ });
+ }
+ } else {
+ showToast('Failed to unsubscribe from space', 'error');
}
} catch (err) {
console.error('Failed to unsubscribe from space:', err);
@@ -1103,6 +1141,7 @@ export const SpaceContentSection = (): JSX.Element => {
authorName={spaceInfo?.authorName || 'Anonymous'}
authorAvatar={spaceInfo?.authorAvatar}
authorNamespace={spaceInfo?.authorNamespace}
+ spaceNamespace={namespace || spaceIdentifier}
spaceDescription={spaceInfo?.description}
spaceCoverUrl={spaceInfo?.coverUrl}
spaceFaceUrl={spaceInfo?.faceUrl}
@@ -1119,6 +1158,15 @@ export const SpaceContentSection = (): JSX.Element => {
onEdit={handleEditSpace}
onImportCSV={isOwner ? () => setShowImportModal(true) : undefined}
onSubscriberCountLoaded={setSubscriberCount}
+ onSubscriptionChange={(isSubscribed) => {
+ // Update local state only - SubscribeButton already handled the API call
+ setIsFollowing(isSubscribed);
+ if (isSubscribed) {
+ setSubscriberCount(prev => (prev || 0) + 1);
+ } else {
+ setSubscriberCount(prev => Math.max((prev || 0) - 1, 0));
+ }
+ }}
/>
{/* Articles Grid */}
diff --git a/src/screens/UserProfile/UserProfile.tsx b/src/screens/UserProfile/UserProfile.tsx
index b23f39d..2733af9 100644
--- a/src/screens/UserProfile/UserProfile.tsx
+++ b/src/screens/UserProfile/UserProfile.tsx
@@ -1,8 +1,15 @@
-import React, { useEffect } from "react";
+import React, { useEffect, Suspense, lazy, startTransition } from "react";
import { useParams, useNavigate, useLocation } from "react-router-dom";
import { useUser } from "../../contexts/UserContext";
import { PageWrapper } from "../../components/layout/PageWrapper";
-import { UserProfileContent } from "./sections/UserProfileContent";
+import { ArticleListSkeleton } from "../../components/ui/skeleton";
+
+// Lazy load the heavy UserProfileContent component
+const UserProfileContent = lazy(() =>
+ import("./sections/UserProfileContent").then(m => ({
+ default: m.UserProfileContent
+ }))
+);
export const UserProfile = (): JSX.Element => {
const { namespace } = useParams<{ namespace: string }>();
@@ -18,7 +25,7 @@ export const UserProfile = (): JSX.Element => {
useEffect(() => {
// If viewing own namespace, redirect to /my-treasury
if (user && namespace === user.namespace) {
- navigate('/my-treasury', { replace: true });
+ startTransition(() => navigate('/my-treasury', { replace: true }));
}
}, [user, namespace, navigate]);
@@ -34,7 +41,9 @@ export const UserProfile = (): JSX.Element => {
return (
-
+ }>
+
+
);
};
\ No newline at end of file
diff --git a/src/screens/UserProfile/sections/UserProfileContent.tsx b/src/screens/UserProfile/sections/UserProfileContent.tsx
index a6fe411..f269b80 100644
--- a/src/screens/UserProfile/sections/UserProfileContent.tsx
+++ b/src/screens/UserProfile/sections/UserProfileContent.tsx
@@ -548,10 +548,13 @@ export const UserProfileContent: React.FC = ({ namespac
{/* Action buttons - Subscribe and Share on the same line */}
- {!isOwnProfile && userInfo?.id && (
+ {!isOwnProfile && userInfo?.id && userInfo.hasOwnProperty('isFollowed') && (
图片URL数组
+ * Batch upload comment images
+ * @param files Array of image files
+ * @returns Promise Array of image URLs
*/
static async uploadCommentImages(files: File[]): Promise {
if (files.length === 0) {
@@ -816,7 +817,7 @@ export class AuthService {
}
try {
- // 并行上传所有图片
+ // Upload all images in parallel
const uploadPromises = files.map(async (file) => {
const result = await this.uploadImage(file);
return result.url;
@@ -1098,12 +1099,13 @@ export class AuthService {
/**
* Get user detail info - by namespace
* Get user home info by namespace
- * Public endpoint - does not require authentication
+ * Requires authentication to get complete user data
*/
static async getUserHomeInfo(namespace: string): Promise {
+ console.log('🔵 AuthService.getUserHomeInfo() calling /client/userHome/userInfo with namespace:', namespace);
const response = await apiRequest(`/client/userHome/userInfo?namespace=${encodeURIComponent(namespace)}`, {
method: 'GET',
- requiresAuth: false,
+ requiresAuth: true,
});
// User information is in response.data
return response.data;
@@ -1112,8 +1114,7 @@ export class AuthService {
/**
* Get other user's treasury information (public data) - by namespace
* Get other user's treasury information (public data) - by namespace
- * NOTE: We explicitly exclude the Authorization header to ensure we get
- * the target user's data, not the logged-in user's data.
+ * Now requires authentication to access user data
*/
static async getOtherUserTreasuryInfoByNamespace(namespace: string): Promise<{
bio: string;
@@ -1143,25 +1144,15 @@ export class AuthService {
username: string;
walletAddress: string;
}> {
- // Make a direct fetch call without the Authorization header
- // This ensures we get the target user's data, not the logged-in user's
- const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'https://api-test.copus.network';
- const url = `${API_BASE_URL}/client/userHome/userInfo?namespace=${encodeURIComponent(namespace)}`;
-
- const response = await fetch(url, {
+ // This API should be publicly accessible for viewing other users' profiles
+ console.log('🔵 AuthService.getOtherUserTreasuryInfoByNamespace() calling /client/userHome/userInfo with namespace:', namespace);
+ const response = await apiRequest(`/client/userHome/userInfo?namespace=${encodeURIComponent(namespace)}`, {
method: 'GET',
- headers: {
- 'Content-Type': 'application/json',
- },
+ requiresAuth: false, // Allow unauthenticated access to public user profiles
});
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
-
- const data = await response.json();
// User information is in response.data
- return data.data;
+ return response.data;
}
/**
@@ -1859,16 +1850,20 @@ export class AuthService {
* @returns Array of followed users
*/
static async getFollowedUsers(): Promise> {
- return apiRequest('/client/follow/followedUsers', {
+ const response = await apiRequest('/client/follow/followedUsers', {
method: 'GET',
+ requiresAuth: true, // This endpoint returns personal following list
});
+
+ // Handle nested response structure: {status: 1, msg: "success", data: [...]}
+ return response?.data || response || [];
}
/**
@@ -1876,37 +1871,41 @@ export class AuthService {
* @returns Array of followed spaces
*/
static async getFollowedSpaces(): Promise;
- description: string;
- faceUrl: string;
- followerCount: number;
- id: number;
- isAdmin: boolean;
- isBind: boolean;
- isFollowed: boolean;
- name: string;
- namespace: string;
- seoDataByAi: string;
- spaceType: number;
- userInfo: {
- bio: string;
- coverUrl: string;
- faceUrl: string;
- id: number;
- namespace: string;
- username: string;
+ description?: string;
+ faceUrl?: string;
+ followerCount?: number;
+ id?: number;
+ isAdmin?: boolean;
+ isBind?: boolean;
+ isFollowed?: boolean;
+ name?: string;
+ namespace?: string;
+ seoDataByAi?: string;
+ spaceType?: number;
+ userInfo?: {
+ bio?: string;
+ coverUrl?: string;
+ faceUrl?: string;
+ id?: number;
+ namespace?: string;
+ username?: string;
};
- visibility: number;
+ visibility?: number;
}>> {
- return apiRequest('/client/follow/myFollowedSpaces', {
+ const response = await apiRequest('/client/follow/myFollowedSpaces', {
method: 'GET',
+ requiresAuth: true, // This endpoint returns personal following list
});
+
+ // Handle nested response structure: {status: 1, msg: "success", data: [...]}
+ return response?.data || response || [];
}
/**
@@ -2036,17 +2035,54 @@ export class AuthService {
});
}
+
/**
- * Get list of followed spaces
- * API: GET /client/article/space/myFollowedSpaces
+ * Email subscribe to authors/spaces
+ * @param email - User email address
+ * @param targetId - Target user/space ID
+ * @param targetType - 1 for user, 2 for space
*/
- static async getFollowedSpaces(): Promise {
- return apiRequest(`/client/article/space/myFollowedSpaces`, {
- method: 'GET',
- requiresAuth: true,
- });
+ static async emailSubscribe(params: {
+ email: string;
+ targetId: number;
+ targetType: number; // 1: user, 2: space
+ }): Promise {
+ try {
+ console.log('🔵 EmailSubscribe API call with params:', params);
+
+ const response = await apiRequest('/client/follow/emailSubscribe', {
+ method: 'POST',
+ body: JSON.stringify(params),
+ requiresAuth: false, // Allow unauthenticated users to subscribe via email
+ });
+
+ console.log('🔵 EmailSubscribe API response:', response);
+
+ // API returns {status: 1, msg: "success", data: boolean} for success
+ // The data field indicates the final subscription state, not the operation success
+ // Any response with status === 1 means the operation was successful
+ if (response && response.status === 1) {
+ return true;
+ }
+
+ // Also check for direct boolean response (backward compatibility)
+ if (response === true) {
+ return true;
+ }
+
+ return false;
+ } catch (error) {
+ console.error('🔥 Email subscribe failed:', error);
+ console.error('🔥 Error details:', {
+ message: error instanceof Error ? error.message : 'Unknown error',
+ params,
+ timestamp: new Date().toISOString()
+ });
+ throw error;
+ }
}
+
/**
* Get articles from followed spaces (paginated)
* API: GET /client/article/space/pageMyFollowedArticle
diff --git a/src/services/subscriptionService.ts b/src/services/subscriptionService.ts
index 79c8d85..a243d2a 100644
--- a/src/services/subscriptionService.ts
+++ b/src/services/subscriptionService.ts
@@ -56,30 +56,9 @@ class SubscriptionService {
subscriberCount: number;
}> {
try {
- // Try to get real subscription status from user API if we have the namespace
- const currentUser = this.getCurrentUserNamespace();
- if (currentUser) {
- try {
- const { AuthService } = await import('./authService');
- const userInfo = await AuthService.getOtherUserTreasuryInfoByNamespace(currentUser);
-
- // For viewing another user's profile, we need to get their info by ID
- // This is a simplified approach - in real app we'd need proper namespace lookup
- const stats = this.mockAuthorStats[authorUserId] || {
- totalSubscribers: Math.floor(Math.random() * 500) + 50,
- weeklyGrowth: Math.floor(Math.random() * 20) + 5,
- growthRate: Math.round((Math.random() * 5 + 3) * 10) / 10,
- activeSubscribers: 0
- };
-
- return {
- isSubscribed: userInfo?.isFollowed || false,
- subscriberCount: userInfo?.followerCount || stats.totalSubscribers
- };
- } catch (apiError) {
- console.warn('Failed to fetch API subscription status, falling back to mock:', apiError);
- }
- }
+ // Removed unnecessary API call that was causing duplicate requests
+ // The logic was incorrect - calling current user's info to check if following another user
+ console.log('🟢 checkSubscriptionStatus: Using fallback mock implementation for authorUserId:', authorUserId);
// Fallback to existing mock implementation
await new Promise(resolve => setTimeout(resolve, 150));
@@ -114,12 +93,14 @@ class SubscriptionService {
subscriberCount: number;
}> {
try {
- const { AuthService } = await import('./authService');
- const userInfo = await AuthService.getOtherUserTreasuryInfoByNamespace(namespace);
+ // Removed API call to prevent duplicate requests
+ // This method was calling the same API unnecessarily
+ console.log('🟢 checkSubscriptionStatusByNamespace: Using fallback for namespace:', namespace);
+ // Return mock data to prevent breaking functionality
return {
- isSubscribed: userInfo?.isFollowed || false,
- subscriberCount: userInfo?.followerCount || 0
+ isSubscribed: false,
+ subscriberCount: 0
};
} catch (error) {
console.error('Failed to check subscription status by namespace:', error);
diff --git a/src/space.ts b/src/space.ts
new file mode 100644
index 0000000..5775271
--- /dev/null
+++ b/src/space.ts
@@ -0,0 +1,96 @@
+// 空间付费相关类型定义
+
+export type PaymentType = 'free' | 'paid' | 'hybrid';
+export type CurrencyType = 'USDT' | 'USDC';
+export type PurchaseStatus = 'pending' | 'completed' | 'failed';
+
+// 空间付费配置
+export interface SpacePaymentConfig {
+ paymentType: PaymentType;
+ unlockPrice?: number;
+ currency?: CurrencyType;
+}
+
+// 空间付费信息
+export interface SpacePaymentInfo {
+ paymentType: PaymentType;
+ unlockPrice: number;
+ currency: CurrencyType;
+ userHasAccess: boolean;
+ freeContentCount: number;
+ paidContentCount: number;
+ totalRevenue?: number;
+ subscriberCount?: number;
+}
+
+// 内容权限
+export interface ContentPermission {
+ articleId: number;
+ isPaidContent: boolean;
+ previewLength?: number; // 免费预览字数
+}
+
+// 批量设置内容权限
+export interface BatchContentPermissions {
+ spaceId: number;
+ permissions: ContentPermission[];
+}
+
+// 空间购买记录
+export interface SpacePurchase {
+ id: number;
+ spaceId: number;
+ spaceName: string;
+ userId: number;
+ purchasePrice: number;
+ purchaseCurrency: CurrencyType;
+ transactionHash?: string;
+ paymentNetwork?: string;
+ walletAddress?: string;
+ purchaseDate: string;
+ status: PurchaseStatus;
+}
+
+// 空间支付请求数据
+export interface SpacePaymentRequest {
+ spaceId: number;
+ network: string;
+ asset: string;
+ userAddress: string;
+}
+
+// 空间支付响应数据
+export interface SpacePaymentResponse {
+ eip712Data?: any;
+ paymentInfo?: {
+ network: string;
+ asset: string;
+ amount: string;
+ recipient: string;
+ contractAddress: string;
+ resourceUrl: string;
+ };
+}
+
+// 用于 x402 支付的空间信息
+export interface SpaceX402PaymentInfo {
+ payTo: string;
+ asset: string;
+ amount: string;
+ network: string;
+ resource: string;
+}
+
+// 空间收益统计
+export interface SpaceRevenueStats {
+ spaceId: number;
+ totalRevenue: number;
+ totalSubscribers: number;
+ monthlyRevenue: number;
+ monthlySubscribers: number;
+ revenueHistory: Array<{
+ month: string;
+ revenue: number;
+ subscribers: number;
+ }>;
+}
\ No newline at end of file
diff --git a/src/utils/cacheManager.ts b/src/utils/cacheManager.ts
new file mode 100644
index 0000000..08be83c
--- /dev/null
+++ b/src/utils/cacheManager.ts
@@ -0,0 +1,178 @@
+/**
+ * 🔍 SEARCH: cache-manager-system
+ * Comprehensive caching system for performance optimization
+ */
+
+export interface CacheItem {
+ data: T;
+ timestamp: number;
+ expiresAt: number;
+ key: string;
+}
+
+export interface CacheConfig {
+ ttl: number; // Time to live in milliseconds
+ maxSize: number; // Maximum number of items
+}
+
+export class CacheManager {
+ private cache: Map>;
+ private config: CacheConfig;
+
+ constructor(config: CacheConfig = { ttl: 5 * 60 * 1000, maxSize: 100 }) {
+ this.cache = new Map();
+ this.config = config;
+ }
+
+ /**
+ * Set cache item with automatic expiration
+ */
+ set(key: string, data: T, customTTL?: number): void {
+ const now = Date.now();
+ const ttl = customTTL || this.config.ttl;
+ const item: CacheItem = {
+ data,
+ timestamp: now,
+ expiresAt: now + ttl,
+ key,
+ };
+
+ // Remove expired items if we're at max capacity
+ if (this.cache.size >= this.config.maxSize) {
+ this.cleanup();
+ }
+
+ this.cache.set(key, item);
+ }
+
+ /**
+ * Get cache item if not expired
+ */
+ get(key: string): T | null {
+ const item = this.cache.get(key);
+ if (!item) return null;
+
+ const now = Date.now();
+ if (now > item.expiresAt) {
+ this.cache.delete(key);
+ return null;
+ }
+
+ return item.data as T;
+ }
+
+ /**
+ * Check if key exists and is not expired
+ */
+ has(key: string): boolean {
+ const item = this.cache.get(key);
+ if (!item) return false;
+
+ if (Date.now() > item.expiresAt) {
+ this.cache.delete(key);
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Remove specific key
+ */
+ delete(key: string): void {
+ this.cache.delete(key);
+ }
+
+ /**
+ * Clear all cache
+ */
+ clear(): void {
+ this.cache.clear();
+ }
+
+ /**
+ * Remove expired items
+ */
+ cleanup(): void {
+ const now = Date.now();
+ for (const [key, item] of this.cache.entries()) {
+ if (now > item.expiresAt) {
+ this.cache.delete(key);
+ }
+ }
+ }
+
+ /**
+ * Get cache statistics
+ */
+ stats(): { size: number; maxSize: number; hitRate: number } {
+ return {
+ size: this.cache.size,
+ maxSize: this.config.maxSize,
+ hitRate: 0, // Would need to track hits/misses for accurate rate
+ };
+ }
+}
+
+// Create singleton instances for different data types
+export const articleCache = new CacheManager({
+ ttl: 10 * 60 * 1000, // 10 minutes for articles
+ maxSize: 50,
+});
+
+export const imageCache = new CacheManager({
+ ttl: 30 * 60 * 1000, // 30 minutes for images
+ maxSize: 200,
+});
+
+export const userCache = new CacheManager({
+ ttl: 5 * 60 * 1000, // 5 minutes for user data
+ maxSize: 30,
+});
+
+/**
+ * 🔍 SEARCH: cache-with-api
+ * Cache-aware API request wrapper
+ */
+export const cacheWithApi = async (
+ key: string,
+ apiCall: () => Promise,
+ cache: CacheManager = articleCache,
+ customTTL?: number
+): Promise => {
+ // Try cache first
+ const cached = cache.get(key);
+ if (cached !== null) {
+ console.log('📦 Cache hit for:', key);
+ return cached;
+ }
+
+ // Make API call and cache result
+ console.log('🌐 Cache miss, making API call for:', key);
+ try {
+ const data = await apiCall();
+ cache.set(key, data, customTTL);
+ return data;
+ } catch (error) {
+ console.error('❌ API call failed for:', key, error);
+ throw error;
+ }
+};
+
+/**
+ * 🔍 SEARCH: cache-preloader
+ * Preload common data
+ */
+export const preloadCommonData = () => {
+ // Auto-cleanup every 5 minutes
+ setInterval(() => {
+ articleCache.cleanup();
+ imageCache.cleanup();
+ userCache.cleanup();
+ }, 5 * 60 * 1000);
+};
+
+// Initialize preloading when module loads
+if (typeof window !== 'undefined') {
+ preloadCommonData();
+}
\ No newline at end of file
diff --git a/src/utils/cssOptimizer.ts b/src/utils/cssOptimizer.ts
new file mode 100644
index 0000000..a61d866
--- /dev/null
+++ b/src/utils/cssOptimizer.ts
@@ -0,0 +1,256 @@
+/**
+ * 🔍 SEARCH: css-optimizer-system
+ * CSS loading optimization for better Critical Rendering Path
+ */
+
+interface CSSResource {
+ href: string;
+ media?: string;
+ priority: 'critical' | 'high' | 'low';
+ loaded: boolean;
+}
+
+class CSSOptimizer {
+ private loadedStylesheets: Set = new Set();
+ private pendingStyles: CSSResource[] = [];
+
+ /**
+ * Load CSS asynchronously to avoid render blocking
+ */
+ loadStylesheetAsync(href: string, media: string = 'all', priority: 'critical' | 'high' | 'low' = 'low'): Promise {
+ return new Promise((resolve, reject) => {
+ // Skip if already loaded
+ if (this.loadedStylesheets.has(href)) {
+ resolve();
+ return;
+ }
+
+ const link = document.createElement('link');
+ link.rel = 'preload';
+ link.as = 'style';
+ link.href = href;
+ link.media = 'print'; // Load as print media to avoid blocking
+ link.onload = () => {
+ // Switch to the correct media after loading
+ link.media = media;
+ link.rel = 'stylesheet';
+ this.loadedStylesheets.add(href);
+ resolve();
+ };
+ link.onerror = reject;
+
+ // Insert based on priority
+ if (priority === 'critical') {
+ document.head.insertBefore(link, document.head.firstChild);
+ } else {
+ document.head.appendChild(link);
+ }
+ });
+ }
+
+ /**
+ * Inline critical CSS for above-the-fold content
+ */
+ inlineCriticalCSS(cssContent: string): void {
+ const style = document.createElement('style');
+ style.textContent = cssContent;
+ style.setAttribute('data-critical', 'true');
+
+ // Insert at the beginning of head for highest priority
+ document.head.insertBefore(style, document.head.firstChild);
+ }
+
+ /**
+ * Extract critical CSS for the current page
+ */
+ extractCriticalCSS(): string {
+ // Define critical CSS that's needed for initial render
+ return `
+ /* Critical CSS for above-the-fold content */
+ .bg-\\[linear-gradient\\(0deg\\2c rgba\\(224\\2c 224\\2c 224\\2c 0\\.18\\)_0\\%\\2c rgba\\(224\\2c 224\\2c 224\\2c 0\\.18\\)_100\\%\\)\\2c linear-gradient\\(0deg\\2c rgba\\(255\\2c 255\\2c 255\\2c 1\\)_0\\%\\2c rgba\\(255\\2c 255\\2c 255\\2c 1\\)_100\\%\\)\\] {
+ background: linear-gradient(0deg,rgba(224,224,224,0.18) 0%,rgba(224,224,224,0.18) 100%),linear-gradient(0deg,rgba(255,255,255,1) 0%,rgba(255,255,255,1) 100%);
+ }
+
+ /* Critical layout classes */
+ .w-full { width: 100%; }
+ .min-h-screen { min-height: 100vh; }
+ .flex { display: flex; }
+ .items-center { align-items: center; }
+ .justify-center { justify-content: center; }
+ .opacity-0 { opacity: 0; }
+ .opacity-100 { opacity: 1; }
+ .transition-opacity { transition-property: opacity; }
+ .duration-300 { transition-duration: 300ms; }
+
+ /* Header height to prevent layout shift */
+ .pt-\\[50px\\] { padding-top: 50px; }
+
+ @media (min-width: 1024px) {
+ .lg\\:ml-\\[310px\\] { margin-left: 310px; }
+ .lg\\:mr-\\[40px\\] { margin-right: 40px; }
+ .lg\\:pt-\\[60px\\] { padding-top: 60px; }
+ }
+ `;
+ }
+
+ /**
+ * Optimize font loading
+ */
+ optimizeFontLoading(): void {
+ // Preload critical fonts
+ const criticalFonts = [
+ // Add any critical fonts here
+ ];
+
+ criticalFonts.forEach(fontUrl => {
+ const link = document.createElement('link');
+ link.rel = 'preload';
+ link.as = 'font';
+ link.type = 'font/woff2';
+ link.crossOrigin = 'anonymous';
+ link.href = fontUrl;
+ document.head.appendChild(link);
+ });
+
+ // Apply font-display: swap to avoid invisible text
+ const style = document.createElement('style');
+ style.textContent = `
+ @font-face {
+ font-family: 'Lato';
+ font-display: swap;
+ }
+
+ /* Ensure text is visible during webfont load */
+ body {
+ font-display: swap;
+ }
+ `;
+ document.head.appendChild(style);
+ }
+
+ /**
+ * Defer non-critical CSS loading
+ */
+ deferNonCriticalCSS(): void {
+ // Wait for the page to load before loading non-critical styles
+ if (document.readyState === 'complete') {
+ this.loadNonCriticalStyles();
+ } else {
+ window.addEventListener('load', () => {
+ this.loadNonCriticalStyles();
+ });
+ }
+ }
+
+ /**
+ * Load non-critical styles after page load
+ */
+ private loadNonCriticalStyles(): void {
+ // Load animation and enhancement CSS after main content
+ const nonCriticalStyles = [
+ // Add any non-critical stylesheets here
+ ];
+
+ nonCriticalStyles.forEach(href => {
+ this.loadStylesheetAsync(href, 'all', 'low');
+ });
+ }
+
+ /**
+ * Remove unused CSS on the fly
+ */
+ removeUnusedCSS(): void {
+ // This is a simplified version - in production you'd use a tool like PurgeCSS
+ const unusedSelectors = [
+ // Add selectors that are definitely not used on current page
+ ];
+
+ // Find and disable unused style rules (development and test only)
+ if (process.env.NODE_ENV !== 'production') {
+ unusedSelectors.forEach(selector => {
+ const rules = document.styleSheets;
+ for (let i = 0; i < rules.length; i++) {
+ try {
+ const cssRules = rules[i].cssRules || rules[i].rules;
+ if (cssRules) {
+ for (let j = 0; j < cssRules.length; j++) {
+ const rule = cssRules[j] as CSSStyleRule;
+ if (rule.selectorText && rule.selectorText.includes(selector)) {
+ rule.style.display = 'none';
+ }
+ }
+ }
+ } catch (e) {
+ // Cross-origin stylesheets can't be accessed
+ }
+ }
+ });
+ }
+ }
+
+ /**
+ * Initialize CSS optimization
+ */
+ init(): void {
+ if (typeof window === 'undefined') return;
+
+ // Inline critical CSS immediately
+ const criticalCSS = this.extractCriticalCSS();
+ this.inlineCriticalCSS(criticalCSS);
+
+ // Optimize font loading
+ this.optimizeFontLoading();
+
+ // Defer non-critical CSS
+ this.deferNonCriticalCSS();
+
+ // Set up performance monitoring
+ this.monitorCSSPerformance();
+ }
+
+ /**
+ * Monitor CSS loading performance
+ */
+ private monitorCSSPerformance(): void {
+ if ('performance' in window && window.performance.getEntriesByType) {
+ setTimeout(() => {
+ const cssEntries = window.performance.getEntriesByType('resource')
+ .filter(entry => entry.name.endsWith('.css'));
+
+ const totalCSSTime = cssEntries.reduce((total, entry) => {
+ return total + (entry.responseEnd - entry.startTime);
+ }, 0);
+
+ if (process.env.NODE_ENV !== 'production') {
+ console.log(`📊 CSS Performance: ${cssEntries.length} stylesheets loaded in ${totalCSSTime.toFixed(2)}ms`);
+ }
+ }, 2000);
+ }
+ }
+
+ /**
+ * Get CSS optimization statistics
+ */
+ getStats(): { loaded: number; pending: number; loadTime: number } {
+ return {
+ loaded: this.loadedStylesheets.size,
+ pending: this.pendingStyles.length,
+ loadTime: 0 // Would track actual load times in production
+ };
+ }
+}
+
+// Create singleton instance
+export const cssOptimizer = new CSSOptimizer();
+
+// Auto-initialize
+if (typeof window !== 'undefined') {
+ // Initialize as early as possible
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ cssOptimizer.init();
+ });
+ } else {
+ cssOptimizer.init();
+ }
+}
\ No newline at end of file
diff --git a/src/utils/resourcePreloader.ts b/src/utils/resourcePreloader.ts
new file mode 100644
index 0000000..aa3e536
--- /dev/null
+++ b/src/utils/resourcePreloader.ts
@@ -0,0 +1,260 @@
+/**
+ * 🔍 SEARCH: resource-preloader-system
+ * Intelligent resource preloading system for better performance
+ */
+
+interface PreloadOptions {
+ priority?: 'high' | 'low';
+ crossOrigin?: 'anonymous' | 'use-credentials';
+ as?: 'script' | 'style' | 'image' | 'font' | 'fetch';
+}
+
+interface PreloadedResource {
+ url: string;
+ type: string;
+ loaded: boolean;
+ element?: HTMLLinkElement | HTMLImageElement;
+}
+
+class ResourcePreloader {
+ private preloadedResources: Map = new Map();
+ private criticalImages: Set = new Set();
+ private routeComponents: Map Promise> = new Map();
+
+ /**
+ * Preload critical images that are likely to be needed soon
+ */
+ preloadCriticalImages(imageUrls: string[]): void {
+ imageUrls.forEach(url => {
+ if (!this.criticalImages.has(url) && url && url !== 'undefined') {
+ this.criticalImages.add(url);
+ this.preloadImage(url, { priority: 'high' });
+ }
+ });
+ }
+
+ /**
+ * Preload an image resource
+ */
+ private preloadImage(url: string, options: PreloadOptions = {}): Promise {
+ return new Promise((resolve, reject) => {
+ if (this.preloadedResources.has(url)) {
+ resolve();
+ return;
+ }
+
+ // Use link rel=preload for high priority images
+ if (options.priority === 'high') {
+ const link = document.createElement('link');
+ link.rel = 'preload';
+ link.as = 'image';
+ link.href = url;
+ link.onload = () => {
+ this.preloadedResources.set(url, { url, type: 'image', loaded: true, element: link });
+ resolve();
+ };
+ link.onerror = reject;
+ document.head.appendChild(link);
+ } else {
+ // Use Image() for low priority preloading
+ const img = new Image();
+ img.onload = () => {
+ this.preloadedResources.set(url, { url, type: 'image', loaded: true });
+ resolve();
+ };
+ img.onerror = reject;
+ img.src = url;
+ }
+ });
+ }
+
+ /**
+ * Preload JavaScript modules for upcoming routes
+ */
+ preloadRoute(routePath: string, moduleLoader: () => Promise): void {
+ if (!this.routeComponents.has(routePath)) {
+ this.routeComponents.set(routePath, moduleLoader);
+
+ // Preload on idle
+ if ('requestIdleCallback' in window) {
+ requestIdleCallback(() => {
+ moduleLoader().catch(console.error);
+ });
+ } else {
+ // Fallback for browsers without requestIdleCallback
+ setTimeout(() => {
+ moduleLoader().catch(console.error);
+ }, 100);
+ }
+ }
+ }
+
+ /**
+ * Preload based on user interaction patterns
+ */
+ onUserInteraction(linkElement: HTMLAnchorElement): void {
+ const href = linkElement.getAttribute('href');
+ if (!href) return;
+
+ // Detect likely next routes based on hover/focus
+ if (href.startsWith('/work/')) {
+ // Preload Content page components
+ this.preloadRoute('/content', () => import('../screens/Content/Content'));
+ } else if (href.startsWith('/user/')) {
+ // Preload UserProfile components
+ this.preloadRoute('/user', () => import('../screens/UserProfile/UserProfile'));
+ } else if (href === '/curate') {
+ // Preload Create page components
+ this.preloadRoute('/curate', () => import('../screens/Create/Create'));
+ }
+ }
+
+ /**
+ * Preload fonts that are used throughout the app
+ */
+ preloadFonts(): void {
+ const fonts = [
+ // Add any custom fonts here
+ // 'https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMw2boKoduKmMEVuLyfAZ9hiJ-Ek-_EeA.woff2'
+ ];
+
+ fonts.forEach(fontUrl => {
+ if (!this.preloadedResources.has(fontUrl)) {
+ const link = document.createElement('link');
+ link.rel = 'preload';
+ link.as = 'font';
+ link.type = 'font/woff2';
+ link.crossOrigin = 'anonymous';
+ link.href = fontUrl;
+ document.head.appendChild(link);
+
+ this.preloadedResources.set(fontUrl, {
+ url: fontUrl,
+ type: 'font',
+ loaded: true,
+ element: link
+ });
+ }
+ });
+ }
+
+ /**
+ * Intelligent preloading based on viewport and user behavior
+ */
+ observeVisibleImages(): void {
+ if (!('IntersectionObserver' in window)) return;
+
+ const observer = new IntersectionObserver((entries) => {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ const img = entry.target as HTMLImageElement;
+ const src = img.getAttribute('data-src') || img.src;
+
+ if (src && !this.preloadedResources.has(src)) {
+ this.preloadImage(src, { priority: 'low' });
+ }
+ }
+ });
+ }, {
+ rootMargin: '200px', // Start preloading 200px before visible
+ threshold: 0.1
+ });
+
+ // Observe all images on the page
+ document.querySelectorAll('img[data-src], img[src]').forEach(img => {
+ observer.observe(img);
+ });
+ }
+
+ /**
+ * Preload next page content on hover
+ */
+ setupHoverPreloading(): void {
+ let hoverTimeout: NodeJS.Timeout;
+
+ document.addEventListener('mouseover', (e) => {
+ const target = e.target as HTMLElement;
+ const link = target.closest('a[href]') as HTMLAnchorElement;
+
+ if (link && link.href) {
+ clearTimeout(hoverTimeout);
+ hoverTimeout = setTimeout(() => {
+ this.onUserInteraction(link);
+ }, 100); // 100ms delay to avoid preloading on quick mouseovers
+ }
+ });
+
+ document.addEventListener('mouseout', () => {
+ clearTimeout(hoverTimeout);
+ });
+ }
+
+ /**
+ * Get preload statistics for performance monitoring
+ */
+ getStats(): { total: number; loaded: number; hitRate: number } {
+ const total = this.preloadedResources.size;
+ const loaded = Array.from(this.preloadedResources.values()).filter(r => r.loaded).length;
+
+ return {
+ total,
+ loaded,
+ hitRate: total > 0 ? (loaded / total) * 100 : 0
+ };
+ }
+
+ /**
+ * Initialize all preloading strategies
+ */
+ init(): void {
+ // Only initialize in browsers
+ if (typeof window === 'undefined') return;
+
+ // Preload fonts
+ this.preloadFonts();
+
+ // Setup hover preloading
+ this.setupHoverPreloading();
+
+ // Observe images when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', () => {
+ this.observeVisibleImages();
+ });
+ } else {
+ this.observeVisibleImages();
+ }
+
+ // Preload likely next routes based on current page
+ this.preloadLikelyRoutes();
+ }
+
+ /**
+ * Preload routes that users are likely to visit next
+ */
+ private preloadLikelyRoutes(): void {
+ const currentPath = window.location.pathname;
+
+ // Wait a bit for the current page to load
+ setTimeout(() => {
+ if (currentPath === '/' || currentPath === '/copus') {
+ // On homepage, users likely to visit content pages
+ this.preloadRoute('/content', () => import('../screens/Content/Content'));
+ } else if (currentPath.startsWith('/work/')) {
+ // On content pages, users might go back to discovery or view profile
+ this.preloadRoute('/user', () => import('../screens/UserProfile/UserProfile'));
+ }
+ }, 2000);
+ }
+}
+
+// Create singleton instance
+export const resourcePreloader = new ResourcePreloader();
+
+// Auto-initialize when module is imported
+if (typeof window !== 'undefined') {
+ // Wait a bit for the app to initialize
+ setTimeout(() => {
+ resourcePreloader.init();
+ }, 1000);
+}
\ No newline at end of file