♻️ refactor: Extract shared hooks and UI components

This commit is contained in:
2026-06-28 02:06:54 +07:00
parent 0ef23dcb3b
commit 2dd6931425
23 changed files with 132 additions and 155 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { ButtonHTMLAttributes } from "react";
const BASE =
"group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors";
export function Card({
className = "",
interactive = true,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { interactive?: boolean }) {
return (
<button
className={`${BASE} ${interactive ? "hover:border-accent" : ""} ${className}`}
{...props}
/>
);
}
@@ -0,0 +1,26 @@
import { useCopied } from "../../lib/useCopied";
export function CodeBlock({
value,
copyValue,
className = "",
}: {
value: string;
copyValue?: string;
className?: string;
}) {
const [copied, copy] = useCopied();
return (
<div className={`relative ${className}`}>
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(copyValue ?? value)}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{value}
</pre>
</div>
);
}
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { LABEL_HEADING } from "./styles";
export function CollapsibleCard({
title,
@@ -17,9 +18,7 @@ export function CollapsibleCard({
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
<button
onClick={() => setOpen((v) => !v)}
className={`flex w-full items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint ${
open ? "mb-3" : ""
}`}
className={`flex w-full items-center gap-1.5 ${LABEL_HEADING} ${open ? "mb-3" : ""}`}
>
<span className="text-faint">
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
@@ -0,0 +1,15 @@
import type { ImgHTMLAttributes } from "react";
export function HoverImage({
className = "",
alt = "",
...props
}: ImgHTMLAttributes<HTMLImageElement>) {
return (
<img
alt={alt}
className={`size-full object-cover transition-transform duration-300 group-hover:scale-105 ${className}`}
{...props}
/>
);
}
+1 -2
View File
@@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
const HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint";
import { LABEL_HEADING as HEADING } from "./styles";
export function Section({
title,
@@ -0,0 +1,3 @@
export function Skeleton({ className = "" }: { className?: string }) {
return <div className={`sk ${className}`} />;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { LABEL_HEADING } from "./styles";
export function StatTile({
icon,
@@ -13,7 +14,7 @@ export function StatTile({
}) {
return (
<div className="rounded-xl border border-border bg-surface-2 p-4 shadow-sm">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint">
<div className={`flex items-center gap-1.5 ${LABEL_HEADING}`}>
{icon} {label}
</div>
<div
+5
View File
@@ -16,4 +16,9 @@ export { Toggle } from "./Toggle";
export { Section, Fact } from "./Section";
export { StatTile } from "./StatTile";
export { SkeletonGrid } from "./SkeletonGrid";
export { Skeleton } from "./Skeleton";
export { LinkPill } from "./LinkPill";
export { CodeBlock } from "./CodeBlock";
export { Card } from "./Card";
export { HoverImage } from "./HoverImage";
export { LABEL_HEADING } from "./styles";
+1
View File
@@ -0,0 +1 @@
export const LABEL_HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint";
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import type { CacheEntryInfo, CacheStats } from "../../../../../shared/types/debug";
import { Badge, Button, Panel, Stat } from "../../../components/ui";
import { useCopied, useNow } from "../useDebug";
import { Badge, Button, CodeBlock, Panel, Stat } from "../../../components/ui";
import { useNow } from "../useDebug";
import {
Chip,
Empty,
@@ -155,7 +155,6 @@ function CacheRow({
onInvalidate: () => void;
}) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
const remaining = entry.expiresAt - now;
const total = entry.expiresAt - entry.createdAt;
const pct = total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
@@ -214,17 +213,7 @@ function CacheRow({
value={entry.lastAccess ? formatTime(entry.lastAccess) : "never"}
/>
</dl>
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(entry.value, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(entry.value, null, 2)}
</pre>
</div>
<CodeBlock value={JSON.stringify(entry.value, null, 2)} />
</div>
) : null}
</div>
@@ -1,9 +1,8 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Users } from "lucide-react";
import type { Group } from "../../../../../shared/types/group";
import { Avatar, Badge, Panel } from "../../../components/ui";
import { Avatar, Badge, CodeBlock, Panel } from "../../../components/ui";
import { useAllGroups, useGroups } from "../../../store/groups";
import { useCopied } from "../useDebug";
import { Empty, SearchInput } from "../ui";
export function GroupStorePanel() {
@@ -45,7 +44,6 @@ export function GroupStorePanel() {
function GroupStoreRow({ group }: { group: Group }) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
return (
<div className="border-b border-border last:border-b-0">
<button
@@ -67,17 +65,7 @@ function GroupStoreRow({ group }: { group: Group }) {
</button>
{open ? (
<div className="px-4 pb-3 pl-[2.4rem]">
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(group, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(group, null, 2)}
</pre>
</div>
<CodeBlock value={JSON.stringify(group, null, 2)} />
</div>
) : null}
</div>
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository";
import { Button, Panel, Stat } from "../../../components/ui";
import { Button, CodeBlock, Panel, Stat } from "../../../components/ui";
import { api } from "../../../lib/api";
import { useCopied, useNow } from "../useDebug";
import { useNow } from "../useDebug";
import {
Empty,
Meta,
@@ -192,7 +192,6 @@ function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void
function EntityRow({ entity, now }: { entity: StoredEntity<{ id: string }>; now: number }) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
const data = entity.data as Record<string, unknown>;
const label = (data.name ?? data.displayName ?? data.id) as string;
const fieldNames = Object.keys(entity.meta.fields).sort();
@@ -255,17 +254,10 @@ function EntityRow({ entity, now }: { entity: StoredEntity<{ id: string }>; now:
<Meta label="Last read" value={relativeAge(now, entity.meta.lastRead)} />
<Meta label="Last fetch" value={relativeAge(now, entity.meta.lastFetch)} />
</dl>
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(entity, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(entity.data, null, 2)}
</pre>
</div>
<CodeBlock
value={JSON.stringify(entity.data, null, 2)}
copyValue={JSON.stringify(entity, null, 2)}
/>
</div>
) : null}
</div>
@@ -1,10 +1,9 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { UserProfile } from "../../../../../shared/types/user";
import { Badge, Panel, PresenceAvatar, Tag } from "../../../components/ui";
import { Badge, CodeBlock, Panel, PresenceAvatar, Tag } from "../../../components/ui";
import { statusMeta, trustMeta } from "../../../lib/vrchat";
import { useSocial } from "../../../store/social";
import { useCopied } from "../useDebug";
import { Chip, Empty, Meta, SearchInput } from "../ui";
type SocialFilter = "all" | "self" | "friends" | "other";
@@ -72,7 +71,6 @@ export function SocialTab() {
function UserRow({ user, isSelf }: { user: UserProfile; isSelf: boolean }) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
const status = statusMeta[user.status];
const trust = trustMeta[user.trustRank];
@@ -111,17 +109,7 @@ function UserRow({ user, isSelf }: { user: UserProfile; isSelf: boolean }) {
{user.statusDescription ? (
<p className="mb-2 text-[12px] italic text-muted">{user.statusDescription}</p>
) : null}
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(user, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(user, null, 2)}
</pre>
</div>
<CodeBlock value={JSON.stringify(user, null, 2)} />
</div>
) : null}
</div>
@@ -1,8 +1,7 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { WsEvent } from "../../../../../shared/types/debug";
import { Badge, Button, Panel } from "../../../components/ui";
import { useCopied } from "../useDebug";
import { Badge, Button, CodeBlock, Panel } from "../../../components/ui";
import { Chip, Empty, SearchInput, formatTime } from "../ui";
export function WsTab({ ws, onClear }: { ws: WsEvent[]; onClear: () => void }) {
@@ -49,7 +48,6 @@ export function WsTab({ ws, onClear }: { ws: WsEvent[]; onClear: () => void }) {
function WsRow({ event }: { event: WsEvent }) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
return (
<div className="border-b border-border last:border-b-0">
<button
@@ -65,17 +63,7 @@ function WsRow({ event }: { event: WsEvent }) {
</button>
{open ? (
<div className="px-4 pb-3 pl-[2.4rem]">
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(event.content, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(event.content, null, 2)}
</pre>
</div>
<CodeBlock value={JSON.stringify(event.content, null, 2)} />
</div>
) : null}
</div>
@@ -1,9 +1,8 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Star } from "lucide-react";
import type { World } from "../../../../../shared/types/world";
import { Badge, Panel } from "../../../components/ui";
import { Badge, CodeBlock, Panel } from "../../../components/ui";
import { useAllWorlds, useWorlds } from "../../../store/worlds";
import { useCopied } from "../useDebug";
import { Empty, SearchInput } from "../ui";
export function WorldStorePanel() {
@@ -45,7 +44,6 @@ export function WorldStorePanel() {
function WorldStoreRow({ world }: { world: World }) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
const img = world.thumbnailImageUrl || world.imageUrl;
return (
<div className="border-b border-border last:border-b-0">
@@ -72,17 +70,7 @@ function WorldStoreRow({ world }: { world: World }) {
</button>
{open ? (
<div className="px-4 pb-3 pl-[2.4rem]">
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(world, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(world, null, 2)}
</pre>
</div>
<CodeBlock value={JSON.stringify(world, null, 2)} />
</div>
) : null}
</div>
+3 -14
View File
@@ -1,8 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import type { CacheEntryInfo, CacheStats, LogEntry, WsEvent } from "../../../../shared/types/debug";
import type { RepoStats } from "../../../../shared/types/repository";
import { api, events } from "../../lib/api";
export { useCopied } from "../../lib/useCopied";
const MAX_LOGS = 500;
export function useNow(ms = 1000): number {
@@ -14,19 +16,6 @@ export function useNow(ms = 1000): number {
return now;
}
export function useCopied(): [boolean, (text: string) => void] {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const copy = (text: string): void => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1200);
});
};
return [copied, copy];
}
export function useDebug() {
const [cache, setCache] = useState<CacheEntryInfo[]>([]);
const [stats, setStats] = useState<CacheStats | null>(null);
@@ -1,6 +1,6 @@
import { BadgeCheck, Globe, Users, UserCheck } from "lucide-react";
import type { Group } from "../../../../shared/types/group";
import { Avatar, Banner, Fact, Section, StatTile, Tag } from "../../components/ui";
import { Avatar, Banner, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
@@ -132,15 +132,15 @@ function GroupSkeleton() {
>
<div className="profile__banner" />
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
<div className="sk size-[112px] rounded-2xl" />
<Skeleton className="size-[112px] rounded-2xl" />
<div className="flex-1 pb-1">
<div className="sk h-[22px] w-2/5 rounded-lg" />
<div className="sk mt-3 h-3 w-1/4 rounded-lg" />
<Skeleton className="h-[22px] w-2/5 rounded-lg" />
<Skeleton className="mt-3 h-3 w-1/4 rounded-lg" />
</div>
</div>
<div className={`${COL_WIDE} mt-6 grid grid-cols-3 gap-3`}>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="sk h-[72px] rounded-xl" />
<Skeleton key={i} className="h-[72px] rounded-xl" />
))}
</div>
</div>
@@ -1,5 +1,6 @@
import { ChevronRight, Globe, MapPin, Users } from "lucide-react";
import { parseLocation, type Location } from "../../../../shared/types/user";
import { HoverImage } from "../../components/ui";
import { useWorld } from "../../store/worlds";
import { useNav } from "../navigation/NavContext";
import { locationLabel, regionLabels } from "../../lib/vrchat";
@@ -35,13 +36,7 @@ export function LocationSection({ location }: { location?: Location }) {
className="group flex w-full items-center gap-3.5 rounded-lg p-1 text-left transition-colors hover:bg-surface-hover"
>
<div className="size-16 shrink-0 overflow-hidden rounded-lg bg-surface-hover">
{img ? (
<img
src={img}
alt=""
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : null}
{img ? <HoverImage src={img} /> : null}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[16px] font-semibold leading-tight" title={world.name}>
@@ -1,6 +1,6 @@
import { useState } from "react";
import { type UserProfile } from "../../../../shared/types/user";
import { Avatar, Banner, Fact, LinkPill, Section, Tabs, Tag } from "../../components/ui";
import { Avatar, Banner, Fact, LinkPill, Section, Skeleton, Tabs, Tag } from "../../components/ui";
import {
avatarOf,
bannerOf,
@@ -266,14 +266,14 @@ function ProfileSkeleton() {
>
<div className="profile__banner" />
<div className={`${COL_WIDE} relative flex items-end gap-6`} style={{ marginTop: -72 }}>
<div className="profile__avatar sk" />
<Skeleton className="profile__avatar" />
<div className="flex-1 pb-2">
<div className="sk h-[22px] w-3/5 rounded-lg" />
<div className="sk mt-3 h-3 w-2/5 rounded-lg" />
<Skeleton className="h-[22px] w-3/5 rounded-lg" />
<Skeleton className="mt-3 h-3 w-2/5 rounded-lg" />
</div>
</div>
<div className={`${COL} sk mt-[18px] h-3.5 rounded-lg`} />
<div className={`${COL} sk mt-[18px] h-3.5 w-2/5 rounded-lg`} />
<Skeleton className={`${COL} mt-[18px] h-3.5 rounded-lg`} />
<Skeleton className={`${COL} mt-[18px] h-3.5 w-2/5 rounded-lg`} />
</div>
);
}
@@ -1,6 +1,6 @@
import { Circle, Star, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world";
import { CollapsibleCard, SkeletonGrid, Tag } from "../../components/ui";
import { Card, CollapsibleCard, HoverImage, SkeletonGrid, Tag } from "../../components/ui";
import { compactNumber } from "../../lib/format";
import { useNav } from "../navigation/NavContext";
import { useUserWorlds } from "./useUserWorlds";
@@ -103,19 +103,9 @@ function WorldCard({ world }: { world: World }) {
const { openWorld } = useNav();
const img = world.thumbnailImageUrl || world.imageUrl;
return (
<button
onClick={() => openWorld(world.id)}
className="group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors hover:border-accent"
>
<Card onClick={() => openWorld(world.id)}>
<div className="relative aspect-video bg-surface-hover">
{img ? (
<img
src={img}
alt=""
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : null}
{img ? <HoverImage src={img} loading="lazy" /> : null}
{world.releaseStatus !== "public" ? (
<span className="absolute right-1.5 top-1.5">
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
@@ -147,6 +137,6 @@ function WorldCard({ world }: { world: World }) {
</span>
</div>
</div>
</button>
</Card>
);
}
@@ -4,7 +4,7 @@ import type { UserProfile } from "../../../../shared/types/user";
import type { World } from "../../../../shared/types/world";
import { api } from "../../lib/api";
import { compactNumber } from "../../lib/format";
import { Avatar, Button, Field, Tabs, Tag } from "../../components/ui";
import { Avatar, Button, Card, Field, HoverImage, Tabs, Tag } from "../../components/ui";
import { useNav } from "../navigation/NavContext";
import { avatarOf, trustMeta } from "../../lib/vrchat";
@@ -113,19 +113,9 @@ function UserResult({ user, onOpen }: { user: UserProfile; onOpen: () => void })
function WorldResult({ world, onOpen }: { world: World; onOpen: () => void }) {
const img = world.thumbnailImageUrl || world.imageUrl;
return (
<button
onClick={onOpen}
className="group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors hover:border-accent"
>
<Card onClick={onOpen}>
<div className="relative aspect-video bg-surface-hover">
{img ? (
<img
src={img}
alt=""
loading="lazy"
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : null}
{img ? <HoverImage src={img} loading="lazy" /> : null}
</div>
<div className="p-2.5">
<div className="truncate text-[13px] font-semibold" title={world.name}>
@@ -152,6 +142,6 @@ function WorldResult({ world, onOpen }: { world: World; onOpen: () => void }) {
</span>
</div>
</div>
</button>
</Card>
);
}
@@ -1,6 +1,6 @@
import { Globe, Heart, Tag as TagIcon, Users } from "lucide-react";
import type { World } from "../../../../shared/types/world";
import { Banner, Fact, Section, StatTile, Tag } from "../../components/ui";
import { Banner, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
import { api } from "../../lib/api";
import { useAsync } from "../../lib/useAsync";
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
@@ -160,15 +160,15 @@ function WorldSkeleton() {
>
<div className="profile__banner" />
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
<div className="world__thumb sk" />
<Skeleton className="world__thumb" />
<div className="flex-1 pb-1">
<div className="sk h-[22px] w-2/5 rounded-lg" />
<div className="sk mt-3 h-3 w-1/4 rounded-lg" />
<Skeleton className="h-[22px] w-2/5 rounded-lg" />
<Skeleton className="mt-3 h-3 w-1/4 rounded-lg" />
</div>
</div>
<div className={`${COL_WIDE} mt-6 grid grid-cols-4 gap-3`}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="sk h-[72px] rounded-xl" />
<Skeleton key={i} className="h-[72px] rounded-xl" />
))}
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
import { useRef, useState } from "react";
export function useCopied(): [boolean, (text: string) => void] {
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const copy = (text: string): void => {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1200);
});
};
return [copied, copy];
}