Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -1,4 +1,3 @@
|
|||||||
import { type ComponentType, useMemo } from 'react';
|
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Camera,
|
Camera,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
Ticket,
|
Ticket,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { type ComponentType, useMemo } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CustomWorldGalleryCard,
|
CustomWorldGalleryCard,
|
||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import type { AuthUser } from '../../services/authService';
|
import type { AuthUser } from '../../services/authService';
|
||||||
|
import type { PlatformBrowseHistoryEntry } from '../../services/platformBrowseHistory';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
import { getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||||
import { useAuthUi } from '../auth/AuthUiContext';
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
resolvePlatformWorldLeadPortrait,
|
resolvePlatformWorldLeadPortrait,
|
||||||
} from './platformWorldPresentation';
|
} from './platformWorldPresentation';
|
||||||
|
|
||||||
export type PlatformHomeTab = 'home' | 'create' | 'profile';
|
export type PlatformHomeTab = 'home' | 'create' | 'discover' | 'profile';
|
||||||
|
|
||||||
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -314,6 +315,7 @@ export function PlatformHomeView({
|
|||||||
featuredEntries,
|
featuredEntries,
|
||||||
latestEntries,
|
latestEntries,
|
||||||
myEntries,
|
myEntries,
|
||||||
|
historyEntries,
|
||||||
isLoadingPlatform,
|
isLoadingPlatform,
|
||||||
platformError,
|
platformError,
|
||||||
onContinueGame,
|
onContinueGame,
|
||||||
@@ -329,6 +331,7 @@ export function PlatformHomeView({
|
|||||||
featuredEntries: CustomWorldGalleryCard[];
|
featuredEntries: CustomWorldGalleryCard[];
|
||||||
latestEntries: CustomWorldGalleryCard[];
|
latestEntries: CustomWorldGalleryCard[];
|
||||||
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
myEntries: CustomWorldLibraryEntry<CustomWorldProfile>[];
|
||||||
|
historyEntries: PlatformBrowseHistoryEntry[];
|
||||||
isLoadingPlatform: boolean;
|
isLoadingPlatform: boolean;
|
||||||
platformError: string | null;
|
platformError: string | null;
|
||||||
onContinueGame: () => void;
|
onContinueGame: () => void;
|
||||||
@@ -367,6 +370,8 @@ export function PlatformHomeView({
|
|||||||
const tabIcons = {
|
const tabIcons = {
|
||||||
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
home: "/Icons/Admurin's Pixel Items/Admurin's Pixel Items/Miscellaneous/Singles/192_RustyTrinket_House.png",
|
||||||
create: '/Icons/01_Scroll.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',
|
profile: '/UI/Icon_Eq_Head.png',
|
||||||
} as const;
|
} as const;
|
||||||
const recentPlayItems = savedSnapshot
|
const recentPlayItems = savedSnapshot
|
||||||
@@ -521,6 +526,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') {
|
if (activeTab === 'profile') {
|
||||||
content = (
|
content = (
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
@@ -664,6 +712,77 @@ export function PlatformHomeView({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</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
|
<section
|
||||||
className="pixel-nine-slice"
|
className="pixel-nine-slice"
|
||||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||||
@@ -728,7 +847,7 @@ export function PlatformHomeView({
|
|||||||
className="mt-4 border-t border-white/5 pt-3"
|
className="mt-4 border-t border-white/5 pt-3"
|
||||||
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
|
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
|
<PlatformTabButton
|
||||||
active={activeTab === 'home'}
|
active={activeTab === 'home'}
|
||||||
label="首页"
|
label="首页"
|
||||||
@@ -741,6 +860,12 @@ export function PlatformHomeView({
|
|||||||
iconSrc={tabIcons.create}
|
iconSrc={tabIcons.create}
|
||||||
onClick={() => onTabChange('create')}
|
onClick={() => onTabChange('create')}
|
||||||
/>
|
/>
|
||||||
|
<PlatformTabButton
|
||||||
|
active={activeTab === 'discover'}
|
||||||
|
label="发现"
|
||||||
|
iconSrc={tabIcons.discover}
|
||||||
|
onClick={() => onTabChange('discover')}
|
||||||
|
/>
|
||||||
<PlatformTabButton
|
<PlatformTabButton
|
||||||
active={activeTab === 'profile'}
|
active={activeTab === 'profile'}
|
||||||
label="我的"
|
label="我的"
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ import {
|
|||||||
buildCustomWorldCreatorIntentGenerationText,
|
buildCustomWorldCreatorIntentGenerationText,
|
||||||
createEmptyCustomWorldCreatorIntent,
|
createEmptyCustomWorldCreatorIntent,
|
||||||
} from '../../services/customWorldCreatorIntent';
|
} from '../../services/customWorldCreatorIntent';
|
||||||
|
import {
|
||||||
|
type PlatformBrowseHistoryEntry,
|
||||||
|
readPlatformBrowseHistory,
|
||||||
|
writePlatformBrowseHistory,
|
||||||
|
} from '../../services/platformBrowseHistory';
|
||||||
import {
|
import {
|
||||||
getCustomWorldGalleryDetail,
|
getCustomWorldGalleryDetail,
|
||||||
listCustomWorldGallery,
|
listCustomWorldGallery,
|
||||||
@@ -61,6 +66,7 @@ import {
|
|||||||
type CustomWorldProfile,
|
type CustomWorldProfile,
|
||||||
type GameState,
|
type GameState,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
|
import { useAuthUi } from '../auth/AuthUiContext';
|
||||||
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
|
import { PlatformCreationTypeModal } from './PlatformCreationTypeModal';
|
||||||
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
|
import { type PlatformHomeTab, PlatformHomeView } from './PlatformHomeView';
|
||||||
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
|
import { PlatformWorldDetailView } from './PlatformWorldDetailView';
|
||||||
@@ -229,6 +235,7 @@ export function PreGameSelectionFlow({
|
|||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
}: PreGameSelectionFlowProps) {
|
}: PreGameSelectionFlowProps) {
|
||||||
|
const authUi = useAuthUi();
|
||||||
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
const initialAgentUiStateRef = useRef(readCustomWorldAgentUiState());
|
||||||
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
const hasAppliedInitialAgentWorkspaceRef = useRef(false);
|
||||||
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
const [generatedCustomWorldProfile, setGeneratedCustomWorldProfile] =
|
||||||
@@ -239,6 +246,9 @@ export function PreGameSelectionFlow({
|
|||||||
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
const [publishedGalleryEntries, setPublishedGalleryEntries] = useState<
|
||||||
CustomWorldGalleryCard[]
|
CustomWorldGalleryCard[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [historyEntries, setHistoryEntries] = useState<
|
||||||
|
PlatformBrowseHistoryEntry[]
|
||||||
|
>([]);
|
||||||
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
|
const [platformTab, setPlatformTab] = useState<PlatformHomeTab>('home');
|
||||||
const [selectedDetailEntry, setSelectedDetailEntry] =
|
const [selectedDetailEntry, setSelectedDetailEntry] =
|
||||||
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
|
||||||
@@ -321,6 +331,51 @@ export function PreGameSelectionFlow({
|
|||||||
return nextSession;
|
return nextSession;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const refreshPlatformData = useCallback(async () => {
|
||||||
|
setIsLoadingPlatform(true);
|
||||||
|
setPlatformError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [libraryEntries, galleryEntries] = await Promise.all([
|
||||||
|
listCustomWorldLibrary(),
|
||||||
|
listCustomWorldGallery(),
|
||||||
|
]);
|
||||||
|
setSavedCustomWorldEntries(libraryEntries);
|
||||||
|
setPublishedGalleryEntries(galleryEntries);
|
||||||
|
if (selectedDetailEntry) {
|
||||||
|
const nextOwnedEntry = libraryEntries.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.ownerUserId === selectedDetailEntry.ownerUserId &&
|
||||||
|
entry.profileId === selectedDetailEntry.profileId,
|
||||||
|
);
|
||||||
|
if (nextOwnedEntry) {
|
||||||
|
setSelectedDetailEntry(nextOwnedEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setPlatformError(resolveErrorMessage(error, '读取平台数据失败。'));
|
||||||
|
} finally {
|
||||||
|
setIsLoadingPlatform(false);
|
||||||
|
}
|
||||||
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (hasAppliedInitialAgentWorkspaceRef.current) {
|
if (hasAppliedInitialAgentWorkspaceRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -349,6 +404,7 @@ export function PreGameSelectionFlow({
|
|||||||
}
|
}
|
||||||
setSavedCustomWorldEntries(libraryEntries);
|
setSavedCustomWorldEntries(libraryEntries);
|
||||||
setPublishedGalleryEntries(galleryEntries);
|
setPublishedGalleryEntries(galleryEntries);
|
||||||
|
setHistoryEntries(readPlatformBrowseHistory(authUi?.user));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isActive) {
|
if (!isActive) {
|
||||||
return;
|
return;
|
||||||
@@ -364,7 +420,7 @@ export function PreGameSelectionFlow({
|
|||||||
return () => {
|
return () => {
|
||||||
isActive = false;
|
isActive = false;
|
||||||
};
|
};
|
||||||
}, []);
|
}, [authUi?.user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -895,6 +951,16 @@ export function PreGameSelectionFlow({
|
|||||||
const openLibraryDetail = (
|
const openLibraryDetail = (
|
||||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
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);
|
setSelectedDetailEntry(entry);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setSelectionStage('detail');
|
setSelectionStage('detail');
|
||||||
@@ -909,6 +975,16 @@ export function PreGameSelectionFlow({
|
|||||||
entry.ownerUserId,
|
entry.ownerUserId,
|
||||||
entry.profileId,
|
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);
|
setSelectedDetailEntry(detailEntry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSelectedDetailEntry(null);
|
setSelectedDetailEntry(null);
|
||||||
@@ -1317,6 +1393,7 @@ export function PreGameSelectionFlow({
|
|||||||
featuredEntries={featuredGalleryEntries}
|
featuredEntries={featuredGalleryEntries}
|
||||||
latestEntries={publishedGalleryEntries}
|
latestEntries={publishedGalleryEntries}
|
||||||
myEntries={savedCustomWorldEntries}
|
myEntries={savedCustomWorldEntries}
|
||||||
|
historyEntries={historyEntries}
|
||||||
isLoadingPlatform={isLoadingPlatform}
|
isLoadingPlatform={isLoadingPlatform}
|
||||||
platformError={
|
platformError={
|
||||||
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
isLoadingPlatform ? null : (platformError ?? creationTypeError)
|
||||||
|
|||||||
133
src/services/platformBrowseHistory.ts
Normal file
133
src/services/platformBrowseHistory.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user