feat: Avatars tab

This commit is contained in:
2026-06-30 01:36:23 +07:00
parent e575f99116
commit 5d983fdbd1
25 changed files with 1183 additions and 27 deletions
+2
View File
@@ -12,6 +12,7 @@ export const policies = {
world: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, world: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
avatar: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, avatar: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
avatarFavorites: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, avatarFavorites: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
avatarMine: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 }, representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 }, group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
@@ -33,6 +34,7 @@ export const cacheKeys = {
world: (id: string) => `world:${id}`, world: (id: string) => `world:${id}`,
avatar: (id: string) => `avatar:${id}`, avatar: (id: string) => `avatar:${id}`,
avatarFavorites: () => "avatar:favorites", avatarFavorites: () => "avatar:favorites",
avatarMine: () => "avatar:mine",
userGroups: (id: string) => `user:groups:${id}`, userGroups: (id: string) => `user:groups:${id}`,
representedGroup: (id: string) => `user:group:represented:${id}`, representedGroup: (id: string) => `user:group:represented:${id}`,
group: (id: string) => `group:${id}`, group: (id: string) => `group:${id}`,
+8 -1
View File
@@ -61,7 +61,14 @@ const handlers = {
guard(() => instances.inviteSelf(worldId, instanceId)), guard(() => instances.inviteSelf(worldId, instanceId)),
"avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)), "avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)),
"avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()), "avatar:snapshot": () => guard(async () => avatars.avatarSnapshot()),
"avatar:loadMine": () => guard(() => avatars.loadMyAvatars()),
"avatar:loadFavorites": () => guard(() => avatars.loadFavoritedAvatars()),
"avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)),
"avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)),
"avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)),
"avatar:setFavorited": ({ avatarId, favorited }) =>
guard(() => avatars.setAvatarFavorited(avatarId, favorited)),
"group:byUser": (userId) => guard(() => groups.getUserGroups(userId)), "group:byUser": (userId) => guard(() => groups.getUserGroups(userId)),
"group:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)), "group:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)),
+84
View File
@@ -0,0 +1,84 @@
import type { Avatar, AvatarSnapshot, FavoriteAvatarFolder } from "../../shared/types/avatar";
import type { FieldSource } from "../../shared/types/repository";
import { repos } from "./repository/manager";
export type { AvatarSnapshot };
type Change = { type: "seed"; snapshot: AvatarSnapshot } | { type: "upsert"; avatar: Avatar };
type Listener = (change: Change) => void;
class AvatarStore {
private readonly listeners = new Set<Listener>();
private mineIds = new Set<string>();
private favorites: FavoriteAvatarFolder[] = [];
private wired = false;
onChange(fn: Listener): () => void {
this.wire();
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private wire(): void {
if (this.wired || !repos.hasActive) return;
this.wired = true;
repos.active.avatars.onChange((c) => {
this.emit({ type: "upsert", avatar: c.entity });
});
}
setMine(avatars: Avatar[]): void {
this.mineIds = new Set(avatars.map((a) => a.id));
repos.active.avatars.upsertMany(avatars, "rest:list");
this.emit({ type: "seed", snapshot: this.snapshot() });
}
setFavorites(folders: { name: string; displayName: string; avatars: Avatar[] }[]): void {
this.favorites = folders.map((f) => ({
name: f.name,
displayName: f.displayName,
avatarIds: f.avatars.map((a) => a.id),
}));
for (const f of folders) repos.active.avatars.upsertMany(f.avatars, "rest:list");
this.emit({ type: "seed", snapshot: this.snapshot() });
}
addAvatar(avatar: Avatar, src: FieldSource = "rest:detail"): void {
repos.active.avatars.upsert(avatar, src);
}
removeAvatar(avatarId: string): void {
this.mineIds.delete(avatarId);
this.favorites = this.favorites
.map((f) => ({ ...f, avatarIds: f.avatarIds.filter((id) => id !== avatarId) }))
.filter((f) => f.avatarIds.length);
repos.active.avatars.remove(avatarId);
this.emit({ type: "seed", snapshot: this.snapshot() });
}
get(avatarId: string): Avatar | undefined {
return repos.active.avatars.get(avatarId);
}
snapshot(): AvatarSnapshot {
return {
avatars: repos.hasActive ? repos.active.avatars.all() : [],
mineIds: [...this.mineIds],
favorites: this.favorites,
};
}
reset(): void {
this.mineIds.clear();
this.favorites = [];
this.wired = false;
this.wire();
this.emit({ type: "seed", snapshot: this.snapshot() });
}
private emit(change: Change): void {
for (const fn of this.listeners) fn(change);
}
}
export const avatarStore = new AvatarStore();
+7
View File
@@ -9,6 +9,7 @@ import { activeId } from "../accounts/store";
import { entityStore, type SocialSnapshot } from "./entityStore"; import { entityStore, type SocialSnapshot } from "./entityStore";
import { worldStore } from "./worldStore"; import { worldStore } from "./worldStore";
import { groupStore } from "./groupStore"; import { groupStore } from "./groupStore";
import { avatarStore } from "./avatarStore";
import { repos } from "./repository/manager"; import { repos } from "./repository/manager";
import { broadcast } from "../windows"; import { broadcast } from "../windows";
import { logger } from "../debug/logger"; import { logger } from "../debug/logger";
@@ -172,6 +173,7 @@ export async function seedActiveAccount(force = false): Promise<void> {
if (!id) { if (!id) {
worldStore.reset(); worldStore.reset();
groupStore.reset(); groupStore.reset();
avatarStore.reset();
entityStore.reset(); entityStore.reset();
return; return;
} }
@@ -212,4 +214,9 @@ export function startSocialBridge(): void {
if (c.type === "seed") broadcast("group:seed", c.snapshot); if (c.type === "seed") broadcast("group:seed", c.snapshot);
else broadcast("group:upsert", c.group); else broadcast("group:upsert", c.group);
}); });
avatarStore.onChange((c) => {
if (c.type === "seed") broadcast("avatar:seed", c.snapshot);
else broadcast("avatar:upsert", c.avatar);
});
} }
+138 -16
View File
@@ -1,27 +1,149 @@
import type { Avatar } from "../../shared/types/avatar"; import type { VRChat } from "vrchat";
import type { Avatar, AvatarEdit, AvatarSnapshot } from "../../shared/types/avatar";
import { toAvatar } from "./mappers"; import { toAvatar } from "./mappers";
import { httpStatusOf } from "./errors";
import { cachedRead } from "./cachedRead"; import { cachedRead } from "./cachedRead";
import { repos } from "../store/repository/manager"; import { requireActiveClient } from "./client";
import { userCache } from "./userService";
import { entityStore } from "../store/entityStore";
import { avatarStore } from "../store/avatarStore";
import { cacheKeys, policies } from "../cache/policies"; import { cacheKeys, policies } from "../cache/policies";
import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints";
type FavoriteFolder = { name: string; displayName: string; avatars: Avatar[] };
export async function getAvatar(avatarId: string): Promise<Avatar> { export async function getAvatar(avatarId: string): Promise<Avatar> {
try {
const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => { const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => {
const { data } = await vrc.getAvatar({ path: { avatarId }, throwOnError: true }); return toAvatar(await getAvatarRaw(vrc, avatarId));
return toAvatar(data);
}); });
repos.active.avatars.upsert(avatar, "rest:detail"); avatarStore.addAvatar(avatar);
return avatar;
} catch (err) {
const fallback = avatarStore.get(avatarId);
if (fallback) return fallback;
throw err;
}
}
export function avatarSnapshot(): AvatarSnapshot {
return avatarStore.snapshot();
}
export async function loadMyAvatars(): Promise<void> {
const avatars = await cachedRead(cacheKeys.avatarMine(), policies.avatarMine, async (vrc) => {
const data = await getMyAvatarsRaw(vrc);
return data.map(toAvatar);
});
avatarStore.setMine(avatars);
}
export async function loadFavoritedAvatars(): Promise<void> {
const folders = await cachedRead(
cacheKeys.avatarFavorites(),
policies.avatarFavorites,
fetchFavoriteFolders,
);
avatarStore.setFavorites(folders);
}
async function fetchFavoriteFolders(vrc: VRChat): Promise<FavoriteFolder[]> {
const { data: groups } = await vrc.getFavoriteGroups({
query: { n: 100 },
throwOnError: true,
});
const avatarGroups = groups.filter((g) => g.type === "avatar");
const folders: FavoriteFolder[] = [];
for (const group of avatarGroups) {
let raw;
try {
raw = await getFavoritedAvatarsRaw(vrc, group.name);
} catch (err) {
if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) continue;
throw err;
}
if (!raw.length) continue;
folders.push({
name: group.name,
displayName: group.displayName || prettyFolderName(group.name),
avatars: raw.map(toAvatar),
});
}
return folders;
}
function prettyFolderName(key: string): string {
const m = /^avatars(\d+)$/.exec(key);
if (m) return `Group ${m[1]}`;
return key.charAt(0).toUpperCase() + key.slice(1);
}
export async function selectAvatar(avatarId: string): Promise<void> {
const vrc = requireActiveClient();
const { data } = await vrc.selectAvatar({ path: { avatarId }, throwOnError: true });
userCache.invalidate(cacheKeys.currentUser());
entityStore.upsertFrom(
{
id: data.id,
currentAvatarId: data.currentAvatar,
currentAvatarImageUrl: data.currentAvatarImageUrl,
currentAvatarThumbnailImageUrl: data.currentAvatarThumbnailImageUrl,
},
"rest:detail",
Date.now(),
);
}
export async function updateAvatar(avatarId: string, edit: AvatarEdit): Promise<Avatar> {
const vrc = requireActiveClient();
const { data } = await vrc.updateAvatar({
path: { avatarId },
body: {
name: edit.name,
description: edit.description,
releaseStatus: edit.releaseStatus as never,
},
throwOnError: true,
});
const avatar = toAvatar(data);
invalidateAvatar(avatarId);
avatarStore.addAvatar(avatar);
await refreshLists(vrc);
return avatar; return avatar;
} }
export async function getFavoritedAvatars(): Promise<Avatar[]> { export async function deleteAvatar(avatarId: string): Promise<void> {
const avatars = await cachedRead( const vrc = requireActiveClient();
cacheKeys.avatarFavorites(), await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true });
policies.avatarFavorites, invalidateAvatar(avatarId);
async (vrc) => { avatarStore.removeAvatar(avatarId);
const { data } = await vrc.getFavoritedAvatars({ query: { n: 100 }, throwOnError: true }); await refreshLists(vrc);
return data.map(toAvatar); }
},
); export async function setAvatarFavorited(avatarId: string, favorited: boolean): Promise<void> {
repos.active.avatars.upsertMany(avatars, "rest:list"); const vrc = requireActiveClient();
return avatars; if (favorited) {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: ["avatars1"] },
throwOnError: true,
});
} else {
const { data } = await vrc.getFavorites({ query: { type: "avatar", n: 100 }, throwOnError: true });
const fav = data.find((f) => f.favoriteId === avatarId);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
}
userCache.invalidate(cacheKeys.avatarFavorites());
await loadFavoritedAvatars();
}
function invalidateAvatar(avatarId: string): void {
userCache.invalidate(cacheKeys.avatar(avatarId));
userCache.invalidate(cacheKeys.avatarMine());
userCache.invalidate(cacheKeys.avatarFavorites());
}
async function refreshLists(vrc: VRChat): Promise<void> {
avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar));
avatarStore.setFavorites(await fetchFavoriteFolders(vrc));
} }
+21
View File
@@ -28,6 +28,7 @@ interface RawUser {
userIcon?: string; userIcon?: string;
profilePicOverride?: string; profilePicOverride?: string;
profilePicOverrideThumbnail?: string; profilePicOverrideThumbnail?: string;
currentAvatar?: string;
currentAvatarImageUrl?: string; currentAvatarImageUrl?: string;
currentAvatarThumbnailImageUrl?: string; currentAvatarThumbnailImageUrl?: string;
currentAvatarTags?: string[]; currentAvatarTags?: string[];
@@ -113,6 +114,7 @@ export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
userIcon: raw.userIcon ?? "", userIcon: raw.userIcon ?? "",
profilePicOverride: raw.profilePicOverride ?? "", profilePicOverride: raw.profilePicOverride ?? "",
profilePicOverrideThumbnail: raw.profilePicOverrideThumbnail ?? "", profilePicOverrideThumbnail: raw.profilePicOverrideThumbnail ?? "",
currentAvatarId: raw.currentAvatar,
currentAvatarImageUrl: raw.currentAvatarImageUrl ?? "", currentAvatarImageUrl: raw.currentAvatarImageUrl ?? "",
currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "", currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "",
currentAvatarTags: raw.currentAvatarTags ?? [], currentAvatarTags: raw.currentAvatarTags ?? [],
@@ -280,10 +282,20 @@ interface RawAvatar {
releaseStatus?: string; releaseStatus?: string;
tags?: string[]; tags?: string[];
favorites?: number; favorites?: number;
featured?: boolean;
performance?: { standalonewindows?: string; android?: string };
created_at?: string | Date; created_at?: string | Date;
updated_at?: string | Date; updated_at?: string | Date;
} }
function hasBuild(rating?: string): boolean {
return Boolean(rating) && rating !== "None";
}
function ratingOf(rating?: string): string | undefined {
return hasBuild(rating) ? rating : undefined;
}
export function toAvatar(raw: RawAvatar): Avatar { export function toAvatar(raw: RawAvatar): Avatar {
return { return {
id: raw.id, id: raw.id,
@@ -296,6 +308,15 @@ export function toAvatar(raw: RawAvatar): Avatar {
releaseStatus: raw.releaseStatus ?? "private", releaseStatus: raw.releaseStatus ?? "private",
tags: raw.tags ?? [], tags: raw.tags ?? [],
favorites: raw.favorites ?? 0, favorites: raw.favorites ?? 0,
featured: raw.featured,
platforms: {
pc: hasBuild(raw.performance?.standalonewindows),
android: hasBuild(raw.performance?.android),
},
performance: {
pc: ratingOf(raw.performance?.standalonewindows),
android: ratingOf(raw.performance?.android),
},
createdAt: toIso(raw.created_at), createdAt: toIso(raw.created_at),
updatedAt: toIso(raw.updated_at), updatedAt: toIso(raw.updated_at),
}; };
+24 -1
View File
@@ -1,7 +1,30 @@
import type { VRChat, FavoritedWorld, LimitedWorld } from "vrchat"; import type { VRChat, FavoritedWorld, LimitedWorld, Avatar } from "vrchat";
// VRChat web routes that are missing from the SDK. // VRChat web routes that are missing from the SDK.
export async function getAvatarRaw(vrc: VRChat, avatarId: string): Promise<Avatar> {
const { data } = await vrc.client.get({ url: `/avatars/${avatarId}`, throwOnError: true });
return data as Avatar;
}
export async function getMyAvatarsRaw(vrc: VRChat): Promise<Avatar[]> {
const { data } = await vrc.client.get<Avatar[], unknown, true>({
url: "/avatars",
query: { user: "me", releaseStatus: "all", sort: "updated", order: "descending", n: 100 },
throwOnError: true,
});
return data;
}
export async function getFavoritedAvatarsRaw(vrc: VRChat, group?: string): Promise<Avatar[]> {
const { data } = await vrc.client.get<Avatar[], unknown, true>({
url: "/avatars/favorites",
query: { n: 100, tag: group },
throwOnError: true,
});
return data;
}
interface FavoriteGroupItem { interface FavoriteGroupItem {
favoriteId: string; favoriteId: string;
id: string; id: string;
+25 -5
View File
@@ -7,6 +7,7 @@ import {
ExternalLink, ExternalLink,
Images, Images,
Globe2, Globe2,
Shirt,
Search, Search,
Settings, Settings,
SlidersHorizontal, SlidersHorizontal,
@@ -16,6 +17,8 @@ import {
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { ProfileView } from "../features/profile/ProfileView"; import { ProfileView } from "../features/profile/ProfileView";
import { MyWorldsView } from "../features/profile/MyWorldsView"; import { MyWorldsView } from "../features/profile/MyWorldsView";
import { AvatarsView } from "../features/avatar/AvatarsView";
import { AvatarView } from "../features/avatar/AvatarView";
import { WorldView } from "../features/world/WorldView"; import { WorldView } from "../features/world/WorldView";
import { InstanceView } from "../features/world/InstanceView"; import { InstanceView } from "../features/world/InstanceView";
import { GroupView } from "../features/group/GroupView"; import { GroupView } from "../features/group/GroupView";
@@ -44,7 +47,7 @@ type NavItem = {
id: string; id: string;
label: string; label: string;
icon: LucideIcon; icon: LucideIcon;
onClick: () => void; onClick: (e: React.MouseEvent) => void;
kind?: View["kind"]; kind?: View["kind"];
external?: boolean; external?: boolean;
divider?: boolean; divider?: boolean;
@@ -94,6 +97,13 @@ function Shell() {
divider: true, divider: true,
onClick: () => nav.openWorlds(), onClick: () => nav.openWorlds(),
}, },
{
id: "avatars",
label: t("nav:avatars"),
icon: Shirt,
kind: "avatars",
onClick: () => nav.openAvatars(),
},
{ {
id: "account", id: "account",
label: t("nav:account"), label: t("nav:account"),
@@ -114,13 +124,19 @@ function Shell() {
label: t("nav:debug"), label: t("nav:debug"),
icon: Settings, icon: Settings,
external: true, external: true,
onClick: () => void api.debug.openWindow(), onClick: (e) => {
if (e.ctrlKey || e.metaKey) void api.debug.cacheClear();
else void api.debug.openWindow();
},
}, },
]; ];
const openProfile = (id: "me" | string) => nav.openUser(id); const openProfile = (id: "me" | string) => nav.openUser(id);
const stageKey = const stageKey =
nav.current.kind === "user" || nav.current.kind === "world" || nav.current.kind === "group" nav.current.kind === "user" ||
nav.current.kind === "world" ||
nav.current.kind === "avatar" ||
nav.current.kind === "group"
? `${nav.current.kind}:${nav.current.id}` ? `${nav.current.kind}:${nav.current.id}`
: nav.current.kind; : nav.current.kind;
@@ -154,8 +170,8 @@ function Shell() {
{item.divider ? <div className="navitem-divider" aria-hidden /> : null} {item.divider ? <div className="navitem-divider" aria-hidden /> : null}
<button <button
className={`navitem ${active ? "is-active" : ""}`} className={`navitem ${active ? "is-active" : ""}`}
onClick={item.onClick} onClick={(e) => item.onClick(e)}
title={item.label} title={item.id === "debug" ? t("nav:debugHint") : item.label}
> >
<span className="navitem__ico"> <span className="navitem__ico">
<Icon size={16} /> <Icon size={16} />
@@ -187,12 +203,16 @@ function Shell() {
<div key={stageKey} className="stage__inner animate-rise"> <div key={stageKey} className="stage__inner animate-rise">
{nav.current.kind === "world" ? ( {nav.current.kind === "world" ? (
<WorldView worldId={nav.current.id} /> <WorldView worldId={nav.current.id} />
) : nav.current.kind === "avatar" ? (
<AvatarView avatarId={nav.current.id} />
) : nav.current.kind === "instance" ? ( ) : nav.current.kind === "instance" ? (
<InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} /> <InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} />
) : nav.current.kind === "group" ? ( ) : nav.current.kind === "group" ? (
<GroupView groupId={nav.current.id} /> <GroupView groupId={nav.current.id} />
) : nav.current.kind === "worlds" ? ( ) : nav.current.kind === "worlds" ? (
<MyWorldsView /> <MyWorldsView />
) : nav.current.kind === "avatars" ? (
<AvatarsView />
) : nav.current.kind === "account" ? ( ) : nav.current.kind === "account" ? (
<AccountSettingsView /> <AccountSettingsView />
) : nav.current.kind === "settings" ? ( ) : nav.current.kind === "settings" ? (
@@ -0,0 +1,205 @@
import { useState } from "react";
import { Pencil, Shirt, Star, Trash2 } from "lucide-react";
import type { Avatar } from "../../../../shared/types/avatar";
import { Button, Field, INPUT_CLASS, Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api";
import { useT } from "../../lib/i18n";
import { useSocial } from "../../store/social";
import { useFavoriteAvatars } from "../../store/avatars";
import { useNav } from "../navigation/NavContext";
const RELEASE_STATUSES = ["public", "private"] as const;
export function AvatarActions({ avatar }: { avatar: Avatar }) {
const t = useT();
const { back } = useNav();
const self = useSocial((s) => (s.selfId ? s.users[s.selfId] : undefined));
const folders = useFavoriteAvatars();
const isOwner = avatar.authorId === self?.id;
const isCurrent = self?.currentAvatarId === avatar.id;
const isFavorited = folders.some((f) => f.avatars.some((a) => a.id === avatar.id));
const [busy, setBusy] = useState<null | "select" | "favorite">(null);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async (kind: "select" | "favorite", fn: () => Promise<void>) => {
setBusy(kind);
setError(null);
try {
await fn();
} catch (err) {
setError(errorMessage(err, t("avatar:actions.failed")));
} finally {
setBusy(null);
}
};
return (
<div className="mb-1 ml-auto flex shrink-0 flex-col items-end gap-1.5">
<div className="flex items-center gap-2">
<Button
onClick={() => run("select", () => api.avatar.select(avatar.id))}
loading={busy === "select"}
disabled={isCurrent}
>
<Shirt size={15} />
{isCurrent ? t("avatar:actions.wearing") : t("avatar:actions.wear")}
</Button>
<Button
variant="ghost"
onClick={() =>
run("favorite", () => api.avatar.setFavorited(avatar.id, !isFavorited))
}
loading={busy === "favorite"}
title={isFavorited ? t("avatar:actions.unfavorite") : t("avatar:actions.favorite")}
>
<Star size={15} fill={isFavorited ? "currentColor" : "none"} />
</Button>
{isOwner ? (
<>
<Button variant="ghost" onClick={() => setEditOpen(true)} title={t("avatar:actions.edit")}>
<Pencil size={15} />
</Button>
<Button
variant="ghost"
onClick={() => setDeleteOpen(true)}
title={t("avatar:actions.delete")}
>
<Trash2 size={15} />
</Button>
</>
) : null}
</div>
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
{editOpen ? (
<EditModal avatar={avatar} onClose={() => setEditOpen(false)} />
) : null}
<DeleteModal
avatar={avatar}
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onDeleted={back}
/>
</div>
);
}
function EditModal({ avatar, onClose }: { avatar: Avatar; onClose: () => void }) {
const t = useT();
const [name, setName] = useState(avatar.name);
const [description, setDescription] = useState(avatar.description);
const [releaseStatus, setReleaseStatus] = useState(avatar.releaseStatus);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async () => {
setBusy(true);
setError(null);
try {
await api.avatar.update(avatar.id, { name, description, releaseStatus });
onClose();
} catch (err) {
setError(errorMessage(err, t("avatar:actions.failed")));
setBusy(false);
}
};
return (
<Modal
open
onClose={onClose}
title={t("avatar:actions.editTitle")}
icon={<Pencil size={16} />}
confirmLabel={t("avatar:actions.save")}
onConfirm={save}
confirmLoading={busy}
confirmDisabled={!name.trim()}
>
<div className="flex flex-col gap-3 text-left">
<Field
label={t("avatar:actions.name")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-muted">
{t("avatar:actions.description")}
</span>
<textarea
className={`${INPUT_CLASS} min-h-[88px] resize-y`}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-muted">
{t("avatar:actions.releaseStatus")}
</span>
<select
className={INPUT_CLASS}
value={releaseStatus}
onChange={(e) => setReleaseStatus(e.target.value)}
>
{RELEASE_STATUSES.map((s) => (
<option key={s} value={s}>
{t(`avatar:releaseStatus.${s}`)}
</option>
))}
</select>
</label>
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div>
</Modal>
);
}
function DeleteModal({
avatar,
open,
onClose,
onDeleted,
}: {
avatar: Avatar;
open: boolean;
onClose: () => void;
onDeleted: () => void;
}) {
const t = useT();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const remove = async () => {
setBusy(true);
setError(null);
try {
await api.avatar.delete(avatar.id);
onClose();
onDeleted();
} catch (err) {
setError(errorMessage(err, t("avatar:actions.failed")));
setBusy(false);
}
};
return (
<Modal
open={open}
onClose={onClose}
title={t("avatar:actions.deleteTitle")}
icon={<Trash2 size={16} />}
danger
confirmLabel={t("avatar:actions.delete")}
onConfirm={remove}
confirmLoading={busy}
>
<p className="text-[14px] text-muted">
{t("avatar:actions.deleteBody", { name: avatar.name })}
</p>
{error ? <p className="mt-2 text-[12px] text-danger">{error}</p> : null}
</Modal>
);
}
@@ -0,0 +1,55 @@
import { Star } from "lucide-react";
import type { Avatar } from "../../../../shared/types/avatar";
import { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
import { compactNumber } from "../../lib/format";
import { useT } from "../../lib/i18n";
import { useNav } from "../navigation/NavContext";
export function AvatarCard({
avatar,
showAuthor,
current,
}: {
avatar: Avatar;
showAuthor?: boolean;
current?: boolean;
}) {
const t = useT();
const { openAvatar } = useNav();
const img = avatar.thumbnailImageUrl || avatar.imageUrl;
return (
<Card onClick={() => openAvatar(avatar.id)}>
<div className="relative aspect-[4/3] bg-surface-hover">
{img ? <HoverImage src={img} loading="lazy" /> : null}
{current ? (
<span className="absolute left-1.5 top-1.5">
<Tag color="var(--accent)">{t("avatar:current")}</Tag>
</span>
) : null}
{avatar.releaseStatus !== "public" ? (
<span className="absolute right-1.5 top-1.5">
<Tag color="var(--status-ask)">{avatar.releaseStatus}</Tag>
</span>
) : null}
</div>
<div className="p-2.5">
<div className="truncate text-[13px] font-semibold" title={avatar.name}>
{avatar.name}
</div>
{showAuthor ? (
<div className="truncate text-[11px] text-faint" title={avatar.authorName}>
{avatar.authorName}
</div>
) : null}
{avatar.favorites > 0 ? (
<div className="mt-1 flex items-center gap-3 text-[11px] tabular-nums text-faint">
<IconLabel icon={<Star size={12} />} title={t("avatar:tip.favorites")}>
{" "}
{compactNumber(avatar.favorites)}
</IconLabel>
</div>
) : null}
</div>
</Card>
);
}
@@ -0,0 +1,149 @@
import { Cpu, Heart, Tag as TagIcon } from "lucide-react";
import type { Avatar } from "../../../../shared/types/avatar";
import { Banner, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
import { compactNumber, formatDate, prettyTag, tagsWithPrefix } from "../../lib/format";
import { useNav } from "../navigation/NavContext";
import { performanceLabel } from "../../lib/vrchat";
import { useT } from "../../lib/i18n";
import { useAvatar } from "../../store/avatars";
import { useSelf } from "../../store/social";
import { COL_WIDE } from "../../lib/layout";
import { HeroHeader } from "../shared/HeroHeader";
import { AvatarActions } from "./AvatarActions";
import "../profile/profile.css";
export function AvatarView({ avatarId }: { avatarId: string }) {
const t = useT();
const { avatar, failed } = useAvatar(avatarId);
if (avatar) return <AvatarDetail avatar={avatar} />;
if (failed) return <Banner className="m-10 max-w-[420px]">{t("avatar:detail.unavailable")}</Banner>;
return <AvatarSkeleton />;
}
function AvatarDetail({ avatar }: { avatar: Avatar }) {
const { openUser } = useNav();
const t = useT();
const isCurrent = useSelf()?.currentAvatarId === avatar.id;
const banner = avatar.imageUrl || avatar.thumbnailImageUrl;
const authorTags = tagsWithPrefix(avatar.tags, "author_tag_");
return (
<HeroHeader
banner={banner}
media={
<div className="world__thumb">
{avatar.thumbnailImageUrl || avatar.imageUrl ? (
<img src={avatar.thumbnailImageUrl || avatar.imageUrl} alt="" />
) : null}
</div>
}
body={
<div className="min-w-0 pb-1">
<h2 className="text-[30px] font-bold leading-tight tracking-[-0.6px]">{avatar.name}</h2>
<p className="mt-1 text-[14px] text-muted">
{t("avatar:detail.byPrefix")}{" "}
<button
onClick={() => openUser(avatar.authorId)}
className="font-semibold text-text transition-colors hover:text-accent"
>
{avatar.authorName}
</button>
</p>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
{isCurrent ? <Tag color="var(--accent)">{t("avatar:current")}</Tag> : null}
{avatar.releaseStatus !== "public" ? (
<Tag color="var(--status-ask)">{avatar.releaseStatus}</Tag>
) : null}
{avatar.featured ? <Tag color="var(--accent)">{t("avatar:detail.featured")}</Tag> : null}
{avatar.platforms?.pc ? <Tag>PC</Tag> : null}
{avatar.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
</div>
</div>
}
actions={<AvatarActions avatar={avatar} />}
>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<StatTile
icon={<Heart size={15} />}
label={t("avatar:detail.stats.favorites")}
value={compactNumber(avatar.favorites)}
/>
{avatar.performance?.pc ? (
<StatTile
icon={<Cpu size={15} />}
label={t("avatar:detail.stats.performancePc")}
value={performanceLabel(t, avatar.performance.pc)!}
/>
) : null}
{avatar.performance?.android ? (
<StatTile
icon={<Cpu size={15} />}
label={t("avatar:detail.stats.performanceQuest")}
value={performanceLabel(t, avatar.performance.android)!}
/>
) : null}
</div>
{avatar.description ? (
<Section title={t("avatar:detail.sections.description")}>
<p className="whitespace-pre-wrap text-[14px] leading-relaxed text-muted">
{avatar.description}
</p>
</Section>
) : null}
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-2">
<Section title={t("avatar:detail.sections.details")}>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3">
<Fact label={t("avatar:detail.facts.releaseStatus")} value={avatar.releaseStatus} />
{avatar.createdAt ? (
<Fact label={t("avatar:detail.facts.created")} value={formatDate(avatar.createdAt)} />
) : null}
{avatar.updatedAt ? (
<Fact label={t("avatar:detail.facts.updated")} value={formatDate(avatar.updatedAt)} />
) : null}
<Fact label={t("avatar:detail.facts.avatarId")} value={avatar.id} mono />
</dl>
</Section>
{avatar.tags.length ? (
<Section title={t("avatar:detail.sections.tags")}>
<div className="flex flex-wrap gap-1.5">
{authorTags.length ? (
authorTags.map((tag) => <Tag key={tag}>{prettyTag(tag, "author_tag_")}</Tag>)
) : (
<span className="inline-flex items-center gap-1.5 text-[13px] text-faint">
<TagIcon size={13} /> {t("avatar:detail.noAuthorTags")}
</span>
)}
</div>
</Section>
) : null}
</div>
</HeroHeader>
);
}
function AvatarSkeleton() {
return (
<div
className="profile profile--skeleton flex min-h-full w-full flex-col bg-surface pb-12"
aria-busy
>
<div className="profile__banner" />
<div className={`${COL_WIDE} relative flex items-end gap-5`} style={{ marginTop: -64 }}>
<Skeleton className="world__thumb" />
<div className="flex-1 pb-1">
<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) => (
<Skeleton key={i} className="h-[72px] rounded-xl" />
))}
</div>
</div>
);
}
@@ -0,0 +1,133 @@
import { useEffect, useMemo, useState } from "react";
import type { Avatar } from "../../../../shared/types/avatar";
import {
CardGrid,
CollapsibleCard,
Field,
LABEL_HEADING,
PAGE_TITLE,
SkeletonGrid,
Tabs,
} from "../../components/ui";
import { api } from "../../lib/api";
import { useT } from "../../lib/i18n";
import { useAvatar, useFavoriteAvatars, useMyAvatars } from "../../store/avatars";
import { useSelf, useSocial } from "../../store/social";
import { AvatarCard } from "./AvatarCard";
const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10";
type Tab = "uploaded" | "favorites";
type AvatarFilter = (a: Avatar) => boolean;
function matchAvatar(query: string): AvatarFilter {
const q = query.trim().toLowerCase();
if (!q) return () => true;
const terms = q.split(/\s+/);
return (a) => {
const haystack = `${a.name} ${a.authorName} ${a.description} ${a.tags.join(" ")}`.toLowerCase();
return terms.every((term) => haystack.includes(term));
};
}
export function AvatarsView() {
const t = useT();
const [tab, setTab] = useState<Tab>("uploaded");
const [query, setQuery] = useState("");
const filter = useMemo(() => matchAvatar(query), [query]);
const selfId = useSocial((s) => s.selfId);
useEffect(() => {
void api.avatar.loadMine();
void api.avatar.loadFavorites();
}, [selfId]);
return (
<div className={SHELL}>
<header>
<h1 className={PAGE_TITLE}>{t("nav:avatars")}</h1>
</header>
<CurrentAvatarSection />
<Tabs
tabs={[
{ id: "uploaded", label: t("avatar:tabs.uploaded") },
{ id: "favorites", label: t("avatar:tabs.favorites") },
]}
active={tab}
onChange={setTab}
/>
<div className="flex flex-col gap-5">
<Field
label={t("avatar:search.label")}
placeholder={t("avatar:search.placeholder")}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="rise-in" key={tab}>
{tab === "uploaded" ? <UploadedTab filter={filter} /> : <FavoritesTab filter={filter} />}
</div>
</div>
</div>
);
}
function CurrentAvatarSection() {
const t = useT();
const self = useSelf();
const { avatar } = useAvatar(self?.currentAvatarId);
if (!avatar) return null;
return (
<section className="flex flex-col gap-2.5">
<h2 className={LABEL_HEADING}>{t("avatar:current")}</h2>
<div className="w-1/2 sm:w-1/3">
<AvatarCard avatar={avatar} showAuthor current />
</div>
</section>
);
}
function UploadedTab({ filter }: { filter: AvatarFilter }) {
const t = useT();
const mine = useMyAvatars();
if (!mine.length) return <SkeletonGrid count={6} />;
const shown = mine.filter(filter);
if (!shown.length) return <p className="text-[13px] text-faint">{t("avatar:empty")}</p>;
return (
<CardGrid>
{shown.map((a) => (
<AvatarCard key={a.id} avatar={a} />
))}
</CardGrid>
);
}
function FavoritesTab({ filter }: { filter: AvatarFilter }) {
const t = useT();
const folders = useFavoriteAvatars();
if (!folders.length) return <SkeletonGrid count={6} />;
const shown = folders
.map((f) => ({ ...f, avatars: f.avatars.filter(filter) }))
.filter((f) => f.avatars.length);
if (!shown.length) return <p className="text-[13px] text-faint">{t("avatar:empty")}</p>;
return (
<div className="flex flex-col gap-5">
{shown.map((folder) => (
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.avatars.length}>
<CardGrid>
{folder.avatars.map((a) => (
<AvatarCard key={a.id} avatar={a} showAuthor />
))}
</CardGrid>
</CollapsibleCard>
))}
</div>
);
}
@@ -3,9 +3,11 @@ import { createContext, useContext, useMemo, useState } from "react";
export type View = export type View =
| { kind: "user"; id: "me" | string } | { kind: "user"; id: "me" | string }
| { kind: "world"; id: string } | { kind: "world"; id: string }
| { kind: "avatar"; id: string }
| { kind: "instance"; worldId: string; instanceId: string; location: string } | { kind: "instance"; worldId: string; instanceId: string; location: string }
| { kind: "group"; id: string } | { kind: "group"; id: string }
| { kind: "worlds" } | { kind: "worlds" }
| { kind: "avatars" }
| { kind: "account" } | { kind: "account" }
| { kind: "settings" } | { kind: "settings" }
| { kind: "enhancements" } | { kind: "enhancements" }
@@ -17,9 +19,11 @@ interface Nav {
canBack: boolean; canBack: boolean;
openUser: (id: "me" | string) => void; openUser: (id: "me" | string) => void;
openWorld: (id: string) => void; openWorld: (id: string) => void;
openAvatar: (id: string) => void;
openInstance: (worldId: string, instanceId: string, location: string) => void; openInstance: (worldId: string, instanceId: string, location: string) => void;
openGroup: (id: string) => void; openGroup: (id: string) => void;
openWorlds: () => void; openWorlds: () => void;
openAvatars: () => void;
openAccount: () => void; openAccount: () => void;
openSettings: () => void; openSettings: () => void;
openEnhancements: () => void; openEnhancements: () => void;
@@ -48,10 +52,12 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
canBack: stack.length > 1, canBack: stack.length > 1,
openUser: (id) => push({ kind: "user", id }), openUser: (id) => push({ kind: "user", id }),
openWorld: (id) => push({ kind: "world", id }), openWorld: (id) => push({ kind: "world", id }),
openAvatar: (id) => push({ kind: "avatar", id }),
openInstance: (worldId, instanceId, location) => openInstance: (worldId, instanceId, location) =>
push({ kind: "instance", worldId, instanceId, location }), push({ kind: "instance", worldId, instanceId, location }),
openGroup: (id) => push({ kind: "group", id }), openGroup: (id) => push({ kind: "group", id }),
openWorlds: () => root({ kind: "worlds" }), openWorlds: () => root({ kind: "worlds" }),
openAvatars: () => root({ kind: "avatars" }),
openAccount: () => root({ kind: "account" }), openAccount: () => root({ kind: "account" }),
openSettings: () => root({ kind: "settings" }), openSettings: () => root({ kind: "settings" }),
openEnhancements: () => root({ kind: "enhancements" }), openEnhancements: () => root({ kind: "enhancements" }),
@@ -69,6 +75,7 @@ function sameView(a: View, b: View): boolean {
if (a.kind !== b.kind) return false; if (a.kind !== b.kind) return false;
if (a.kind === "user" && b.kind === "user") return a.id === b.id; if (a.kind === "user" && b.kind === "user") return a.id === b.id;
if (a.kind === "world" && b.kind === "world") return a.id === b.id; if (a.kind === "world" && b.kind === "world") return a.id === b.id;
if (a.kind === "avatar" && b.kind === "avatar") return a.id === b.id;
if (a.kind === "instance" && b.kind === "instance") return a.location === b.location; if (a.kind === "instance" && b.kind === "instance") return a.location === b.location;
if (a.kind === "group" && b.kind === "group") return a.id === b.id; if (a.kind === "group" && b.kind === "group") return a.id === b.id;
return true; return true;
+9 -1
View File
@@ -5,6 +5,7 @@ import type { UserStatus } from "../../../shared/types/user";
import type { EnhancementId } from "../../../shared/types/enhancements"; import type { EnhancementId } from "../../../shared/types/enhancements";
import type { CreateInstanceInput } from "../../../shared/types/instance"; import type { CreateInstanceInput } from "../../../shared/types/instance";
import type { PreferredRegion } from "../../../shared/types/appConfig"; import type { PreferredRegion } from "../../../shared/types/appConfig";
import type { AvatarEdit } from "../../../shared/types/avatar";
export class ApiException extends Error { export class ApiException extends Error {
constructor(public readonly error: ApiError) { constructor(public readonly error: ApiError) {
@@ -71,7 +72,14 @@ export const api = {
}, },
avatar: { avatar: {
get: (avatarId: string) => call("avatar:get", avatarId), get: (avatarId: string) => call("avatar:get", avatarId),
favorites: () => call("avatar:favorites"), snapshot: () => call("avatar:snapshot"),
loadMine: () => call("avatar:loadMine"),
loadFavorites: () => call("avatar:loadFavorites"),
select: (avatarId: string) => call("avatar:select", avatarId),
update: (avatarId: string, edit: AvatarEdit) => call("avatar:update", { avatarId, edit }),
delete: (avatarId: string) => call("avatar:delete", avatarId),
setFavorited: (avatarId: string, favorited: boolean) =>
call("avatar:setFavorited", { avatarId, favorited }),
}, },
group: { group: {
byUser: (userId: string) => call("group:byUser", userId), byUser: (userId: string) => call("group:byUser", userId),
@@ -0,0 +1,64 @@
{
"tabs": {
"uploaded": "Uploaded",
"favorites": "Favorites"
},
"search": {
"label": "Search avatars",
"placeholder": "Filter by name, author, or tag"
},
"tip": {
"favorites": "Favorites"
},
"empty": "No avatars to show.",
"current": "Current",
"releaseStatus": {
"public": "Public",
"private": "Private"
},
"actions": {
"wear": "Wear",
"wearing": "Wearing",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"failed": "Action failed.",
"name": "Name",
"description": "Description",
"releaseStatus": "Visibility",
"editTitle": "Edit avatar",
"deleteTitle": "Delete avatar",
"deleteBody": "Permanently delete \"{{name}}\"? This can't be undone."
},
"performance": {
"Excellent": "Excellent",
"Good": "Good",
"Medium": "Medium",
"Poor": "Poor",
"VeryPoor": "Very Poor"
},
"detail": {
"byPrefix": "by",
"unavailable": "This avatar is unavailable.",
"featured": "Featured",
"noAuthorTags": "No author tags",
"stats": {
"favorites": "Favorites",
"performancePc": "PC Rank",
"performanceQuest": "Quest Rank"
},
"sections": {
"description": "Description",
"details": "Details",
"tags": "Tags"
},
"facts": {
"releaseStatus": "Release status",
"created": "Created",
"updated": "Updated",
"avatarId": "Avatar ID"
}
}
}
@@ -3,9 +3,11 @@
"gallery": "Gallery", "gallery": "Gallery",
"enhancements": "Enhancements", "enhancements": "Enhancements",
"worlds": "Worlds", "worlds": "Worlds",
"avatars": "Avatars",
"account": "Account Settings", "account": "Account Settings",
"settings": "Settings", "settings": "Settings",
"debug": "Debug", "debug": "Debug",
"debugHint": "Debug (Ctrl+click to clear API cache)",
"hideNav": "Hide navigation", "hideNav": "Hide navigation",
"showNav": "Show navigation", "showNav": "Show navigation",
"toggleNav": "Toggle navigation sidebar", "toggleNav": "Toggle navigation sidebar",
@@ -0,0 +1,64 @@
{
"tabs": {
"uploaded": "アップロード済み",
"favorites": "お気に入り"
},
"search": {
"label": "アバターを検索",
"placeholder": "名前・作者・タグで絞り込み"
},
"tip": {
"favorites": "お気に入り"
},
"empty": "表示するアバターがありません。",
"current": "使用中",
"releaseStatus": {
"public": "公開",
"private": "非公開"
},
"actions": {
"wear": "着用",
"wearing": "使用中",
"favorite": "お気に入り登録",
"unfavorite": "お気に入り解除",
"edit": "編集",
"delete": "削除",
"save": "保存",
"failed": "操作に失敗しました。",
"name": "名前",
"description": "説明",
"releaseStatus": "公開設定",
"editTitle": "アバターを編集",
"deleteTitle": "アバターを削除",
"deleteBody": "「{{name}}」を完全に削除しますか?元に戻せません。"
},
"performance": {
"Excellent": "非常に良い",
"Good": "良い",
"Medium": "普通",
"Poor": "悪い",
"VeryPoor": "非常に悪い"
},
"detail": {
"byPrefix": "作者",
"unavailable": "このアバターは利用できません。",
"featured": "注目",
"noAuthorTags": "作者タグなし",
"stats": {
"favorites": "お気に入り",
"performancePc": "PCランク",
"performanceQuest": "Questランク"
},
"sections": {
"description": "説明",
"details": "詳細",
"tags": "タグ"
},
"facts": {
"releaseStatus": "公開状態",
"created": "作成日",
"updated": "更新日",
"avatarId": "アバターID"
}
}
}
@@ -3,9 +3,11 @@
"gallery": "ギャラリー", "gallery": "ギャラリー",
"enhancements": "拡張機能", "enhancements": "拡張機能",
"worlds": "ワールド", "worlds": "ワールド",
"avatars": "アバター",
"account": "アカウント設定", "account": "アカウント設定",
"settings": "設定", "settings": "設定",
"debug": "デバッグ", "debug": "デバッグ",
"debugHint": "デバッグ(Ctrl+クリックでAPIキャッシュをクリア)",
"hideNav": "ナビゲーションを隠す", "hideNav": "ナビゲーションを隠す",
"showNav": "ナビゲーションを表示", "showNav": "ナビゲーションを表示",
"toggleNav": "ナビゲーションサイドバーの切り替え", "toggleNav": "ナビゲーションサイドバーの切り替え",
@@ -0,0 +1,64 @@
{
"tabs": {
"uploaded": "ที่อัปโหลด",
"favorites": "รายการโปรด"
},
"search": {
"label": "ค้นหาอวตาร",
"placeholder": "กรองตามชื่อ ผู้สร้าง หรือแท็ก"
},
"tip": {
"favorites": "รายการโปรด"
},
"empty": "ไม่มีอวตารให้แสดง",
"current": "ที่ใช้อยู่",
"releaseStatus": {
"public": "สาธารณะ",
"private": "ส่วนตัว"
},
"actions": {
"wear": "สวมใส่",
"wearing": "กำลังสวมใส่",
"favorite": "เพิ่มรายการโปรด",
"unfavorite": "เอาออกจากรายการโปรด",
"edit": "แก้ไข",
"delete": "ลบ",
"save": "บันทึก",
"failed": "การดำเนินการล้มเหลว",
"name": "ชื่อ",
"description": "คำอธิบาย",
"releaseStatus": "การมองเห็น",
"editTitle": "แก้ไขอวตาร",
"deleteTitle": "ลบอวตาร",
"deleteBody": "ลบ \"{{name}}\" อย่างถาวรหรือไม่? ไม่สามารถย้อนกลับได้"
},
"performance": {
"Excellent": "ดีเยี่ยม",
"Good": "ดี",
"Medium": "ปานกลาง",
"Poor": "แย่",
"VeryPoor": "แย่มาก"
},
"detail": {
"byPrefix": "โดย",
"unavailable": "อวตารนี้ไม่พร้อมใช้งาน",
"featured": "แนะนำ",
"noAuthorTags": "ไม่มีแท็กจากผู้สร้าง",
"stats": {
"favorites": "รายการโปรด",
"performancePc": "อันดับ PC",
"performanceQuest": "อันดับ Quest"
},
"sections": {
"description": "คำอธิบาย",
"details": "รายละเอียด",
"tags": "แท็ก"
},
"facts": {
"releaseStatus": "สถานะการเผยแพร่",
"created": "สร้างเมื่อ",
"updated": "อัปเดตเมื่อ",
"avatarId": "ไอดีอวตาร"
}
}
}
@@ -3,9 +3,11 @@
"gallery": "แกลเลอรี", "gallery": "แกลเลอรี",
"enhancements": "ส่วนเสริม", "enhancements": "ส่วนเสริม",
"worlds": "โลก", "worlds": "โลก",
"avatars": "อวตาร",
"account": "ตั้งค่าบัญชี", "account": "ตั้งค่าบัญชี",
"settings": "ตั้งค่า", "settings": "ตั้งค่า",
"debug": "ดีบัก", "debug": "ดีบัก",
"debugHint": "ดีบัก (Ctrl+คลิกเพื่อล้างแคช API)",
"hideNav": "ซ่อนแถบนำทาง", "hideNav": "ซ่อนแถบนำทาง",
"showNav": "แสดงแถบนำทาง", "showNav": "แสดงแถบนำทาง",
"toggleNav": "สลับแถบนำทาง", "toggleNav": "สลับแถบนำทาง",
+7
View File
@@ -112,6 +112,13 @@ export function accessLabel(t: TFunc, type?: string): string {
return label === key ? type : label; return label === key ? type : label;
} }
export function performanceLabel(t: TFunc, rating?: string): string | null {
if (!rating) return null;
const key = `avatar:performance.${rating}`;
const label = t(key);
return label === key ? rating : label;
}
const languageNames: Record<string, string> = { const languageNames: Record<string, string> = {
eng: "English", eng: "English",
kor: "Korean", kor: "Korean",
+71
View File
@@ -0,0 +1,71 @@
import { useMemo } from "react";
import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";
import type { Avatar, AvatarSnapshot, FavoriteAvatarFolder } from "../../../shared/types/avatar";
import { api, events } from "../lib/api";
interface AvatarState {
avatars: Record<string, Avatar>;
mineIds: string[];
favorites: FavoriteAvatarFolder[];
seed: (s: AvatarSnapshot) => void;
upsert: (a: Avatar) => void;
}
export const useAvatars = create<AvatarState>((set) => ({
avatars: {},
mineIds: [],
favorites: [],
seed: (s) =>
set({
avatars: Object.fromEntries(s.avatars.map((a) => [a.id, a])),
mineIds: s.mineIds,
favorites: s.favorites,
}),
upsert: (a) => set((st) => ({ avatars: { ...st.avatars, [a.id]: a } })),
}));
events.on("avatar:seed", (s) => useAvatars.getState().seed(s));
events.on("avatar:upsert", (a) => useAvatars.getState().upsert(a));
api.avatar
.snapshot()
.then((s) => useAvatars.getState().seed(s))
.catch(() => {});
const fetching = new Set<string>();
const failed = new Set<string>();
export function useAvatar(avatarId?: string): { avatar?: Avatar; failed: boolean } {
const avatar = useAvatars((s) => (avatarId ? s.avatars[avatarId] : undefined));
if (avatarId && !avatar && !fetching.has(avatarId) && !failed.has(avatarId)) {
fetching.add(avatarId);
api.avatar
.get(avatarId)
.catch(() => failed.add(avatarId))
.finally(() => fetching.delete(avatarId));
}
return { avatar, failed: avatarId ? failed.has(avatarId) : false };
}
export const useMyAvatars = (): Avatar[] =>
useAvatars(useShallow((s) => s.mineIds.map((id) => s.avatars[id]).filter(Boolean)));
export interface FavoriteFolder {
name: string;
displayName: string;
avatars: Avatar[];
}
export function useFavoriteAvatars(): FavoriteFolder[] {
const favorites = useAvatars((s) => s.favorites);
const avatars = useAvatars((s) => s.avatars);
return useMemo(
() =>
favorites.map((f) => ({
name: f.name,
displayName: f.displayName,
avatars: f.avatarIds.map((id) => avatars[id]).filter((a): a is Avatar => Boolean(a)),
})),
[favorites, avatars],
);
}
+10 -2
View File
@@ -9,7 +9,7 @@ import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world"; import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance"; import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
import type { UnityStatus } from "./types/unity"; import type { UnityStatus } from "./types/unity";
import type { Avatar } from "./types/avatar"; import type { Avatar, AvatarEdit, AvatarSnapshot } from "./types/avatar";
import type { RepoStats, StoredEntity } from "./types/repository"; import type { RepoStats, StoredEntity } from "./types/repository";
import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings"; import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
import type { Group, GroupSnapshot } from "./types/group"; import type { Group, GroupSnapshot } from "./types/group";
@@ -58,7 +58,13 @@ export interface IpcRequests {
"instance:inviteSelf": (p: { worldId: string; instanceId: string }) => IpcResult<void>; "instance:inviteSelf": (p: { worldId: string; instanceId: string }) => IpcResult<void>;
"avatar:get": (avatarId: string) => IpcResult<Avatar>; "avatar:get": (avatarId: string) => IpcResult<Avatar>;
"avatar:favorites": () => IpcResult<Avatar[]>; "avatar:snapshot": () => IpcResult<AvatarSnapshot>;
"avatar:loadMine": () => IpcResult<void>;
"avatar:loadFavorites": () => IpcResult<void>;
"avatar:select": (avatarId: string) => IpcResult<void>;
"avatar:update": (p: { avatarId: string; edit: AvatarEdit }) => IpcResult<Avatar>;
"avatar:delete": (avatarId: string) => IpcResult<void>;
"avatar:setFavorited": (p: { avatarId: string; favorited: boolean }) => IpcResult<void>;
"group:byUser": (userId: string) => IpcResult<Group[]>; "group:byUser": (userId: string) => IpcResult<Group[]>;
"group:represented": (userId: string) => IpcResult<Group | null>; "group:represented": (userId: string) => IpcResult<Group | null>;
@@ -142,6 +148,8 @@ export interface IpcEvents {
"world:upsert": World; "world:upsert": World;
"group:seed": GroupSnapshot; "group:seed": GroupSnapshot;
"group:upsert": Group; "group:upsert": Group;
"avatar:seed": AvatarSnapshot;
"avatar:upsert": Avatar;
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean }; "world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
"game:changed": GameStatus; "game:changed": GameStatus;
"instance:open": { worldId: string; instanceId: string; location: string }; "instance:open": { worldId: string; instanceId: string; location: string };
+28
View File
@@ -1,3 +1,10 @@
import type { WorldPlatforms } from "./world";
export interface AvatarPerformance {
pc?: string;
android?: string;
}
export interface Avatar { export interface Avatar {
id: string; id: string;
name: string; name: string;
@@ -9,6 +16,27 @@ export interface Avatar {
releaseStatus: string; releaseStatus: string;
tags: string[]; tags: string[];
favorites: number; favorites: number;
featured?: boolean;
platforms?: WorldPlatforms;
performance?: AvatarPerformance;
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
} }
export interface AvatarEdit {
name?: string;
description?: string;
releaseStatus?: string;
}
export interface FavoriteAvatarFolder {
name: string;
displayName: string;
avatarIds: string[];
}
export interface AvatarSnapshot {
avatars: Avatar[];
mineIds: string[];
favorites: FavoriteAvatarFolder[];
}
+1
View File
@@ -51,6 +51,7 @@ export interface UserProfile {
userIcon: string; userIcon: string;
profilePicOverride: string; profilePicOverride: string;
profilePicOverrideThumbnail: string; profilePicOverrideThumbnail: string;
currentAvatarId?: string;
currentAvatarImageUrl: string; currentAvatarImageUrl: string;
currentAvatarThumbnailImageUrl: string; currentAvatarThumbnailImageUrl: string;
currentAvatarTags: string[]; currentAvatarTags: string[];