mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +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 { useState, type ReactNode } from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { LABEL_HEADING } from "./styles";
|
||||||
|
|
||||||
export function CollapsibleCard({
|
export function CollapsibleCard({
|
||||||
title,
|
title,
|
||||||
@@ -17,9 +18,7 @@ export function CollapsibleCard({
|
|||||||
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
|
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className={`flex w-full items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint ${
|
className={`flex w-full items-center gap-1.5 ${LABEL_HEADING} ${open ? "mb-3" : ""}`}
|
||||||
open ? "mb-3" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span className="text-faint">
|
<span className="text-faint">
|
||||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
{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 { useState, type ReactNode } from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import { LABEL_HEADING as HEADING } from "./styles";
|
||||||
const HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint";
|
|
||||||
|
|
||||||
export function Section({
|
export function Section({
|
||||||
title,
|
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 type { ReactNode } from "react";
|
||||||
|
import { LABEL_HEADING } from "./styles";
|
||||||
|
|
||||||
export function StatTile({
|
export function StatTile({
|
||||||
icon,
|
icon,
|
||||||
@@ -13,7 +14,7 @@ export function StatTile({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-surface-2 p-4 shadow-sm">
|
<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}
|
{icon} {label}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ 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 { Skeleton } from "./Skeleton";
|
||||||
export { LinkPill } from "./LinkPill";
|
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 { useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||||
import type { CacheEntryInfo, CacheStats } from "../../../../../shared/types/debug";
|
import type { CacheEntryInfo, CacheStats } from "../../../../../shared/types/debug";
|
||||||
import { Badge, Button, Panel, Stat } from "../../../components/ui";
|
import { Badge, Button, CodeBlock, Panel, Stat } from "../../../components/ui";
|
||||||
import { useCopied, useNow } from "../useDebug";
|
import { useNow } from "../useDebug";
|
||||||
import {
|
import {
|
||||||
Chip,
|
Chip,
|
||||||
Empty,
|
Empty,
|
||||||
@@ -155,7 +155,6 @@ function CacheRow({
|
|||||||
onInvalidate: () => void;
|
onInvalidate: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, copy] = useCopied();
|
|
||||||
const remaining = entry.expiresAt - now;
|
const remaining = entry.expiresAt - now;
|
||||||
const total = entry.expiresAt - entry.createdAt;
|
const total = entry.expiresAt - entry.createdAt;
|
||||||
const pct = total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
|
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"}
|
value={entry.lastAccess ? formatTime(entry.lastAccess) : "never"}
|
||||||
/>
|
/>
|
||||||
</dl>
|
</dl>
|
||||||
<div className="relative">
|
<CodeBlock value={JSON.stringify(entry.value, null, 2)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Users } from "lucide-react";
|
import { ChevronDown, ChevronRight, Users } from "lucide-react";
|
||||||
import type { Group } from "../../../../../shared/types/group";
|
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 { useAllGroups, useGroups } from "../../../store/groups";
|
||||||
import { useCopied } from "../useDebug";
|
|
||||||
import { Empty, SearchInput } from "../ui";
|
import { Empty, SearchInput } from "../ui";
|
||||||
|
|
||||||
export function GroupStorePanel() {
|
export function GroupStorePanel() {
|
||||||
@@ -45,7 +44,6 @@ export function GroupStorePanel() {
|
|||||||
|
|
||||||
function GroupStoreRow({ group }: { group: Group }) {
|
function GroupStoreRow({ group }: { group: Group }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, copy] = useCopied();
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border last:border-b-0">
|
<div className="border-b border-border last:border-b-0">
|
||||||
<button
|
<button
|
||||||
@@ -67,17 +65,7 @@ function GroupStoreRow({ group }: { group: Group }) {
|
|||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="px-4 pb-3 pl-[2.4rem]">
|
<div className="px-4 pb-3 pl-[2.4rem]">
|
||||||
<div className="relative">
|
<CodeBlock value={JSON.stringify(group, null, 2)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||||
import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository";
|
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 { api } from "../../../lib/api";
|
||||||
import { useCopied, useNow } from "../useDebug";
|
import { useNow } from "../useDebug";
|
||||||
import {
|
import {
|
||||||
Empty,
|
Empty,
|
||||||
Meta,
|
Meta,
|
||||||
@@ -192,7 +192,6 @@ function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void
|
|||||||
|
|
||||||
function EntityRow({ entity, now }: { entity: StoredEntity<{ id: string }>; now: number }) {
|
function EntityRow({ entity, now }: { entity: StoredEntity<{ id: string }>; now: number }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, copy] = useCopied();
|
|
||||||
const data = entity.data as Record<string, unknown>;
|
const data = entity.data as Record<string, unknown>;
|
||||||
const label = (data.name ?? data.displayName ?? data.id) as string;
|
const label = (data.name ?? data.displayName ?? data.id) as string;
|
||||||
const fieldNames = Object.keys(entity.meta.fields).sort();
|
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 read" value={relativeAge(now, entity.meta.lastRead)} />
|
||||||
<Meta label="Last fetch" value={relativeAge(now, entity.meta.lastFetch)} />
|
<Meta label="Last fetch" value={relativeAge(now, entity.meta.lastFetch)} />
|
||||||
</dl>
|
</dl>
|
||||||
<div className="relative">
|
<CodeBlock
|
||||||
<button
|
value={JSON.stringify(entity.data, null, 2)}
|
||||||
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"
|
copyValue={JSON.stringify(entity, null, 2)}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import type { UserProfile } from "../../../../../shared/types/user";
|
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 { statusMeta, trustMeta } from "../../../lib/vrchat";
|
||||||
import { useSocial } from "../../../store/social";
|
import { useSocial } from "../../../store/social";
|
||||||
import { useCopied } from "../useDebug";
|
|
||||||
import { Chip, Empty, Meta, SearchInput } from "../ui";
|
import { Chip, Empty, Meta, SearchInput } from "../ui";
|
||||||
|
|
||||||
type SocialFilter = "all" | "self" | "friends" | "other";
|
type SocialFilter = "all" | "self" | "friends" | "other";
|
||||||
@@ -72,7 +71,6 @@ export function SocialTab() {
|
|||||||
|
|
||||||
function UserRow({ user, isSelf }: { user: UserProfile; isSelf: boolean }) {
|
function UserRow({ user, isSelf }: { user: UserProfile; isSelf: boolean }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, copy] = useCopied();
|
|
||||||
const status = statusMeta[user.status];
|
const status = statusMeta[user.status];
|
||||||
const trust = trustMeta[user.trustRank];
|
const trust = trustMeta[user.trustRank];
|
||||||
|
|
||||||
@@ -111,17 +109,7 @@ function UserRow({ user, isSelf }: { user: UserProfile; isSelf: boolean }) {
|
|||||||
{user.statusDescription ? (
|
{user.statusDescription ? (
|
||||||
<p className="mb-2 text-[12px] italic text-muted">“{user.statusDescription}”</p>
|
<p className="mb-2 text-[12px] italic text-muted">“{user.statusDescription}”</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative">
|
<CodeBlock value={JSON.stringify(user, null, 2)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import type { WsEvent } from "../../../../../shared/types/debug";
|
import type { WsEvent } from "../../../../../shared/types/debug";
|
||||||
import { Badge, Button, Panel } from "../../../components/ui";
|
import { Badge, Button, CodeBlock, Panel } from "../../../components/ui";
|
||||||
import { useCopied } from "../useDebug";
|
|
||||||
import { Chip, Empty, SearchInput, formatTime } from "../ui";
|
import { Chip, Empty, SearchInput, formatTime } from "../ui";
|
||||||
|
|
||||||
export function WsTab({ ws, onClear }: { ws: WsEvent[]; onClear: () => void }) {
|
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 }) {
|
function WsRow({ event }: { event: WsEvent }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, copy] = useCopied();
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border last:border-b-0">
|
<div className="border-b border-border last:border-b-0">
|
||||||
<button
|
<button
|
||||||
@@ -65,17 +63,7 @@ function WsRow({ event }: { event: WsEvent }) {
|
|||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="px-4 pb-3 pl-[2.4rem]">
|
<div className="px-4 pb-3 pl-[2.4rem]">
|
||||||
<div className="relative">
|
<CodeBlock value={JSON.stringify(event.content, null, 2)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Star } from "lucide-react";
|
import { ChevronDown, ChevronRight, Star } from "lucide-react";
|
||||||
import type { World } from "../../../../../shared/types/world";
|
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 { useAllWorlds, useWorlds } from "../../../store/worlds";
|
||||||
import { useCopied } from "../useDebug";
|
|
||||||
import { Empty, SearchInput } from "../ui";
|
import { Empty, SearchInput } from "../ui";
|
||||||
|
|
||||||
export function WorldStorePanel() {
|
export function WorldStorePanel() {
|
||||||
@@ -45,7 +44,6 @@ export function WorldStorePanel() {
|
|||||||
|
|
||||||
function WorldStoreRow({ world }: { world: World }) {
|
function WorldStoreRow({ world }: { world: World }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [copied, copy] = useCopied();
|
|
||||||
const img = world.thumbnailImageUrl || world.imageUrl;
|
const img = world.thumbnailImageUrl || world.imageUrl;
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border last:border-b-0">
|
<div className="border-b border-border last:border-b-0">
|
||||||
@@ -72,17 +70,7 @@ function WorldStoreRow({ world }: { world: World }) {
|
|||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open ? (
|
||||||
<div className="px-4 pb-3 pl-[2.4rem]">
|
<div className="px-4 pb-3 pl-[2.4rem]">
|
||||||
<div className="relative">
|
<CodeBlock value={JSON.stringify(world, null, 2)} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 { CacheEntryInfo, CacheStats, LogEntry, WsEvent } from "../../../../shared/types/debug";
|
||||||
import type { RepoStats } from "../../../../shared/types/repository";
|
import type { RepoStats } from "../../../../shared/types/repository";
|
||||||
import { api, events } from "../../lib/api";
|
import { api, events } from "../../lib/api";
|
||||||
|
|
||||||
|
export { useCopied } from "../../lib/useCopied";
|
||||||
|
|
||||||
const MAX_LOGS = 500;
|
const MAX_LOGS = 500;
|
||||||
|
|
||||||
export function useNow(ms = 1000): number {
|
export function useNow(ms = 1000): number {
|
||||||
@@ -14,19 +16,6 @@ export function useNow(ms = 1000): number {
|
|||||||
return now;
|
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() {
|
export function useDebug() {
|
||||||
const [cache, setCache] = useState<CacheEntryInfo[]>([]);
|
const [cache, setCache] = useState<CacheEntryInfo[]>([]);
|
||||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadgeCheck, Globe, Users, UserCheck } from "lucide-react";
|
import { BadgeCheck, Globe, Users, UserCheck } from "lucide-react";
|
||||||
import type { Group } from "../../../../shared/types/group";
|
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 { api } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
||||||
@@ -132,15 +132,15 @@ function GroupSkeleton() {
|
|||||||
>
|
>
|
||||||
<div className="profile__banner" />
|
<div className="profile__banner" />
|
||||||
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
|
<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="flex-1 pb-1">
|
||||||
<div className="sk h-[22px] w-2/5 rounded-lg" />
|
<Skeleton className="h-[22px] w-2/5 rounded-lg" />
|
||||||
<div className="sk mt-3 h-3 w-1/4 rounded-lg" />
|
<Skeleton className="mt-3 h-3 w-1/4 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${COL_WIDE} mt-6 grid grid-cols-3 gap-3`}>
|
<div className={`${COL_WIDE} mt-6 grid grid-cols-3 gap-3`}>
|
||||||
{Array.from({ length: 3 }).map((_, i) => (
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ChevronRight, Globe, MapPin, Users } from "lucide-react";
|
import { ChevronRight, Globe, 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 { useWorld } from "../../store/worlds";
|
import { useWorld } from "../../store/worlds";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { locationLabel, regionLabels } from "../../lib/vrchat";
|
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"
|
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">
|
<div className="size-16 shrink-0 overflow-hidden rounded-lg bg-surface-hover">
|
||||||
{img ? (
|
{img ? <HoverImage src={img} /> : null}
|
||||||
<img
|
|
||||||
src={img}
|
|
||||||
alt=""
|
|
||||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate text-[16px] font-semibold leading-tight" title={world.name}>
|
<div className="truncate text-[16px] font-semibold leading-tight" title={world.name}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { type UserProfile } from "../../../../shared/types/user";
|
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 {
|
import {
|
||||||
avatarOf,
|
avatarOf,
|
||||||
bannerOf,
|
bannerOf,
|
||||||
@@ -266,14 +266,14 @@ function ProfileSkeleton() {
|
|||||||
>
|
>
|
||||||
<div className="profile__banner" />
|
<div className="profile__banner" />
|
||||||
<div className={`${COL_WIDE} relative flex items-end gap-6`} style={{ marginTop: -72 }}>
|
<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="flex-1 pb-2">
|
||||||
<div className="sk h-[22px] w-3/5 rounded-lg" />
|
<Skeleton className="h-[22px] w-3/5 rounded-lg" />
|
||||||
<div className="sk mt-3 h-3 w-2/5 rounded-lg" />
|
<Skeleton className="mt-3 h-3 w-2/5 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${COL} sk mt-[18px] h-3.5 rounded-lg`} />
|
<Skeleton className={`${COL} 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 w-2/5 rounded-lg`} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { CollapsibleCard, SkeletonGrid, Tag } from "../../components/ui";
|
import { Card, CollapsibleCard, HoverImage, SkeletonGrid, Tag } from "../../components/ui";
|
||||||
import { compactNumber } from "../../lib/format";
|
import { compactNumber } from "../../lib/format";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { useUserWorlds } from "./useUserWorlds";
|
import { useUserWorlds } from "./useUserWorlds";
|
||||||
@@ -103,19 +103,9 @@ function WorldCard({ world }: { world: World }) {
|
|||||||
const { openWorld } = useNav();
|
const { openWorld } = useNav();
|
||||||
const img = world.thumbnailImageUrl || world.imageUrl;
|
const img = world.thumbnailImageUrl || world.imageUrl;
|
||||||
return (
|
return (
|
||||||
<button
|
<Card onClick={() => openWorld(world.id)}>
|
||||||
onClick={() => openWorld(world.id)}
|
|
||||||
className="group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors hover:border-accent"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-video bg-surface-hover">
|
<div className="relative aspect-video bg-surface-hover">
|
||||||
{img ? (
|
{img ? <HoverImage src={img} loading="lazy" /> : null}
|
||||||
<img
|
|
||||||
src={img}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{world.releaseStatus !== "public" ? (
|
{world.releaseStatus !== "public" ? (
|
||||||
<span className="absolute right-1.5 top-1.5">
|
<span className="absolute right-1.5 top-1.5">
|
||||||
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
|
<Tag color="var(--status-ask)">{world.releaseStatus}</Tag>
|
||||||
@@ -147,6 +137,6 @@ function WorldCard({ world }: { world: World }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ 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 { compactNumber } from "../../lib/format";
|
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 { useNav } from "../navigation/NavContext";
|
||||||
import { avatarOf, trustMeta } from "../../lib/vrchat";
|
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 }) {
|
function WorldResult({ world, onOpen }: { world: World; onOpen: () => void }) {
|
||||||
const img = world.thumbnailImageUrl || world.imageUrl;
|
const img = world.thumbnailImageUrl || world.imageUrl;
|
||||||
return (
|
return (
|
||||||
<button
|
<Card onClick={onOpen}>
|
||||||
onClick={onOpen}
|
|
||||||
className="group block overflow-hidden rounded-lg border border-border bg-surface text-left transition-colors hover:border-accent"
|
|
||||||
>
|
|
||||||
<div className="relative aspect-video bg-surface-hover">
|
<div className="relative aspect-video bg-surface-hover">
|
||||||
{img ? (
|
{img ? <HoverImage src={img} loading="lazy" /> : null}
|
||||||
<img
|
|
||||||
src={img}
|
|
||||||
alt=""
|
|
||||||
loading="lazy"
|
|
||||||
className="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2.5">
|
<div className="p-2.5">
|
||||||
<div className="truncate text-[13px] font-semibold" title={world.name}>
|
<div className="truncate text-[13px] font-semibold" title={world.name}>
|
||||||
@@ -152,6 +142,6 @@ function WorldResult({ world, onOpen }: { world: World; onOpen: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Globe, Heart, Tag as TagIcon, Users } from "lucide-react";
|
import { Globe, Heart, Tag as TagIcon, Users } from "lucide-react";
|
||||||
import type { World } from "../../../../shared/types/world";
|
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 { api } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
||||||
@@ -160,15 +160,15 @@ function WorldSkeleton() {
|
|||||||
>
|
>
|
||||||
<div className="profile__banner" />
|
<div className="profile__banner" />
|
||||||
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
|
<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="flex-1 pb-1">
|
||||||
<div className="sk h-[22px] w-2/5 rounded-lg" />
|
<Skeleton className="h-[22px] w-2/5 rounded-lg" />
|
||||||
<div className="sk mt-3 h-3 w-1/4 rounded-lg" />
|
<Skeleton className="mt-3 h-3 w-1/4 rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={`${COL_WIDE} mt-6 grid grid-cols-4 gap-3`}>
|
<div className={`${COL_WIDE} mt-6 grid grid-cols-4 gap-3`}>
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{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>
|
||||||
</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