diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index a5dd9e67..ffdcbfe0 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -1,4 +1,3 @@ -import { type ComponentType, useMemo } from 'react'; import { BookOpen, Camera, @@ -13,6 +12,7 @@ import { Ticket, UserPlus, } from 'lucide-react'; +import { type ComponentType, useMemo } from 'react'; import type { CustomWorldGalleryCard, @@ -20,6 +20,7 @@ import type { } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { AuthUser } from '../../services/authService'; +import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory'; import type { CustomWorldProfile } from '../../types'; import { getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { useAuthUi } from '../auth/AuthUiContext'; @@ -33,7 +34,7 @@ import { resolvePlatformWorldLeadPortrait, } from './platformWorldPresentation'; -export type PlatformHomeTab = 'home' | 'create' | 'profile'; +export type PlatformHomeTab = 'home' | 'create' | 'discover' | 'profile'; function SectionHeader({ title, detail }: { title: string; detail: string }) { return ( @@ -312,6 +313,7 @@ export function PlatformHomeView({ featuredEntries, latestEntries, myEntries, + historyEntries, isLoadingPlatform, platformError, onContinueGame, @@ -327,6 +329,7 @@ export function PlatformHomeView({ featuredEntries: CustomWorldGalleryCard[]; latestEntries: CustomWorldGalleryCard[]; myEntries: CustomWorldLibraryEntry[]; + historyEntries: PlatformBrowseHistoryEntry[]; isLoadingPlatform: boolean; platformError: string | null; onContinueGame: () => void; @@ -365,6 +368,8 @@ export function PlatformHomeView({ const tabIcons = { home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png", create: '/Icons/01_Scroll.png', + discover: + "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/321_Compass.png", profile: '/UI/Icon_Eq_Head.png', } as const; const recentPlayItems = savedSnapshot @@ -519,6 +524,49 @@ export function PlatformHomeView({ ); } + if (activeTab === 'discover') { + content = ( +
+
+
+ DISCOVER +
+
发现频道
+
+ 这里会放后续的专题策展、内容聚合和更多平台频道。首版先保留一个干净的发现位,方便后续扩展。 +
+
+ +
+ + {isLoadingPlatform ? ( + + ) : latestEntries.length > 0 ? ( +
+ {latestEntries.map((entry: CustomWorldGalleryCard) => ( + onOpenGalleryDetail(entry)} + /> + ))} +
+ ) : ( + + )} +
+
+ ); + } + if (activeTab === 'profile') { content = (
@@ -662,6 +710,77 @@ export function PlatformHomeView({ )} +
+ + {historyEntries.length > 0 ? ( +
+ {historyEntries.map((entry) => ( + + ))} +
+ ) : ( + + )} +
+
-
+
onTabChange('create')} /> + onTabChange('discover')} + /> void; @@ -225,6 +220,7 @@ export function PreGameSelectionFlow({ handleStartNewGame, handleCustomWorldSelect, }: PreGameSelectionFlowProps) { + const authUi = useAuthUi(); const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState()); const hasAppliedInitialAgentWorkspaceRef = useRef(false); const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] = @@ -235,6 +231,9 @@ export function PreGameSelectionFlow({ const [publishedGalleryEntries, setPublishedGalleryEntries] = useState< CustomWorldGalleryCard[] >([]); + const [historyEntries, setHistoryEntries] = useState< + PlatformBrowseHistoryEntry[] + >([]); const [platformTab, setPlatformTab] = useState('home'); const [selectedDetailEntry, setSelectedDetailEntry] = useState | null>(null); @@ -270,10 +269,6 @@ export function PreGameSelectionFlow({ const [isMutatingDetail, setIsMutatingDetail] = useState(false); const [customWorldProgress, setCustomWorldProgress] = useState(null); - const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] = - useState(null); - const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] = - useState(null); const customWorldAbortControllerRef = useRef(null); const previewCustomWorldCharacters = useMemo( @@ -335,6 +330,23 @@ export function PreGameSelectionFlow({ } }, [selectedDetailEntry]); + const appendBrowseHistoryEntry = useCallback( + (entry: { + ownerUserId: string; + profileId: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldGalleryCard['themeMode']; + authorDisplayName: string; + }) => { + const nextEntries = writePlatformBrowseHistory(authUi?.user, entry); + setHistoryEntries(nextEntries); + }, + [authUi?.user], + ); + useEffect(() => { if (hasAppliedInitialAgentWorkspaceRef.current) { return; @@ -363,6 +375,7 @@ export function PreGameSelectionFlow({ } setSavedCustomWorldEntries(libraryEntries); setPublishedGalleryEntries(galleryEntries); + setHistoryEntries(readPlatformBrowseHistory(authUi?.user)); } catch (error) { if (!isActive) { return; @@ -378,7 +391,7 @@ export function PreGameSelectionFlow({ return () => { isActive = false; }; - }, []); + }, [authUi?.user]); useEffect(() => { if ( @@ -527,40 +540,10 @@ export function PreGameSelectionFlow({ return customWorldCreatorIntent.rawSettingText.trim(); }, [customWorldCreatorIntent]); - const agentDraftSettingPreview = useMemo( - () => buildAgentDraftFoundationSettingText(agentSession), - [agentSession], - ); - - const agentDraftGenerationProgress = useMemo( - () => - buildAgentDraftFoundationGenerationProgress( - agentOperation, - agentDraftGenerationStartedAt, - ), - [agentDraftGenerationStartedAt, agentOperation], - ); - - const isAgentDraftGenerationView = - customWorldGenerationViewSource === 'agent-draft-foundation'; - const activeGenerationProgress = isAgentDraftGenerationView - ? agentDraftGenerationProgress - : customWorldProgress; - const isActiveGenerationRunning = isAgentDraftGenerationView - ? isDraftFoundationOperationRunning(agentOperation) - : isGeneratingCustomWorld; - const activeGenerationError = - isAgentDraftGenerationView && - isDraftFoundationOperation(agentOperation) && - agentOperation.status === 'failed' - ? agentOperation.error || agentOperation.phaseDetail - : customWorldError; - const leaveCustomWorldResult = () => { setGeneratedCustomWorldProfile(null); setCustomWorldError(null); setCustomWorldProgress(null); - setCustomWorldGenerationViewSource(null); setSelectionStage(selectedDetailEntry ? 'detail' : 'platform'); }; @@ -571,7 +554,6 @@ export function PreGameSelectionFlow({ setCustomWorldError(null); setCustomWorldProgress(null); - setCustomWorldGenerationViewSource(null); setSelectionStage('platform'); }; @@ -717,6 +699,16 @@ export function PreGameSelectionFlow({ const openLibraryDetail = ( entry: CustomWorldLibraryEntry, ) => { + appendBrowseHistoryEntry({ + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + worldName: entry.worldName, + subtitle: entry.subtitle, + summaryText: entry.summaryText, + coverImageSrc: entry.coverImageSrc, + themeMode: entry.themeMode, + authorDisplayName: entry.authorDisplayName, + }); setSelectedDetailEntry(entry); setDetailError(null); setSelectionStage('detail'); @@ -731,6 +723,16 @@ export function PreGameSelectionFlow({ entry.ownerUserId, entry.profileId, ); + appendBrowseHistoryEntry({ + ownerUserId: detailEntry.ownerUserId, + profileId: detailEntry.profileId, + worldName: detailEntry.worldName, + subtitle: detailEntry.subtitle, + summaryText: detailEntry.summaryText, + coverImageSrc: detailEntry.coverImageSrc, + themeMode: detailEntry.themeMode, + authorDisplayName: detailEntry.authorDisplayName, + }); setSelectedDetailEntry(detailEntry); } catch (error) { setSelectedDetailEntry(null); @@ -1039,6 +1041,7 @@ export function PreGameSelectionFlow({ featuredEntries={featuredGalleryEntries} latestEntries={publishedGalleryEntries} myEntries={savedCustomWorldEntries} + historyEntries={historyEntries} isLoadingPlatform={isLoadingPlatform} platformError={ isLoadingPlatform ? null : (platformError ?? creationTypeError) diff --git a/src/services/platformBrowseHistory.ts b/src/services/platformBrowseHistory.ts new file mode 100644 index 00000000..6c191cdd --- /dev/null +++ b/src/services/platformBrowseHistory.ts @@ -0,0 +1,133 @@ +import type { CustomWorldGalleryCard } from '../../packages/shared/src/contracts/runtime'; +import type { AuthUser } from './authService'; + +export type PlatformBrowseHistoryEntry = { + ownerUserId: string; + profileId: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldGalleryCard['themeMode']; + authorDisplayName: string; + visitedAt: string; +}; + +const HISTORY_STORAGE_KEY_PREFIX = 'genarrative.platform.browse-history.v1'; +const MAX_HISTORY_ENTRIES = 20; + +function canUseLocalStorage() { + return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'; +} + +function buildHistoryStorageKey(user: AuthUser | null | undefined) { + const accountId = user?.id?.trim() || user?.username?.trim() || 'guest'; + return `${HISTORY_STORAGE_KEY_PREFIX}:${accountId}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readString(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizeHistoryEntry(value: unknown): PlatformBrowseHistoryEntry | null { + if (!isRecord(value)) { + return null; + } + + const ownerUserId = readString(value.ownerUserId); + const profileId = readString(value.profileId); + const worldName = readString(value.worldName); + const visitedAt = readString(value.visitedAt); + if (!ownerUserId || !profileId || !worldName || !visitedAt) { + return null; + } + + const themeMode = readString(value.themeMode) as PlatformBrowseHistoryEntry['themeMode']; + + return { + ownerUserId, + profileId, + worldName, + subtitle: readString(value.subtitle), + summaryText: readString(value.summaryText), + coverImageSrc: readString(value.coverImageSrc) || null, + themeMode: themeMode || 'mythic', + authorDisplayName: readString(value.authorDisplayName) || '玩家', + visitedAt, + }; +} + +function sortHistoryEntries(entries: PlatformBrowseHistoryEntry[]) { + return [...entries].sort((left, right) => { + return ( + new Date(right.visitedAt).getTime() - new Date(left.visitedAt).getTime() + ); + }); +} + +export function readPlatformBrowseHistory(user: AuthUser | null | undefined) { + if (!canUseLocalStorage()) { + return [] as PlatformBrowseHistoryEntry[]; + } + + const raw = window.localStorage.getItem(buildHistoryStorageKey(user)); + if (!raw?.trim()) { + return [] as PlatformBrowseHistoryEntry[]; + } + + try { + const parsed = JSON.parse(raw) as unknown[]; + if (!Array.isArray(parsed)) { + return [] as PlatformBrowseHistoryEntry[]; + } + + return sortHistoryEntries( + parsed + .map((entry) => normalizeHistoryEntry(entry)) + .filter((entry): entry is PlatformBrowseHistoryEntry => Boolean(entry)), + ).slice(0, MAX_HISTORY_ENTRIES); + } catch { + return [] as PlatformBrowseHistoryEntry[]; + } +} + +export function writePlatformBrowseHistory( + user: AuthUser | null | undefined, + entry: Omit & { + visitedAt?: string; + }, +) { + if (!canUseLocalStorage()) { + return [] as PlatformBrowseHistoryEntry[]; + } + + const nextEntry: PlatformBrowseHistoryEntry = { + ...entry, + subtitle: entry.subtitle?.trim() || '', + summaryText: entry.summaryText?.trim() || '', + coverImageSrc: entry.coverImageSrc?.trim() || null, + authorDisplayName: entry.authorDisplayName?.trim() || '玩家', + visitedAt: entry.visitedAt?.trim() || new Date().toISOString(), + }; + const deduped = readPlatformBrowseHistory(user).filter( + (current) => + !( + current.ownerUserId === nextEntry.ownerUserId && + current.profileId === nextEntry.profileId + ), + ); + const nextEntries = sortHistoryEntries([nextEntry, ...deduped]).slice( + 0, + MAX_HISTORY_ENTRIES, + ); + + window.localStorage.setItem( + buildHistoryStorageKey(user), + JSON.stringify(nextEntries), + ); + return nextEntries; +}