mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: Manage avatar favorites
This commit is contained in:
@@ -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)),
|
||||||
|
|||||||
@@ -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() });
|
||||||
|
|||||||
@@ -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: ["avatars1"] },
|
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
|
||||||
throwOnError: true,
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
} else {
|
await reloadFavorites();
|
||||||
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> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const fav = await findFavoriteRecord(vrc, avatarId);
|
||||||
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
|
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,20 +5,23 @@ 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">
|
||||||
|
<div className={`flex items-center gap-1.5 ${open ? "mb-3" : ""}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className={`flex w-full items-center gap-1.5 ${LABEL_HEADING} ${open ? "mb-3" : ""}`}
|
className={`flex flex-1 items-center gap-1.5 ${LABEL_HEADING}`}
|
||||||
>
|
>
|
||||||
<span className="text-faint">
|
<span className="text-faint">
|
||||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||||
@@ -26,6 +29,8 @@ export function CollapsibleCard({
|
|||||||
{title}
|
{title}
|
||||||
{count !== undefined ? <span className="text-faint/70">{count}</span> : null}
|
{count !== undefined ? <span className="text-faint/70">{count}</span> : null}
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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": "ดี",
|
||||||
|
|||||||
@@ -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
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user