♻️ refactor: dedupe utils, lift CardGrid/IconLabel primitives

This commit is contained in:
2026-06-29 05:56:49 +07:00
parent 1a91ffbe92
commit a18d9612ee
15 changed files with 106 additions and 81 deletions
+3 -5
View File
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile"; import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile";
import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug"; import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug";
import { httpStatusOf } from "../vrchat/errors";
interface Entry<T> { interface Entry<T> {
value: T; value: T;
@@ -31,14 +32,11 @@ function byteSize(value: unknown): number {
} }
function retryAfterMs(err: unknown): number | null { function retryAfterMs(err: unknown): number | null {
if (httpStatusOf(err) !== 429) return null;
const e = (err ?? {}) as { const e = (err ?? {}) as {
status?: number; response?: { headers?: Record<string, string> };
statusCode?: number;
response?: { status?: number; headers?: Record<string, string> };
headers?: Record<string, string>; headers?: Record<string, string>;
}; };
const status = e.status ?? e.statusCode ?? e.response?.status;
if (status !== 429) return null;
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"]; const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
const secs = raw != null ? Number(raw) : NaN; const secs = raw != null ? Number(raw) : NaN;
return Number.isFinite(secs) ? secs * 1000 : 8000; return Number.isFinite(secs) ? secs * 1000 : 8000;
+3 -3
View File
@@ -92,10 +92,10 @@ function normalizeStatus(status?: string): UserStatus {
} }
} }
function toIso(v?: string | Date | null): string | undefined { export function toIso(v?: string | Date | null): string | undefined {
if (!v) return undefined; if (!v) return undefined;
const s = v instanceof Date ? v.toISOString() : v; const date = v instanceof Date ? v : new Date(v);
return s === "" ? undefined : s; return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
} }
export function toUserProfile(raw: RawUser, selfId: string): UserProfile { export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
+1 -6
View File
@@ -8,6 +8,7 @@ import type {
import type { TwoFactorMethod } from "../../shared/types/auth"; import type { TwoFactorMethod } from "../../shared/types/auth";
import type { UserStatus } from "../../shared/types/user"; import type { UserStatus } from "../../shared/types/user";
import { requireActiveClient } from "./client"; import { requireActiveClient } from "./client";
import { toIso } from "./mappers";
import { userCache } from "./userService"; import { userCache } from "./userService";
import { cacheKeys } from "../cache/policies"; import { cacheKeys } from "../cache/policies";
import { entityStore } from "../store/entityStore"; import { entityStore } from "../store/entityStore";
@@ -20,12 +21,6 @@ const CONTENT_FILTER_KEYS: ContentFilterKey[] = [
"content_horror", "content_horror",
]; ];
function toIso(d?: Date | string | null): string | undefined {
if (!d) return undefined;
const date = typeof d === "string" ? new Date(d) : d;
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function toSettings(u: CurrentUser): AccountSettings { function toSettings(u: CurrentUser): AccountSettings {
const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey => const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey =>
(CONTENT_FILTER_KEYS as string[]).includes(t), (CONTENT_FILTER_KEYS as string[]).includes(t),
+4 -1
View File
@@ -49,10 +49,13 @@ export async function getUser(userId: string): Promise<UserProfile> {
export async function getUserByName(username: string): Promise<UserProfile> { export async function getUserByName(username: string): Promise<UserProfile> {
const vrc = requireActiveClient(); const vrc = requireActiveClient();
const self = await selfId(); const self = await selfId();
return userCache.get(cacheKeys.userByName(username), policies.user, async () => { const key = cacheKeys.userByName(username);
const profile = await userCache.get(key, policies.user, async () => {
const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true }); const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true });
return toUserProfile(data, self); return toUserProfile(data, self);
}); });
refreshStore(profile, key);
return profile;
} }
export async function searchUsers(query: string): Promise<UserProfile[]> { export async function searchUsers(query: string): Promise<UserProfile[]> {
@@ -0,0 +1,9 @@
export function CardGrid({
children,
className = "",
}: {
children: React.ReactNode;
className?: string;
}) {
return <div className={`grid grid-cols-2 gap-3 sm:grid-cols-3 ${className}`}>{children}</div>;
}
@@ -0,0 +1,22 @@
export function IconLabel({
icon,
children,
gap = "gap-1",
title,
className = "",
style,
}: {
icon: React.ReactNode;
children: React.ReactNode;
gap?: string;
title?: string;
className?: string;
style?: React.CSSProperties;
}) {
return (
<span className={`inline-flex items-center ${gap} ${className}`} title={title} style={style}>
{icon}
{children}
</span>
);
}
+2
View File
@@ -17,6 +17,8 @@ export { Toggle } from "./Toggle";
export { Section, Fact } from "./Section"; export { Section, Fact } from "./Section";
export { StatTile } from "./StatTile"; export { StatTile } from "./StatTile";
export { SkeletonGrid } from "./SkeletonGrid"; export { SkeletonGrid } from "./SkeletonGrid";
export { CardGrid } from "./CardGrid";
export { IconLabel } from "./IconLabel";
export { Skeleton } from "./Skeleton"; export { Skeleton } from "./Skeleton";
export { LinkPill } from "./LinkPill"; export { LinkPill } from "./LinkPill";
export { CodeBlock } from "./CodeBlock"; export { CodeBlock } from "./CodeBlock";
+2 -6
View File
@@ -1,6 +1,8 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { CacheEntryInfo, CacheStatus } from "../../../../shared/types/debug"; import type { CacheEntryInfo, CacheStatus } from "../../../../shared/types/debug";
export { formatBytes } from "../../lib/format";
export const statusTone = { export const statusTone = {
fresh: "success", fresh: "success",
stale: "warn", stale: "warn",
@@ -44,12 +46,6 @@ export function cacheStatus(e: CacheEntryInfo, now: number): CacheStatus {
return now < e.expiresAt ? "fresh" : now < e.hardExpiresAt ? "stale" : "expired"; return now < e.expiresAt ? "fresh" : now < e.hardExpiresAt ? "stale" : "expired";
} }
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export function formatDuration(s: number): string { export function formatDuration(s: number): string {
if (s < 60) return `${s}s`; if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
+1 -11
View File
@@ -1,14 +1,4 @@
export function formatBytes(bytes: number): string { export { formatBytes } from "../../lib/format";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let n = bytes / 1024;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
}
export function formatBucket(bucket: string, locale?: string): string { export function formatBucket(bucket: string, locale?: string): string {
const m = /^(\d{4})-(\d{2})$/.exec(bucket); const m = /^(\d{4})-(\d{2})$/.exec(bucket);
@@ -1,6 +1,6 @@
import { ChevronRight, Globe, Hash, MapPin, Users } from "lucide-react"; import { ChevronRight, Globe, Hash, MapPin, Users } from "lucide-react";
import { parseLocation, type Location } from "../../../../shared/types/user"; import { parseLocation, type Location } from "../../../../shared/types/user";
import { HoverImage } from "../../components/ui"; import { HoverImage, IconLabel } from "../../components/ui";
import { useWorld } from "../../store/worlds"; import { useWorld } from "../../store/worlds";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
@@ -54,36 +54,34 @@ export function LocationSection({ location }: { location?: Location }) {
<span className="truncate"> <span className="truncate">
{t("profile:location.by", { author: world.authorName })} {t("profile:location.by", { author: world.authorName })}
</span> </span>
<span <IconLabel icon={<Hash size={11} />} title={t("profile:location.instance")}>
className="inline-flex items-center gap-1"
title={t("profile:location.instance")}
>
<Hash size={11} />
{parsed.instanceId} {parsed.instanceId}
</span> </IconLabel>
{region ? ( {region ? (
<span className="inline-flex items-center gap-1"> <IconLabel
{flag ? ( icon={
flag ? (
<span className="text-[13px] leading-none">{flag}</span> <span className="text-[13px] leading-none">{flag}</span>
) : ( ) : (
<Globe size={11} /> <Globe size={11} />
)}{" "} )
}
>
{" "}
{region} {region}
</span> </IconLabel>
) : null} ) : null}
{inInstance ? ( {inInstance ? (
<span <IconLabel icon={<Users size={11} />} style={{ color: "var(--status-active)" }}>
className="inline-flex items-center gap-1" {" "}
style={{ color: "var(--status-active)" }}
>
<Users size={11} />{" "}
{t("profile:location.inInstance", { n: inInstance.userCount })} {t("profile:location.inInstance", { n: inInstance.userCount })}
</span> </IconLabel>
) : null} ) : null}
{world.occupants > 0 ? ( {world.occupants > 0 ? (
<span className="inline-flex items-center gap-1"> <IconLabel icon={<Users size={11} />}>
<Users size={11} /> {t("profile:location.inWorld", { n: world.occupants })} {" "}
</span> {t("profile:location.inWorld", { n: world.occupants })}
</IconLabel>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { CollapsibleCard, Field, SkeletonGrid } from "../../components/ui"; import { CardGrid, CollapsibleCard, Field, SkeletonGrid } from "../../components/ui";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { WorldCard } from "../world/WorldCard"; import { WorldCard } from "../world/WorldCard";
import { useUserWorlds } from "./useUserWorlds"; import { useUserWorlds } from "./useUserWorlds";
@@ -73,11 +73,11 @@ export function FavoriteWorldsSection({
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{shown.map((folder) => ( {shown.map((folder) => (
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}> <CollapsibleCard key={folder.name} title={folder.displayName} count={folder.worlds.length}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <CardGrid>
{folder.worlds.map((w) => ( {folder.worlds.map((w) => (
<WorldCard key={w.id} world={w} /> <WorldCard key={w.id} world={w} />
))} ))}
</div> </CardGrid>
</CollapsibleCard> </CollapsibleCard>
))} ))}
{loading ? <SkeletonGrid count={3} /> : null} {loading ? <SkeletonGrid count={3} /> : null}
@@ -99,11 +99,11 @@ function WorldGrid({
if (worlds.length) { if (worlds.length) {
return ( return (
<CollapsibleCard title={title} count={worlds.length}> <CollapsibleCard title={title} count={worlds.length}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <CardGrid>
{worlds.map((w) => ( {worlds.map((w) => (
<WorldCard key={w.id} world={w} /> <WorldCard key={w.id} world={w} />
))} ))}
</div> </CardGrid>
</CollapsibleCard> </CollapsibleCard>
); );
} }
@@ -3,7 +3,7 @@ import { Search as SearchIcon } from "lucide-react";
import type { UserProfile } from "../../../../shared/types/user"; import type { UserProfile } from "../../../../shared/types/user";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { Avatar, Button, ContextMenu, Field, Tabs, Tag } from "../../components/ui"; import { Avatar, Button, CardGrid, ContextMenu, Field, Tabs, Tag } from "../../components/ui";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { useUserMenu } from "../friends/useUserMenu"; import { useUserMenu } from "../friends/useUserMenu";
@@ -106,11 +106,11 @@ export function SearchView() {
))} ))}
</div> </div>
) : ( ) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <CardGrid>
{(results as World[]).map((w) => ( {(results as World[]).map((w) => (
<WorldCard key={w.id} world={w} showAuthor onOpen={() => openWorld(w.id)} /> <WorldCard key={w.id} world={w} showAuthor onOpen={() => openWorld(w.id)} />
))} ))}
</div> </CardGrid>
)} )}
</div> </div>
+14 -11
View File
@@ -1,6 +1,6 @@
import { Circle, Star, Users } from "lucide-react"; import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world"; import type { World } from "../../../../shared/types/world";
import { Card, HoverImage, Tag } from "../../components/ui"; import { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
import { compactNumber } from "../../lib/format"; import { compactNumber } from "../../lib/format";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
@@ -37,26 +37,29 @@ export function WorldCard({
</div> </div>
) : null} ) : null}
<div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-faint"> <div className="mt-1 flex flex-wrap items-center gap-3 text-[11px] tabular-nums text-faint">
<span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.favorites")}> <IconLabel icon={<Star size={12} />} title={t("profile:worlds.tip.favorites")}>
<Star size={12} /> {compactNumber(world.favorites)} {" "}
</span> {compactNumber(world.favorites)}
</IconLabel>
{world.occupants > 0 ? ( {world.occupants > 0 ? (
<span <IconLabel
className="inline-flex items-center gap-1" icon={<Circle size={9} fill="currentColor" />}
title={t("profile:worlds.tip.online")} title={t("profile:worlds.tip.online")}
style={{ color: "var(--status-active)" }} style={{ color: "var(--status-active)" }}
> >
<Circle size={9} fill="currentColor" /> {compactNumber(world.occupants)} {" "}
</span> {compactNumber(world.occupants)}
</IconLabel>
) : null} ) : null}
{world.visits > 0 ? ( {world.visits > 0 ? (
<span title={t("profile:worlds.tip.visits")}> <span title={t("profile:worlds.tip.visits")}>
{t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })} {t("profile:worlds.visitsCount", { formattedCount: compactNumber(world.visits) })}
</span> </span>
) : null} ) : null}
<span className="inline-flex items-center gap-1" title={t("profile:worlds.tip.capacity")}> <IconLabel icon={<Users size={12} />} title={t("profile:worlds.tip.capacity")}>
<Users size={12} /> {world.capacity} {" "}
</span> {world.capacity}
</IconLabel>
</div> </div>
</div> </div>
</Card> </Card>
+12
View File
@@ -1,3 +1,15 @@
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let n = bytes / 1024;
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
}
export function compactNumber(n: number): string { export function compactNumber(n: number): string {
if (n < 1000) return `${n}`; if (n < 1000) return `${n}`;
if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`;
+4 -7
View File
@@ -5,19 +5,16 @@ export type AuthStatus =
| { state: "awaiting2fa"; methods: TwoFactorMethod[] } | { state: "awaiting2fa"; methods: TwoFactorMethod[] }
| { state: "authenticated"; user: CurrentUserSummary }; | { state: "authenticated"; user: CurrentUserSummary };
export interface CurrentUserSummary {
id: string;
displayName: string;
userIcon: string;
currentAvatarThumbnailImageUrl: string;
}
export interface Account { export interface Account {
id: string; id: string;
displayName: string; displayName: string;
userIcon: string; userIcon: string;
} }
export interface CurrentUserSummary extends Account {
currentAvatarThumbnailImageUrl: string;
}
export interface AccountsState { export interface AccountsState {
accounts: Account[]; accounts: Account[];
activeId: string | null; activeId: string | null;