feat: add platform browse history tab updates
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
victo
2026-04-15 18:32:04 +08:00
parent 6363267bca
commit 00dfb78b00
3 changed files with 311 additions and 50 deletions

View File

@@ -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<CustomWorldProfile>[];
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 = (
<div className="space-y-4 pb-2">
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 18,
paddingY: 16,
})}
>
<div className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] tracking-[0.2em] text-zinc-100">
DISCOVER
</div>
<div className="mt-4 text-3xl font-black text-white"></div>
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-300">
便
</div>
</section>
<section>
<SectionHeader title="最近上新" detail="先看广场里的新内容" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取推荐内容..." />
) : latestEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: CustomWorldGalleryCard) => (
<WorldCard
key={`${entry.ownerUserId}:${entry.profileId}:discover`}
entry={entry}
badge={formatPlatformWorldTime(entry.publishedAt)}
metaLabel={describePlatformThemeLabel(entry.themeMode)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="发现频道暂时还没有可展示的内容。" />
)}
</section>
</div>
);
}
if (activeTab === 'profile') {
content = (
<div className="space-y-4 pb-2">
@@ -662,6 +710,77 @@ export function PlatformHomeView({
)}
</section>
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
paddingX: 16,
paddingY: 14,
})}
>
<SectionHeader title="历史浏览" detail="最近看过的作品" />
{historyEntries.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1 scrollbar-hide">
{historyEntries.map((entry) => (
<button
key={`${entry.ownerUserId}:${entry.profileId}:history`}
type="button"
onClick={() =>
onOpenGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
visibility: 'published',
publishedAt: entry.visitedAt,
updatedAt: entry.visitedAt,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
authorDisplayName: entry.authorDisplayName,
playableNpcCount: 0,
landmarkCount: 0,
})
}
className="relative flex h-[10.5rem] w-[17rem] shrink-0 overflow-hidden rounded-[1.4rem] border border-white/10 bg-[linear-gradient(135deg,rgba(25,32,46,0.95),rgba(9,12,18,0.96))] p-4 text-left"
>
{entry.coverImageSrc ? (
<img
src={entry.coverImageSrc}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-22"
style={{ imageRendering: 'pixelated' }}
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.08),rgba(8,10,14,0.9))]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="rounded-full border border-amber-300/20 bg-amber-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-amber-100">
HISTORY
</span>
<span className="text-[11px] text-zinc-400">
{formatSnapshotTime(entry.visitedAt)}
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-white">
{entry.worldName}
</div>
<div className="mt-1 text-sm text-zinc-300">
{entry.authorDisplayName}
</div>
<div className="mt-2 line-clamp-3 text-xs leading-5 text-zinc-400">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
</div>
</div>
</button>
))}
</div>
) : (
<EmptyShelf text="你最近还没有浏览过作品详情,去首页或发现逛一逛吧。" />
)}
</section>
<section
className="pixel-nine-slice"
style={getNineSliceStyle(UI_CHROME.panel, {
@@ -726,7 +845,7 @@ export function PlatformHomeView({
className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
>
<div className="grid h-14 grid-cols-3 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<div className="grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] bg-black/18 px-1 py-1">
<PlatformTabButton
active={activeTab === 'home'}
label="首页"
@@ -739,6 +858,12 @@ export function PlatformHomeView({
iconSrc={tabIcons.create}
onClick={() => onTabChange('create')}
/>
<PlatformTabButton
active={activeTab === 'discover'}
label="发现"
iconSrc={tabIcons.discover}
onClick={() => onTabChange('discover')}
/>
<PlatformTabButton
active={activeTab === 'profile'}
label="我的"

View File

@@ -35,17 +35,16 @@ import {
readCustomWorldAgentUiState,
writeCustomWorldAgentUiState,
} from '../../services/customWorldAgentUiState';
import {
buildAgentDraftFoundationGenerationProgress,
buildAgentDraftFoundationSettingText,
isDraftFoundationOperation,
isDraftFoundationOperationRunning,
} from '../../services/customWorldAgentGenerationProgress';
import {
buildCustomWorldCreatorIntentDisplayText,
buildCustomWorldCreatorIntentGenerationText,
createEmptyCustomWorldCreatorIntent,
} from '../../services/customWorldCreatorIntent';
import {
type PlatformBrowseHistoryEntry,
readPlatformBrowseHistory,
writePlatformBrowseHistory,
} from '../../services/platformBrowseHistory';
import {
getCustomWorldGalleryDetail,
listCustomWorldGallery,
@@ -60,6 +59,7 @@ import {
type CustomWorldProfile,
type GameState,
} from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
import { type PlatformHomeTab,PlatformHomeView } from './PlatformHomeView';
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
@@ -101,11 +101,6 @@ export type SelectionStage =
| 'custom-world-generating'
| 'custom-world-result';
type CustomWorldGenerationViewSource =
| 'classic'
| 'agent-draft-foundation'
| null;
type PreGameSelectionFlowProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => 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<PlatformHomeTab>('home');
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
@@ -270,10 +269,6 @@ export function PreGameSelectionFlow({
const [isMutatingDetail, setIsMutatingDetail] = useState(false);
const [customWorldProgress, setCustomWorldProgress] =
useState<CustomWorldGenerationProgress | null>(null);
const [customWorldGenerationViewSource, setCustomWorldGenerationViewSource] =
useState<CustomWorldGenerationViewSource>(null);
const [agentDraftGenerationStartedAt, setAgentDraftGenerationStartedAt] =
useState<number | null>(null);
const customWorldAbortControllerRef = useRef<AbortController | null>(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<CustomWorldProfile>,
) => {
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)

View File

@@ -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<string, unknown> {
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<PlatformBrowseHistoryEntry, 'visitedAt'> & {
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;
}