mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: Avatars tab
This commit is contained in:
Vendored
+2
@@ -12,6 +12,7 @@ export const policies = {
|
||||
world: { 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 },
|
||||
avatarMine: { 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 },
|
||||
group: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
|
||||
@@ -33,6 +34,7 @@ export const cacheKeys = {
|
||||
world: (id: string) => `world:${id}`,
|
||||
avatar: (id: string) => `avatar:${id}`,
|
||||
avatarFavorites: () => "avatar:favorites",
|
||||
avatarMine: () => "avatar:mine",
|
||||
userGroups: (id: string) => `user:groups:${id}`,
|
||||
representedGroup: (id: string) => `user:group:represented:${id}`,
|
||||
group: (id: string) => `group:${id}`,
|
||||
|
||||
@@ -61,7 +61,14 @@ const handlers = {
|
||||
guard(() => instances.inviteSelf(worldId, instanceId)),
|
||||
|
||||
"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:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)),
|
||||
|
||||
@@ -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();
|
||||
@@ -9,6 +9,7 @@ import { activeId } from "../accounts/store";
|
||||
import { entityStore, type SocialSnapshot } from "./entityStore";
|
||||
import { worldStore } from "./worldStore";
|
||||
import { groupStore } from "./groupStore";
|
||||
import { avatarStore } from "./avatarStore";
|
||||
import { repos } from "./repository/manager";
|
||||
import { broadcast } from "../windows";
|
||||
import { logger } from "../debug/logger";
|
||||
@@ -172,6 +173,7 @@ export async function seedActiveAccount(force = false): Promise<void> {
|
||||
if (!id) {
|
||||
worldStore.reset();
|
||||
groupStore.reset();
|
||||
avatarStore.reset();
|
||||
entityStore.reset();
|
||||
return;
|
||||
}
|
||||
@@ -212,4 +214,9 @@ export function startSocialBridge(): void {
|
||||
if (c.type === "seed") broadcast("group:seed", c.snapshot);
|
||||
else broadcast("group:upsert", c.group);
|
||||
});
|
||||
|
||||
avatarStore.onChange((c) => {
|
||||
if (c.type === "seed") broadcast("avatar:seed", c.snapshot);
|
||||
else broadcast("avatar:upsert", c.avatar);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 { httpStatusOf } from "./errors";
|
||||
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 { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints";
|
||||
|
||||
type FavoriteFolder = { name: string; displayName: string; avatars: Avatar[] };
|
||||
|
||||
export async function getAvatar(avatarId: string): Promise<Avatar> {
|
||||
const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => {
|
||||
const { data } = await vrc.getAvatar({ path: { avatarId }, throwOnError: true });
|
||||
return toAvatar(data);
|
||||
try {
|
||||
const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => {
|
||||
return toAvatar(await getAvatarRaw(vrc, avatarId));
|
||||
});
|
||||
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);
|
||||
});
|
||||
repos.active.avatars.upsert(avatar, "rest:detail");
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getFavoritedAvatars(): Promise<Avatar[]> {
|
||||
const avatars = await cachedRead(
|
||||
cacheKeys.avatarFavorites(),
|
||||
policies.avatarFavorites,
|
||||
async (vrc) => {
|
||||
const { data } = await vrc.getFavoritedAvatars({ query: { n: 100 }, throwOnError: true });
|
||||
return data.map(toAvatar);
|
||||
},
|
||||
);
|
||||
repos.active.avatars.upsertMany(avatars, "rest:list");
|
||||
return avatars;
|
||||
export async function deleteAvatar(avatarId: string): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true });
|
||||
invalidateAvatar(avatarId);
|
||||
avatarStore.removeAvatar(avatarId);
|
||||
await refreshLists(vrc);
|
||||
}
|
||||
|
||||
export async function setAvatarFavorited(avatarId: string, favorited: boolean): Promise<void> {
|
||||
const vrc = requireActiveClient();
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ interface RawUser {
|
||||
userIcon?: string;
|
||||
profilePicOverride?: string;
|
||||
profilePicOverrideThumbnail?: string;
|
||||
currentAvatar?: string;
|
||||
currentAvatarImageUrl?: string;
|
||||
currentAvatarThumbnailImageUrl?: string;
|
||||
currentAvatarTags?: string[];
|
||||
@@ -113,6 +114,7 @@ export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
|
||||
userIcon: raw.userIcon ?? "",
|
||||
profilePicOverride: raw.profilePicOverride ?? "",
|
||||
profilePicOverrideThumbnail: raw.profilePicOverrideThumbnail ?? "",
|
||||
currentAvatarId: raw.currentAvatar,
|
||||
currentAvatarImageUrl: raw.currentAvatarImageUrl ?? "",
|
||||
currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "",
|
||||
currentAvatarTags: raw.currentAvatarTags ?? [],
|
||||
@@ -280,10 +282,20 @@ interface RawAvatar {
|
||||
releaseStatus?: string;
|
||||
tags?: string[];
|
||||
favorites?: number;
|
||||
featured?: boolean;
|
||||
performance?: { standalonewindows?: string; android?: string };
|
||||
created_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 {
|
||||
return {
|
||||
id: raw.id,
|
||||
@@ -296,6 +308,15 @@ export function toAvatar(raw: RawAvatar): Avatar {
|
||||
releaseStatus: raw.releaseStatus ?? "private",
|
||||
tags: raw.tags ?? [],
|
||||
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),
|
||||
updatedAt: toIso(raw.updated_at),
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 {
|
||||
favoriteId: string;
|
||||
id: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ExternalLink,
|
||||
Images,
|
||||
Globe2,
|
||||
Shirt,
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ProfileView } from "../features/profile/ProfileView";
|
||||
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 { InstanceView } from "../features/world/InstanceView";
|
||||
import { GroupView } from "../features/group/GroupView";
|
||||
@@ -44,7 +47,7 @@ type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
kind?: View["kind"];
|
||||
external?: boolean;
|
||||
divider?: boolean;
|
||||
@@ -94,6 +97,13 @@ function Shell() {
|
||||
divider: true,
|
||||
onClick: () => nav.openWorlds(),
|
||||
},
|
||||
{
|
||||
id: "avatars",
|
||||
label: t("nav:avatars"),
|
||||
icon: Shirt,
|
||||
kind: "avatars",
|
||||
onClick: () => nav.openAvatars(),
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
label: t("nav:account"),
|
||||
@@ -114,13 +124,19 @@ function Shell() {
|
||||
label: t("nav:debug"),
|
||||
icon: Settings,
|
||||
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 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;
|
||||
|
||||
@@ -154,8 +170,8 @@ function Shell() {
|
||||
{item.divider ? <div className="navitem-divider" aria-hidden /> : null}
|
||||
<button
|
||||
className={`navitem ${active ? "is-active" : ""}`}
|
||||
onClick={item.onClick}
|
||||
title={item.label}
|
||||
onClick={(e) => item.onClick(e)}
|
||||
title={item.id === "debug" ? t("nav:debugHint") : item.label}
|
||||
>
|
||||
<span className="navitem__ico">
|
||||
<Icon size={16} />
|
||||
@@ -187,12 +203,16 @@ function Shell() {
|
||||
<div key={stageKey} className="stage__inner animate-rise">
|
||||
{nav.current.kind === "world" ? (
|
||||
<WorldView worldId={nav.current.id} />
|
||||
) : nav.current.kind === "avatar" ? (
|
||||
<AvatarView avatarId={nav.current.id} />
|
||||
) : nav.current.kind === "instance" ? (
|
||||
<InstanceView worldId={nav.current.worldId} instanceId={nav.current.instanceId} />
|
||||
) : nav.current.kind === "group" ? (
|
||||
<GroupView groupId={nav.current.id} />
|
||||
) : nav.current.kind === "worlds" ? (
|
||||
<MyWorldsView />
|
||||
) : nav.current.kind === "avatars" ? (
|
||||
<AvatarsView />
|
||||
) : nav.current.kind === "account" ? (
|
||||
<AccountSettingsView />
|
||||
) : 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 =
|
||||
| { kind: "user"; id: "me" | string }
|
||||
| { kind: "world"; id: string }
|
||||
| { kind: "avatar"; id: string }
|
||||
| { kind: "instance"; worldId: string; instanceId: string; location: string }
|
||||
| { kind: "group"; id: string }
|
||||
| { kind: "worlds" }
|
||||
| { kind: "avatars" }
|
||||
| { kind: "account" }
|
||||
| { kind: "settings" }
|
||||
| { kind: "enhancements" }
|
||||
@@ -17,9 +19,11 @@ interface Nav {
|
||||
canBack: boolean;
|
||||
openUser: (id: "me" | string) => void;
|
||||
openWorld: (id: string) => void;
|
||||
openAvatar: (id: string) => void;
|
||||
openInstance: (worldId: string, instanceId: string, location: string) => void;
|
||||
openGroup: (id: string) => void;
|
||||
openWorlds: () => void;
|
||||
openAvatars: () => void;
|
||||
openAccount: () => void;
|
||||
openSettings: () => void;
|
||||
openEnhancements: () => void;
|
||||
@@ -48,10 +52,12 @@ export function NavProvider({ children }: { children: React.ReactNode }) {
|
||||
canBack: stack.length > 1,
|
||||
openUser: (id) => push({ kind: "user", id }),
|
||||
openWorld: (id) => push({ kind: "world", id }),
|
||||
openAvatar: (id) => push({ kind: "avatar", id }),
|
||||
openInstance: (worldId, instanceId, location) =>
|
||||
push({ kind: "instance", worldId, instanceId, location }),
|
||||
openGroup: (id) => push({ kind: "group", id }),
|
||||
openWorlds: () => root({ kind: "worlds" }),
|
||||
openAvatars: () => root({ kind: "avatars" }),
|
||||
openAccount: () => root({ kind: "account" }),
|
||||
openSettings: () => root({ kind: "settings" }),
|
||||
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 === "user" && b.kind === "user") 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 === "group" && b.kind === "group") return a.id === b.id;
|
||||
return true;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { UserStatus } from "../../../shared/types/user";
|
||||
import type { EnhancementId } from "../../../shared/types/enhancements";
|
||||
import type { CreateInstanceInput } from "../../../shared/types/instance";
|
||||
import type { PreferredRegion } from "../../../shared/types/appConfig";
|
||||
import type { AvatarEdit } from "../../../shared/types/avatar";
|
||||
|
||||
export class ApiException extends Error {
|
||||
constructor(public readonly error: ApiError) {
|
||||
@@ -71,7 +72,14 @@ export const api = {
|
||||
},
|
||||
avatar: {
|
||||
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: {
|
||||
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",
|
||||
"enhancements": "Enhancements",
|
||||
"worlds": "Worlds",
|
||||
"avatars": "Avatars",
|
||||
"account": "Account Settings",
|
||||
"settings": "Settings",
|
||||
"debug": "Debug",
|
||||
"debugHint": "Debug (Ctrl+click to clear API cache)",
|
||||
"hideNav": "Hide navigation",
|
||||
"showNav": "Show navigation",
|
||||
"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": "ギャラリー",
|
||||
"enhancements": "拡張機能",
|
||||
"worlds": "ワールド",
|
||||
"avatars": "アバター",
|
||||
"account": "アカウント設定",
|
||||
"settings": "設定",
|
||||
"debug": "デバッグ",
|
||||
"debugHint": "デバッグ(Ctrl+クリックでAPIキャッシュをクリア)",
|
||||
"hideNav": "ナビゲーションを隠す",
|
||||
"showNav": "ナビゲーションを表示",
|
||||
"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": "แกลเลอรี",
|
||||
"enhancements": "ส่วนเสริม",
|
||||
"worlds": "โลก",
|
||||
"avatars": "อวตาร",
|
||||
"account": "ตั้งค่าบัญชี",
|
||||
"settings": "ตั้งค่า",
|
||||
"debug": "ดีบัก",
|
||||
"debugHint": "ดีบัก (Ctrl+คลิกเพื่อล้างแคช API)",
|
||||
"hideNav": "ซ่อนแถบนำทาง",
|
||||
"showNav": "แสดงแถบนำทาง",
|
||||
"toggleNav": "สลับแถบนำทาง",
|
||||
|
||||
@@ -112,6 +112,13 @@ export function accessLabel(t: TFunc, type?: string): string {
|
||||
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> = {
|
||||
eng: "English",
|
||||
kor: "Korean",
|
||||
|
||||
@@ -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
@@ -9,7 +9,7 @@ import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
||||
import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
||||
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
|
||||
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 { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings";
|
||||
import type { Group, GroupSnapshot } from "./types/group";
|
||||
@@ -58,7 +58,13 @@ export interface IpcRequests {
|
||||
"instance:inviteSelf": (p: { worldId: string; instanceId: string }) => IpcResult<void>;
|
||||
|
||||
"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:represented": (userId: string) => IpcResult<Group | null>;
|
||||
@@ -142,6 +148,8 @@ export interface IpcEvents {
|
||||
"world:upsert": World;
|
||||
"group:seed": GroupSnapshot;
|
||||
"group:upsert": Group;
|
||||
"avatar:seed": AvatarSnapshot;
|
||||
"avatar:upsert": Avatar;
|
||||
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
|
||||
"game:changed": GameStatus;
|
||||
"instance:open": { worldId: string; instanceId: string; location: string };
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import type { WorldPlatforms } from "./world";
|
||||
|
||||
export interface AvatarPerformance {
|
||||
pc?: string;
|
||||
android?: string;
|
||||
}
|
||||
|
||||
export interface Avatar {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -9,6 +16,27 @@ export interface Avatar {
|
||||
releaseStatus: string;
|
||||
tags: string[];
|
||||
favorites: number;
|
||||
featured?: boolean;
|
||||
platforms?: WorldPlatforms;
|
||||
performance?: AvatarPerformance;
|
||||
createdAt?: 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[];
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface UserProfile {
|
||||
userIcon: string;
|
||||
profilePicOverride: string;
|
||||
profilePicOverrideThumbnail: string;
|
||||
currentAvatarId?: string;
|
||||
currentAvatarImageUrl: string;
|
||||
currentAvatarThumbnailImageUrl: string;
|
||||
currentAvatarTags: string[];
|
||||
|
||||
Reference in New Issue
Block a user