feat: Manage avatar favorites

This commit is contained in:
2026-06-30 01:53:17 +07:00
parent 5d983fdbd1
commit d7af72a505
15 changed files with 462 additions and 63 deletions
+6 -2
View File
@@ -67,8 +67,12 @@ const handlers = {
"avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)), "avatar:select": (avatarId) => guard(() => avatars.selectAvatar(avatarId)),
"avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)), "avatar:update": ({ avatarId, edit }) => guard(() => avatars.updateAvatar(avatarId, edit)),
"avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)), "avatar:delete": (avatarId) => guard(() => avatars.deleteAvatar(avatarId)),
"avatar:setFavorited": ({ avatarId, favorited }) => "avatar:favorite": ({ avatarId, folder }) => guard(() => avatars.favoriteAvatar(avatarId, folder)),
guard(() => avatars.setAvatarFavorited(avatarId, favorited)), "avatar:unfavorite": (avatarId) => guard(() => avatars.unfavoriteAvatar(avatarId)),
"avatar:moveFavorite": ({ avatarId, folder }) =>
guard(() => avatars.moveAvatarToFolder(avatarId, folder)),
"avatar:updateFavoriteFolder": ({ folder, edit }) =>
guard(() => avatars.updateFavoriteFolder(folder, edit)),
"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)),
+22 -2
View File
@@ -1,7 +1,22 @@
import type { Avatar, AvatarSnapshot, FavoriteAvatarFolder } from "../../shared/types/avatar"; import type {
Avatar,
AvatarSnapshot,
FavoriteAvatarFolder,
FavoriteLimits,
FavoriteVisibility,
} from "../../shared/types/avatar";
import type { FieldSource } from "../../shared/types/repository"; import type { FieldSource } from "../../shared/types/repository";
import { repos } from "./repository/manager"; import { repos } from "./repository/manager";
const DEFAULT_LIMITS: FavoriteLimits = { maxGroups: 6, maxPerGroup: 50 };
export type FavoriteFolderInput = {
name: string;
displayName: string;
visibility: FavoriteVisibility;
avatars: Avatar[];
};
export type { AvatarSnapshot }; export type { AvatarSnapshot };
type Change = { type: "seed"; snapshot: AvatarSnapshot } | { type: "upsert"; avatar: Avatar }; type Change = { type: "seed"; snapshot: AvatarSnapshot } | { type: "upsert"; avatar: Avatar };
@@ -11,6 +26,7 @@ class AvatarStore {
private readonly listeners = new Set<Listener>(); private readonly listeners = new Set<Listener>();
private mineIds = new Set<string>(); private mineIds = new Set<string>();
private favorites: FavoriteAvatarFolder[] = []; private favorites: FavoriteAvatarFolder[] = [];
private favoriteLimits: FavoriteLimits = DEFAULT_LIMITS;
private wired = false; private wired = false;
onChange(fn: Listener): () => void { onChange(fn: Listener): () => void {
@@ -33,12 +49,14 @@ class AvatarStore {
this.emit({ type: "seed", snapshot: this.snapshot() }); this.emit({ type: "seed", snapshot: this.snapshot() });
} }
setFavorites(folders: { name: string; displayName: string; avatars: Avatar[] }[]): void { setFavorites(folders: FavoriteFolderInput[], limits?: FavoriteLimits): void {
this.favorites = folders.map((f) => ({ this.favorites = folders.map((f) => ({
name: f.name, name: f.name,
displayName: f.displayName, displayName: f.displayName,
visibility: f.visibility,
avatarIds: f.avatars.map((a) => a.id), avatarIds: f.avatars.map((a) => a.id),
})); }));
if (limits) this.favoriteLimits = limits;
for (const f of folders) repos.active.avatars.upsertMany(f.avatars, "rest:list"); for (const f of folders) repos.active.avatars.upsertMany(f.avatars, "rest:list");
this.emit({ type: "seed", snapshot: this.snapshot() }); this.emit({ type: "seed", snapshot: this.snapshot() });
} }
@@ -65,12 +83,14 @@ class AvatarStore {
avatars: repos.hasActive ? repos.active.avatars.all() : [], avatars: repos.hasActive ? repos.active.avatars.all() : [],
mineIds: [...this.mineIds], mineIds: [...this.mineIds],
favorites: this.favorites, favorites: this.favorites,
favoriteLimits: this.favoriteLimits,
}; };
} }
reset(): void { reset(): void {
this.mineIds.clear(); this.mineIds.clear();
this.favorites = []; this.favorites = [];
this.favoriteLimits = DEFAULT_LIMITS;
this.wired = false; this.wired = false;
this.wire(); this.wire();
this.emit({ type: "seed", snapshot: this.snapshot() }); this.emit({ type: "seed", snapshot: this.snapshot() });
+91 -28
View File
@@ -1,16 +1,28 @@
import type { VRChat } from "vrchat"; import type { VRChat } from "vrchat";
import type { Avatar, AvatarEdit, AvatarSnapshot } from "../../shared/types/avatar"; import type {
Avatar,
AvatarEdit,
AvatarSnapshot,
FavoriteGroupEdit,
FavoriteLimits,
FavoriteVisibility,
} from "../../shared/types/avatar";
import { toAvatar } from "./mappers"; import { toAvatar } from "./mappers";
import { httpStatusOf } from "./errors"; import { httpStatusOf } from "./errors";
import { cachedRead } from "./cachedRead"; import { cachedRead } from "./cachedRead";
import { requireActiveClient } from "./client"; import { requireActiveClient } from "./client";
import { userCache } from "./userService"; import { userCache, currentUser } from "./userService";
import { entityStore } from "../store/entityStore"; import { entityStore } from "../store/entityStore";
import { avatarStore } from "../store/avatarStore"; import { avatarStore } from "../store/avatarStore";
import { cacheKeys, policies } from "../cache/policies"; import { cacheKeys, policies } from "../cache/policies";
import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints"; import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints";
type FavoriteFolder = { name: string; displayName: string; avatars: Avatar[] }; type FavoriteFolder = {
name: string;
displayName: string;
visibility: FavoriteVisibility;
avatars: Avatar[];
};
export async function getAvatar(avatarId: string): Promise<Avatar> { export async function getAvatar(avatarId: string): Promise<Avatar> {
try { try {
@@ -39,38 +51,52 @@ export async function loadMyAvatars(): Promise<void> {
} }
export async function loadFavoritedAvatars(): Promise<void> { export async function loadFavoritedAvatars(): Promise<void> {
const folders = await cachedRead( const { folders, limits } = await cachedRead(
cacheKeys.avatarFavorites(), cacheKeys.avatarFavorites(),
policies.avatarFavorites, policies.avatarFavorites,
fetchFavoriteFolders, fetchFavorites,
); );
avatarStore.setFavorites(folders); avatarStore.setFavorites(folders, limits);
} }
async function fetchFavoriteFolders(vrc: VRChat): Promise<FavoriteFolder[]> { async function fetchFavorites(vrc: VRChat): Promise<{
const { data: groups } = await vrc.getFavoriteGroups({ folders: FavoriteFolder[];
query: { n: 100 }, limits: FavoriteLimits;
throwOnError: true, }> {
}); const [{ data: groups }, limits] = await Promise.all([
vrc.getFavoriteGroups({ query: { n: 100 }, throwOnError: true }),
fetchFavoriteLimits(vrc),
]);
const avatarGroups = groups.filter((g) => g.type === "avatar"); const avatarGroups = groups.filter((g) => g.type === "avatar");
const folders: FavoriteFolder[] = []; const folders: FavoriteFolder[] = [];
for (const group of avatarGroups) { for (const group of avatarGroups) {
let raw; let raw: Awaited<ReturnType<typeof getFavoritedAvatarsRaw>> = [];
try { try {
raw = await getFavoritedAvatarsRaw(vrc, group.name); raw = await getFavoritedAvatarsRaw(vrc, group.name);
} catch (err) { } catch (err) {
if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) continue; if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err;
throw err;
} }
if (!raw.length) continue;
folders.push({ folders.push({
name: group.name, name: group.name,
displayName: group.displayName || prettyFolderName(group.name), displayName: group.displayName || prettyFolderName(group.name),
visibility: normalizeVisibility(group.visibility),
avatars: raw.map(toAvatar), avatars: raw.map(toAvatar),
}); });
} }
return folders; return { folders, limits };
}
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
return {
maxGroups: data.maxFavoriteGroups?.avatar ?? data.defaultMaxFavoriteGroups,
maxPerGroup: data.maxFavoritesPerGroup?.avatar ?? data.defaultMaxFavoritesPerGroup,
};
}
function normalizeVisibility(v: string): FavoriteVisibility {
return v === "friends" || v === "public" ? v : "private";
} }
function prettyFolderName(key: string): string { function prettyFolderName(key: string): string {
@@ -121,18 +147,54 @@ export async function deleteAvatar(avatarId: string): Promise<void> {
await refreshLists(vrc); await refreshLists(vrc);
} }
export async function setAvatarFavorited(avatarId: string, favorited: boolean): Promise<void> { export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise<void> {
const vrc = requireActiveClient(); const vrc = requireActiveClient();
if (favorited) { await vrc.addFavorite({
await vrc.addFavorite({ body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
body: { type: "avatar", favoriteId: avatarId, tags: ["avatars1"] }, throwOnError: true,
throwOnError: true, });
}); await reloadFavorites();
} else { }
const { data } = await vrc.getFavorites({ query: { type: "avatar", n: 100 }, throwOnError: true });
const fav = data.find((f) => f.favoriteId === avatarId); export async function unfavoriteAvatar(avatarId: string): Promise<void> {
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); const vrc = requireActiveClient();
} const fav = await findFavoriteRecord(vrc, avatarId);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await reloadFavorites();
}
export async function moveAvatarToFolder(avatarId: string, folder: string): Promise<void> {
const vrc = requireActiveClient();
const fav = await findFavoriteRecord(vrc, avatarId);
if (fav?.tags?.includes(folder)) return;
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
throwOnError: true,
});
await reloadFavorites();
}
export async function updateFavoriteFolder(folder: string, edit: FavoriteGroupEdit): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
await vrc.updateFavoriteGroup({
path: { favoriteGroupType: "avatar", favoriteGroupName: folder, userId: me.id },
body: {
displayName: edit.displayName,
visibility: edit.visibility as never,
},
throwOnError: true,
});
await reloadFavorites();
}
async function findFavoriteRecord(vrc: VRChat, avatarId: string) {
const { data } = await vrc.getFavorites({ query: { type: "avatar", n: 100 }, throwOnError: true });
return data.find((f) => f.favoriteId === avatarId);
}
async function reloadFavorites(): Promise<void> {
userCache.invalidate(cacheKeys.avatarFavorites()); userCache.invalidate(cacheKeys.avatarFavorites());
await loadFavoritedAvatars(); await loadFavoritedAvatars();
} }
@@ -145,5 +207,6 @@ function invalidateAvatar(avatarId: string): void {
async function refreshLists(vrc: VRChat): Promise<void> { async function refreshLists(vrc: VRChat): Promise<void> {
avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar)); avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar));
avatarStore.setFavorites(await fetchFavoriteFolders(vrc)); const { folders, limits } = await fetchFavorites(vrc);
avatarStore.setFavorites(folders, limits);
} }
@@ -5,27 +5,32 @@ import { LABEL_HEADING } from "./styles";
export function CollapsibleCard({ export function CollapsibleCard({
title, title,
count, count,
action,
defaultOpen = true, defaultOpen = true,
children, children,
}: { }: {
title: string; title: string;
count?: number | string; count?: number | string;
action?: ReactNode;
defaultOpen?: boolean; defaultOpen?: boolean;
children: ReactNode; children: ReactNode;
}) { }) {
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
return ( return (
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm"> <section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
<button <div className={`flex items-center gap-1.5 ${open ? "mb-3" : ""}`}>
onClick={() => setOpen((v) => !v)} <button
className={`flex w-full items-center gap-1.5 ${LABEL_HEADING} ${open ? "mb-3" : ""}`} onClick={() => setOpen((v) => !v)}
> className={`flex flex-1 items-center gap-1.5 ${LABEL_HEADING}`}
<span className="text-faint"> >
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />} <span className="text-faint">
</span> {open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{title} </span>
{count !== undefined ? <span className="text-faint/70">{count}</span> : null} {title}
</button> {count !== undefined ? <span className="text-faint/70">{count}</span> : null}
</button>
{action}
</div>
{open ? children : null} {open ? children : null}
</section> </section>
); );
@@ -5,8 +5,9 @@ import { Button, Field, INPUT_CLASS, Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api"; import { api, errorMessage } from "../../lib/api";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useSocial } from "../../store/social"; import { useSocial } from "../../store/social";
import { useFavoriteAvatars } from "../../store/avatars"; import { useAvatarFolder } from "../../store/avatars";
import { useNav } from "../navigation/NavContext"; import { useNav } from "../navigation/NavContext";
import { FavoriteModal } from "./FavoriteModal";
const RELEASE_STATUSES = ["public", "private"] as const; const RELEASE_STATUSES = ["public", "private"] as const;
@@ -14,17 +15,18 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
const t = useT(); const t = useT();
const { back } = useNav(); const { back } = useNav();
const self = useSocial((s) => (s.selfId ? s.users[s.selfId] : undefined)); const self = useSocial((s) => (s.selfId ? s.users[s.selfId] : undefined));
const folders = useFavoriteAvatars(); const folder = useAvatarFolder(avatar.id);
const isOwner = avatar.authorId === self?.id; const isOwner = avatar.authorId === self?.id;
const isCurrent = self?.currentAvatarId === avatar.id; const isCurrent = self?.currentAvatarId === avatar.id;
const isFavorited = folders.some((f) => f.avatars.some((a) => a.id === avatar.id)); const isFavorited = Boolean(folder);
const [busy, setBusy] = useState<null | "select" | "favorite">(null); const [busy, setBusy] = useState<null | "select">(null);
const [favoriteOpen, setFavoriteOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const run = async (kind: "select" | "favorite", fn: () => Promise<void>) => { const run = async (kind: "select", fn: () => Promise<void>) => {
setBusy(kind); setBusy(kind);
setError(null); setError(null);
try { try {
@@ -48,12 +50,9 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
{isCurrent ? t("avatar:actions.wearing") : t("avatar:actions.wear")} {isCurrent ? t("avatar:actions.wearing") : t("avatar:actions.wear")}
</Button> </Button>
<Button <Button
variant="ghost" variant={isFavorited ? "primary" : "ghost"}
onClick={() => onClick={() => setFavoriteOpen(true)}
run("favorite", () => api.avatar.setFavorited(avatar.id, !isFavorited)) title={isFavorited ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite")}
}
loading={busy === "favorite"}
title={isFavorited ? t("avatar:actions.unfavorite") : t("avatar:actions.favorite")}
> >
<Star size={15} fill={isFavorited ? "currentColor" : "none"} /> <Star size={15} fill={isFavorited ? "currentColor" : "none"} />
</Button> </Button>
@@ -74,6 +73,10 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
</div> </div>
{error ? <p className="text-[12px] text-danger">{error}</p> : null} {error ? <p className="text-[12px] text-danger">{error}</p> : null}
{favoriteOpen ? (
<FavoriteModal avatar={avatar} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
) : null}
{editOpen ? ( {editOpen ? (
<EditModal avatar={avatar} onClose={() => setEditOpen(false)} /> <EditModal avatar={avatar} onClose={() => setEditOpen(false)} />
) : null} ) : null}
@@ -1,9 +1,11 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Avatar } from "../../../../shared/types/avatar"; import type { Avatar } from "../../../../shared/types/avatar";
import { Pencil } from "lucide-react";
import { import {
CardGrid, CardGrid,
CollapsibleCard, CollapsibleCard,
Field, Field,
IconButton,
LABEL_HEADING, LABEL_HEADING,
PAGE_TITLE, PAGE_TITLE,
SkeletonGrid, SkeletonGrid,
@@ -11,9 +13,16 @@ import {
} from "../../components/ui"; } from "../../components/ui";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { useAvatar, useFavoriteAvatars, useMyAvatars } from "../../store/avatars"; import {
useAvatar,
useFavoriteAvatars,
useFavoriteLimits,
useMyAvatars,
type FavoriteFolder,
} from "../../store/avatars";
import { useSelf, useSocial } from "../../store/social"; import { useSelf, useSocial } from "../../store/social";
import { AvatarCard } from "./AvatarCard"; import { AvatarCard } from "./AvatarCard";
import { FolderEditModal } from "./FolderEditModal";
const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10"; const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10";
@@ -109,6 +118,8 @@ function UploadedTab({ filter }: { filter: AvatarFilter }) {
function FavoritesTab({ filter }: { filter: AvatarFilter }) { function FavoritesTab({ filter }: { filter: AvatarFilter }) {
const t = useT(); const t = useT();
const folders = useFavoriteAvatars(); const folders = useFavoriteAvatars();
const { maxPerGroup } = useFavoriteLimits();
const [editFolder, setEditFolder] = useState<FavoriteFolder | null>(null);
if (!folders.length) return <SkeletonGrid count={6} />; if (!folders.length) return <SkeletonGrid count={6} />;
@@ -120,7 +131,20 @@ function FavoritesTab({ filter }: { filter: AvatarFilter }) {
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
{shown.map((folder) => ( {shown.map((folder) => (
<CollapsibleCard key={folder.name} title={folder.displayName} count={folder.avatars.length}> <CollapsibleCard
key={folder.name}
title={folder.displayName}
count={`${folder.count} / ${maxPerGroup}`}
action={
<IconButton
title={t("avatar:folder.edit")}
onClick={() => setEditFolder(folder)}
aria-label={t("avatar:folder.edit")}
>
<Pencil size={14} />
</IconButton>
}
>
<CardGrid> <CardGrid>
{folder.avatars.map((a) => ( {folder.avatars.map((a) => (
<AvatarCard key={a.id} avatar={a} showAuthor /> <AvatarCard key={a.id} avatar={a} showAuthor />
@@ -128,6 +152,10 @@ function FavoritesTab({ filter }: { filter: AvatarFilter }) {
</CardGrid> </CardGrid>
</CollapsibleCard> </CollapsibleCard>
))} ))}
{editFolder ? (
<FolderEditModal folder={editFolder} onClose={() => setEditFolder(null)} />
) : null}
</div> </div>
); );
} }
@@ -0,0 +1,90 @@
import { useState } from "react";
import { Check, Star } from "lucide-react";
import type { Avatar } from "../../../../shared/types/avatar";
import { Button, Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api";
import { useT } from "../../lib/i18n";
import { useFolderSlots } from "../../store/avatars";
export function FavoriteModal({
avatar,
currentFolder,
onClose,
}: {
avatar: Avatar;
currentFolder?: string;
onClose: () => void;
}) {
const t = useT();
const slots = useFolderSlots();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const run = async (key: string, fn: () => Promise<void>) => {
setBusy(key);
setError(null);
try {
await fn();
onClose();
} catch (err) {
setError(errorMessage(err, t("avatar:actions.failed")));
setBusy(null);
}
};
const pick = (folder: string) => {
if (folder === currentFolder) return onClose();
if (currentFolder) return run(folder, () => api.avatar.moveFavorite(avatar.id, folder));
return run(folder, () => api.avatar.favorite(avatar.id, folder));
};
return (
<Modal
open
onClose={onClose}
title={currentFolder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite")}
icon={<Star size={16} />}
>
<div className="flex flex-col gap-1.5 text-left">
{slots.map((slot) => {
const isCurrent = slot.name === currentFolder;
const disabled = busy !== null || (slot.full && !isCurrent);
return (
<button
key={slot.name}
onClick={() => pick(slot.name)}
disabled={disabled}
className="flex items-center gap-2.5 rounded-lg border border-border bg-surface-2 px-3 py-2.5 text-left transition-colors hover:not-disabled:border-accent disabled:opacity-50"
>
<span className="text-faint">
<Star size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{slot.displayName}</span>
<span className="text-[11px] tabular-nums text-faint">
{t("avatar:actions.folderCount", { count: slot.count })}
{slot.full ? ` · ${t("avatar:actions.folderFull")}` : ""}
</span>
</span>
{isCurrent ? <Check size={15} className="text-accent" /> : null}
</button>
);
})}
{currentFolder ? (
<Button
variant="ghost"
className="mt-1 justify-center"
block
loading={busy === "unfavorite"}
onClick={() => run("unfavorite", () => api.avatar.unfavorite(avatar.id))}
>
{t("avatar:actions.unfavorite")}
</Button>
) : null}
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div>
</Modal>
);
}
@@ -0,0 +1,71 @@
import { useState } from "react";
import { Pencil } from "lucide-react";
import type { FavoriteVisibility } from "../../../../shared/types/avatar";
import { Field, INPUT_CLASS, Modal } from "../../components/ui";
import { api, errorMessage } from "../../lib/api";
import { useT } from "../../lib/i18n";
import type { FavoriteFolder } from "../../store/avatars";
const VISIBILITIES: FavoriteVisibility[] = ["private", "friends", "public"];
export function FolderEditModal({
folder,
onClose,
}: {
folder: FavoriteFolder;
onClose: () => void;
}) {
const t = useT();
const [displayName, setDisplayName] = useState(folder.displayName);
const [visibility, setVisibility] = useState<FavoriteVisibility>(folder.visibility);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async () => {
setBusy(true);
setError(null);
try {
await api.avatar.updateFavoriteFolder(folder.name, { displayName, visibility });
onClose();
} catch (err) {
setError(errorMessage(err, t("avatar:actions.failed")));
setBusy(false);
}
};
return (
<Modal
open
onClose={onClose}
title={t("avatar:folder.editTitle")}
icon={<Pencil size={16} />}
confirmLabel={t("avatar:actions.save")}
onConfirm={save}
confirmLoading={busy}
confirmDisabled={!displayName.trim()}
>
<div className="flex flex-col gap-3 text-left">
<Field
label={t("avatar:folder.name")}
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<label className="flex flex-col gap-1.5">
<span className="text-[12px] font-medium text-muted">{t("avatar:folder.visibility")}</span>
<select
className={INPUT_CLASS}
value={visibility}
onChange={(e) => setVisibility(e.target.value as FavoriteVisibility)}
>
{VISIBILITIES.map((v) => (
<option key={v} value={v}>
{t(`avatar:visibility.${v}`)}
</option>
))}
</select>
</label>
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div>
</Modal>
);
}
+7 -3
View File
@@ -5,7 +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"; import type { AvatarEdit, FavoriteGroupEdit } from "../../../shared/types/avatar";
export class ApiException extends Error { export class ApiException extends Error {
constructor(public readonly error: ApiError) { constructor(public readonly error: ApiError) {
@@ -78,8 +78,12 @@ export const api = {
select: (avatarId: string) => call("avatar:select", avatarId), select: (avatarId: string) => call("avatar:select", avatarId),
update: (avatarId: string, edit: AvatarEdit) => call("avatar:update", { avatarId, edit }), update: (avatarId: string, edit: AvatarEdit) => call("avatar:update", { avatarId, edit }),
delete: (avatarId: string) => call("avatar:delete", avatarId), delete: (avatarId: string) => call("avatar:delete", avatarId),
setFavorited: (avatarId: string, favorited: boolean) => favorite: (avatarId: string, folder?: string) => call("avatar:favorite", { avatarId, folder }),
call("avatar:setFavorited", { avatarId, favorited }), unfavorite: (avatarId: string) => call("avatar:unfavorite", avatarId),
moveFavorite: (avatarId: string, folder: string) =>
call("avatar:moveFavorite", { avatarId, folder }),
updateFavoriteFolder: (folder: string, edit: FavoriteGroupEdit) =>
call("avatar:updateFavoriteFolder", { folder, edit }),
}, },
group: { group: {
byUser: (userId: string) => call("group:byUser", userId), byUser: (userId: string) => call("group:byUser", userId),
@@ -21,6 +21,9 @@
"wearing": "Wearing", "wearing": "Wearing",
"favorite": "Favorite", "favorite": "Favorite",
"unfavorite": "Unfavorite", "unfavorite": "Unfavorite",
"manageFavorite": "Manage favorite",
"folderCount": "{{count}} avatars",
"folderFull": "Full",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"save": "Save", "save": "Save",
@@ -32,6 +35,17 @@
"deleteTitle": "Delete avatar", "deleteTitle": "Delete avatar",
"deleteBody": "Permanently delete \"{{name}}\"? This can't be undone." "deleteBody": "Permanently delete \"{{name}}\"? This can't be undone."
}, },
"folder": {
"edit": "Edit folder",
"editTitle": "Edit folder",
"name": "Folder name",
"visibility": "Visibility"
},
"visibility": {
"private": "Private",
"friends": "Friends",
"public": "Public"
},
"performance": { "performance": {
"Excellent": "Excellent", "Excellent": "Excellent",
"Good": "Good", "Good": "Good",
@@ -21,6 +21,9 @@
"wearing": "使用中", "wearing": "使用中",
"favorite": "お気に入り登録", "favorite": "お気に入り登録",
"unfavorite": "お気に入り解除", "unfavorite": "お気に入り解除",
"manageFavorite": "お気に入りを管理",
"folderCount": "{{count}} 体",
"folderFull": "満杯",
"edit": "編集", "edit": "編集",
"delete": "削除", "delete": "削除",
"save": "保存", "save": "保存",
@@ -32,6 +35,17 @@
"deleteTitle": "アバターを削除", "deleteTitle": "アバターを削除",
"deleteBody": "「{{name}}」を完全に削除しますか?元に戻せません。" "deleteBody": "「{{name}}」を完全に削除しますか?元に戻せません。"
}, },
"folder": {
"edit": "フォルダを編集",
"editTitle": "フォルダを編集",
"name": "フォルダ名",
"visibility": "公開設定"
},
"visibility": {
"private": "非公開",
"friends": "フレンド",
"public": "公開"
},
"performance": { "performance": {
"Excellent": "非常に良い", "Excellent": "非常に良い",
"Good": "良い", "Good": "良い",
@@ -21,6 +21,9 @@
"wearing": "กำลังสวมใส่", "wearing": "กำลังสวมใส่",
"favorite": "เพิ่มรายการโปรด", "favorite": "เพิ่มรายการโปรด",
"unfavorite": "เอาออกจากรายการโปรด", "unfavorite": "เอาออกจากรายการโปรด",
"manageFavorite": "จัดการรายการโปรด",
"folderCount": "{{count}} ตัว",
"folderFull": "เต็ม",
"edit": "แก้ไข", "edit": "แก้ไข",
"delete": "ลบ", "delete": "ลบ",
"save": "บันทึก", "save": "บันทึก",
@@ -32,6 +35,17 @@
"deleteTitle": "ลบอวตาร", "deleteTitle": "ลบอวตาร",
"deleteBody": "ลบ \"{{name}}\" อย่างถาวรหรือไม่? ไม่สามารถย้อนกลับได้" "deleteBody": "ลบ \"{{name}}\" อย่างถาวรหรือไม่? ไม่สามารถย้อนกลับได้"
}, },
"folder": {
"edit": "แก้ไขโฟลเดอร์",
"editTitle": "แก้ไขโฟลเดอร์",
"name": "ชื่อโฟลเดอร์",
"visibility": "การมองเห็น"
},
"visibility": {
"private": "ส่วนตัว",
"friends": "เพื่อน",
"public": "สาธารณะ"
},
"performance": { "performance": {
"Excellent": "ดีเยี่ยม", "Excellent": "ดีเยี่ยม",
"Good": "ดี", "Good": "ดี",
+55 -3
View File
@@ -1,13 +1,21 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { create } from "zustand"; import { create } from "zustand";
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import type { Avatar, AvatarSnapshot, FavoriteAvatarFolder } from "../../../shared/types/avatar"; import type {
Avatar,
AvatarSnapshot,
FavoriteAvatarFolder,
FavoriteLimits,
} from "../../../shared/types/avatar";
import { api, events } from "../lib/api"; import { api, events } from "../lib/api";
const DEFAULT_LIMITS: FavoriteLimits = { maxGroups: 6, maxPerGroup: 50 };
interface AvatarState { interface AvatarState {
avatars: Record<string, Avatar>; avatars: Record<string, Avatar>;
mineIds: string[]; mineIds: string[];
favorites: FavoriteAvatarFolder[]; favorites: FavoriteAvatarFolder[];
favoriteLimits: FavoriteLimits;
seed: (s: AvatarSnapshot) => void; seed: (s: AvatarSnapshot) => void;
upsert: (a: Avatar) => void; upsert: (a: Avatar) => void;
} }
@@ -16,11 +24,13 @@ export const useAvatars = create<AvatarState>((set) => ({
avatars: {}, avatars: {},
mineIds: [], mineIds: [],
favorites: [], favorites: [],
favoriteLimits: DEFAULT_LIMITS,
seed: (s) => seed: (s) =>
set({ set({
avatars: Object.fromEntries(s.avatars.map((a) => [a.id, a])), avatars: Object.fromEntries(s.avatars.map((a) => [a.id, a])),
mineIds: s.mineIds, mineIds: s.mineIds,
favorites: s.favorites, favorites: s.favorites,
favoriteLimits: s.favoriteLimits,
}), }),
upsert: (a) => set((st) => ({ avatars: { ...st.avatars, [a.id]: a } })), upsert: (a) => set((st) => ({ avatars: { ...st.avatars, [a.id]: a } })),
})); }));
@@ -53,19 +63,61 @@ export const useMyAvatars = (): Avatar[] =>
export interface FavoriteFolder { export interface FavoriteFolder {
name: string; name: string;
displayName: string; displayName: string;
visibility: FavoriteAvatarFolder["visibility"];
count: number;
full: boolean;
avatars: Avatar[]; avatars: Avatar[];
} }
export function useFavoriteAvatars(): FavoriteFolder[] { export function useFavoriteAvatars(): FavoriteFolder[] {
const favorites = useAvatars((s) => s.favorites); const favorites = useAvatars((s) => s.favorites);
const avatars = useAvatars((s) => s.avatars); const avatars = useAvatars((s) => s.avatars);
const maxPerGroup = useAvatars((s) => s.favoriteLimits.maxPerGroup);
return useMemo(
() => favorites.map((f) => toFolder(f, avatars, maxPerGroup)),
[favorites, avatars, maxPerGroup],
);
}
function toFolder(
f: FavoriteAvatarFolder,
avatars: Record<string, Avatar>,
maxPerGroup: number,
): FavoriteFolder {
return {
name: f.name,
displayName: f.displayName,
visibility: f.visibility,
count: f.avatarIds.length,
full: f.avatarIds.length >= maxPerGroup,
avatars: f.avatarIds.map((id) => avatars[id]).filter((a): a is Avatar => Boolean(a)),
};
}
export const useFavoriteLimits = (): FavoriteLimits => useAvatars((s) => s.favoriteLimits);
export function useAvatarFolder(avatarId: string): string | undefined {
return useAvatars((s) => s.favorites.find((f) => f.avatarIds.includes(avatarId))?.name);
}
export interface FolderSlot {
name: string;
displayName: string;
count: number;
full: boolean;
}
export function useFolderSlots(): FolderSlot[] {
const favorites = useAvatars((s) => s.favorites);
const maxPerGroup = useAvatars((s) => s.favoriteLimits.maxPerGroup);
return useMemo( return useMemo(
() => () =>
favorites.map((f) => ({ favorites.map((f) => ({
name: f.name, name: f.name,
displayName: f.displayName, displayName: f.displayName,
avatars: f.avatarIds.map((id) => avatars[id]).filter((a): a is Avatar => Boolean(a)), count: f.avatarIds.length,
full: f.avatarIds.length >= maxPerGroup,
})), })),
[favorites, avatars], [favorites, maxPerGroup],
); );
} }
+5 -2
View File
@@ -9,7 +9,7 @@ import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world"; import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance"; import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
import type { UnityStatus } from "./types/unity"; import type { UnityStatus } from "./types/unity";
import type { Avatar, AvatarEdit, AvatarSnapshot } from "./types/avatar"; import type { Avatar, AvatarEdit, AvatarSnapshot, FavoriteGroupEdit } 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";
@@ -64,7 +64,10 @@ export interface IpcRequests {
"avatar:select": (avatarId: string) => IpcResult<void>; "avatar:select": (avatarId: string) => IpcResult<void>;
"avatar:update": (p: { avatarId: string; edit: AvatarEdit }) => IpcResult<Avatar>; "avatar:update": (p: { avatarId: string; edit: AvatarEdit }) => IpcResult<Avatar>;
"avatar:delete": (avatarId: string) => IpcResult<void>; "avatar:delete": (avatarId: string) => IpcResult<void>;
"avatar:setFavorited": (p: { avatarId: string; favorited: boolean }) => IpcResult<void>; "avatar:favorite": (p: { avatarId: string; folder?: string }) => IpcResult<void>;
"avatar:unfavorite": (avatarId: string) => IpcResult<void>;
"avatar:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult<void>;
"avatar:updateFavoriteFolder": (p: { folder: string; edit: FavoriteGroupEdit }) => 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>;
+14
View File
@@ -29,14 +29,28 @@ export interface AvatarEdit {
releaseStatus?: string; releaseStatus?: string;
} }
export type FavoriteVisibility = "private" | "friends" | "public";
export interface FavoriteAvatarFolder { export interface FavoriteAvatarFolder {
name: string; name: string;
displayName: string; displayName: string;
visibility: FavoriteVisibility;
avatarIds: string[]; avatarIds: string[];
} }
export interface FavoriteLimits {
maxGroups: number;
maxPerGroup: number;
}
export interface FavoriteGroupEdit {
displayName?: string;
visibility?: FavoriteVisibility;
}
export interface AvatarSnapshot { export interface AvatarSnapshot {
avatars: Avatar[]; avatars: Avatar[];
mineIds: string[]; mineIds: string[];
favorites: FavoriteAvatarFolder[]; favorites: FavoriteAvatarFolder[];
favoriteLimits: FavoriteLimits;
} }