mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
♻️ refactor: Extract shared hooks and UI components
This commit is contained in:
@@ -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,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}`} />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user