mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +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 },
|
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}`,
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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 { 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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": "สลับแถบนำทาง",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 { 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 };
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user