From d7af72a505286b5f0e604b09e9f82dd19a2200ce Mon Sep 17 00:00:00 2001 From: Yuzu Date: Tue, 30 Jun 2026 01:52:30 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Manage=20avatar=20favorites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/ipc/handlers.ts | 8 +- src/main/store/avatarStore.ts | 24 +++- src/main/vrchat/avatarService.ts | 119 +++++++++++++----- .../src/components/ui/CollapsibleCard.tsx | 25 ++-- .../src/features/avatar/AvatarActions.tsx | 25 ++-- .../src/features/avatar/AvatarsView.tsx | 32 ++++- .../src/features/avatar/FavoriteModal.tsx | 90 +++++++++++++ .../src/features/avatar/FolderEditModal.tsx | 71 +++++++++++ src/renderer/src/lib/api.ts | 10 +- .../src/lib/i18n/locales/en/avatar.json | 14 +++ .../src/lib/i18n/locales/ja/avatar.json | 14 +++ .../src/lib/i18n/locales/th/avatar.json | 14 +++ src/renderer/src/store/avatars.ts | 58 ++++++++- src/shared/ipc.ts | 7 +- src/shared/types/avatar.ts | 14 +++ 15 files changed, 462 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/features/avatar/FavoriteModal.tsx create mode 100644 src/renderer/src/features/avatar/FolderEditModal.tsx diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 778bec0..6712267 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -67,8 +67,12 @@ const handlers = { "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)), + "avatar:favorite": ({ avatarId, folder }) => guard(() => avatars.favoriteAvatar(avatarId, folder)), + "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:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)), diff --git a/src/main/store/avatarStore.ts b/src/main/store/avatarStore.ts index 5c97b60..3170680 100644 --- a/src/main/store/avatarStore.ts +++ b/src/main/store/avatarStore.ts @@ -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 { 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 }; type Change = { type: "seed"; snapshot: AvatarSnapshot } | { type: "upsert"; avatar: Avatar }; @@ -11,6 +26,7 @@ class AvatarStore { private readonly listeners = new Set(); private mineIds = new Set(); private favorites: FavoriteAvatarFolder[] = []; + private favoriteLimits: FavoriteLimits = DEFAULT_LIMITS; private wired = false; onChange(fn: Listener): () => void { @@ -33,12 +49,14 @@ class AvatarStore { 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) => ({ name: f.name, displayName: f.displayName, + visibility: f.visibility, 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"); this.emit({ type: "seed", snapshot: this.snapshot() }); } @@ -65,12 +83,14 @@ class AvatarStore { avatars: repos.hasActive ? repos.active.avatars.all() : [], mineIds: [...this.mineIds], favorites: this.favorites, + favoriteLimits: this.favoriteLimits, }; } reset(): void { this.mineIds.clear(); this.favorites = []; + this.favoriteLimits = DEFAULT_LIMITS; this.wired = false; this.wire(); this.emit({ type: "seed", snapshot: this.snapshot() }); diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts index 21d6e9b..e378f19 100644 --- a/src/main/vrchat/avatarService.ts +++ b/src/main/vrchat/avatarService.ts @@ -1,16 +1,28 @@ 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 { httpStatusOf } from "./errors"; import { cachedRead } from "./cachedRead"; import { requireActiveClient } from "./client"; -import { userCache } from "./userService"; +import { userCache, currentUser } from "./userService"; import { entityStore } from "../store/entityStore"; import { avatarStore } from "../store/avatarStore"; import { cacheKeys, policies } from "../cache/policies"; import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints"; -type FavoriteFolder = { name: string; displayName: string; avatars: Avatar[] }; +type FavoriteFolder = { + name: string; + displayName: string; + visibility: FavoriteVisibility; + avatars: Avatar[]; +}; export async function getAvatar(avatarId: string): Promise { try { @@ -39,38 +51,52 @@ export async function loadMyAvatars(): Promise { } export async function loadFavoritedAvatars(): Promise { - const folders = await cachedRead( + const { folders, limits } = await cachedRead( cacheKeys.avatarFavorites(), policies.avatarFavorites, - fetchFavoriteFolders, + fetchFavorites, ); - avatarStore.setFavorites(folders); + avatarStore.setFavorites(folders, limits); } -async function fetchFavoriteFolders(vrc: VRChat): Promise { - const { data: groups } = await vrc.getFavoriteGroups({ - query: { n: 100 }, - throwOnError: true, - }); +async function fetchFavorites(vrc: VRChat): Promise<{ + folders: FavoriteFolder[]; + limits: FavoriteLimits; +}> { + 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 folders: FavoriteFolder[] = []; for (const group of avatarGroups) { - let raw; + let raw: Awaited> = []; try { raw = await getFavoritedAvatarsRaw(vrc, group.name); } catch (err) { - if (httpStatusOf(err) === 401 || httpStatusOf(err) === 403) continue; - throw err; + if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err; } - if (!raw.length) continue; folders.push({ name: group.name, displayName: group.displayName || prettyFolderName(group.name), + visibility: normalizeVisibility(group.visibility), avatars: raw.map(toAvatar), }); } - return folders; + return { folders, limits }; +} + +async function fetchFavoriteLimits(vrc: VRChat): Promise { + 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 { @@ -121,18 +147,54 @@ export async function deleteAvatar(avatarId: string): Promise { await refreshLists(vrc); } -export async function setAvatarFavorited(avatarId: string, favorited: boolean): Promise { +export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise { const vrc = requireActiveClient(); - if (favorited) { - await vrc.addFavorite({ - body: { type: "avatar", favoriteId: avatarId, tags: ["avatars1"] }, - throwOnError: true, - }); - } else { - const { data } = await vrc.getFavorites({ query: { type: "avatar", n: 100 }, throwOnError: true }); - const fav = data.find((f) => f.favoriteId === avatarId); - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); - } + await vrc.addFavorite({ + body: { type: "avatar", favoriteId: avatarId, tags: [folder] }, + throwOnError: true, + }); + await reloadFavorites(); +} + +export async function unfavoriteAvatar(avatarId: string): Promise { + 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 { + 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 { + 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 { userCache.invalidate(cacheKeys.avatarFavorites()); await loadFavoritedAvatars(); } @@ -145,5 +207,6 @@ function invalidateAvatar(avatarId: string): void { async function refreshLists(vrc: VRChat): Promise { avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar)); - avatarStore.setFavorites(await fetchFavoriteFolders(vrc)); + const { folders, limits } = await fetchFavorites(vrc); + avatarStore.setFavorites(folders, limits); } diff --git a/src/renderer/src/components/ui/CollapsibleCard.tsx b/src/renderer/src/components/ui/CollapsibleCard.tsx index 85ef2b0..dd1bfc6 100644 --- a/src/renderer/src/components/ui/CollapsibleCard.tsx +++ b/src/renderer/src/components/ui/CollapsibleCard.tsx @@ -5,27 +5,32 @@ import { LABEL_HEADING } from "./styles"; export function CollapsibleCard({ title, count, + action, defaultOpen = true, children, }: { title: string; count?: number | string; + action?: ReactNode; defaultOpen?: boolean; children: ReactNode; }) { const [open, setOpen] = useState(defaultOpen); return (
- +
+ + {action} +
{open ? children : null}
); diff --git a/src/renderer/src/features/avatar/AvatarActions.tsx b/src/renderer/src/features/avatar/AvatarActions.tsx index f540598..99f6a50 100644 --- a/src/renderer/src/features/avatar/AvatarActions.tsx +++ b/src/renderer/src/features/avatar/AvatarActions.tsx @@ -5,8 +5,9 @@ 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 { useAvatarFolder } from "../../store/avatars"; import { useNav } from "../navigation/NavContext"; +import { FavoriteModal } from "./FavoriteModal"; const RELEASE_STATUSES = ["public", "private"] as const; @@ -14,17 +15,18 @@ 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 folder = useAvatarFolder(avatar.id); 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 isFavorited = Boolean(folder); - const [busy, setBusy] = useState(null); + const [busy, setBusy] = useState(null); + const [favoriteOpen, setFavoriteOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [error, setError] = useState(null); - const run = async (kind: "select" | "favorite", fn: () => Promise) => { + const run = async (kind: "select", fn: () => Promise) => { setBusy(kind); setError(null); try { @@ -48,12 +50,9 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) { {isCurrent ? t("avatar:actions.wearing") : t("avatar:actions.wear")} @@ -74,6 +73,10 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) { {error ?

{error}

: null} + {favoriteOpen ? ( + setFavoriteOpen(false)} /> + ) : null} + {editOpen ? ( setEditOpen(false)} /> ) : null} diff --git a/src/renderer/src/features/avatar/AvatarsView.tsx b/src/renderer/src/features/avatar/AvatarsView.tsx index 3f1e3e0..51cffce 100644 --- a/src/renderer/src/features/avatar/AvatarsView.tsx +++ b/src/renderer/src/features/avatar/AvatarsView.tsx @@ -1,9 +1,11 @@ import { useEffect, useMemo, useState } from "react"; import type { Avatar } from "../../../../shared/types/avatar"; +import { Pencil } from "lucide-react"; import { CardGrid, CollapsibleCard, Field, + IconButton, LABEL_HEADING, PAGE_TITLE, SkeletonGrid, @@ -11,9 +13,16 @@ import { } from "../../components/ui"; import { api } from "../../lib/api"; 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 { 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"; @@ -109,6 +118,8 @@ function UploadedTab({ filter }: { filter: AvatarFilter }) { function FavoritesTab({ filter }: { filter: AvatarFilter }) { const t = useT(); const folders = useFavoriteAvatars(); + const { maxPerGroup } = useFavoriteLimits(); + const [editFolder, setEditFolder] = useState(null); if (!folders.length) return ; @@ -120,7 +131,20 @@ function FavoritesTab({ filter }: { filter: AvatarFilter }) { return (
{shown.map((folder) => ( - + setEditFolder(folder)} + aria-label={t("avatar:folder.edit")} + > + + + } + > {folder.avatars.map((a) => ( @@ -128,6 +152,10 @@ function FavoritesTab({ filter }: { filter: AvatarFilter }) { ))} + + {editFolder ? ( + setEditFolder(null)} /> + ) : null}
); } diff --git a/src/renderer/src/features/avatar/FavoriteModal.tsx b/src/renderer/src/features/avatar/FavoriteModal.tsx new file mode 100644 index 0000000..237da7c --- /dev/null +++ b/src/renderer/src/features/avatar/FavoriteModal.tsx @@ -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(null); + const [error, setError] = useState(null); + + const run = async (key: string, fn: () => Promise) => { + 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 ( + } + > +
+ {slots.map((slot) => { + const isCurrent = slot.name === currentFolder; + const disabled = busy !== null || (slot.full && !isCurrent); + return ( + + ); + })} + + {currentFolder ? ( + + ) : null} + + {error ?

{error}

: null} +
+
+ ); +} diff --git a/src/renderer/src/features/avatar/FolderEditModal.tsx b/src/renderer/src/features/avatar/FolderEditModal.tsx new file mode 100644 index 0000000..46a1d5e --- /dev/null +++ b/src/renderer/src/features/avatar/FolderEditModal.tsx @@ -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(folder.visibility); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( + } + confirmLabel={t("avatar:actions.save")} + onConfirm={save} + confirmLoading={busy} + confirmDisabled={!displayName.trim()} + > +
+ setDisplayName(e.target.value)} + /> + + {error ?

{error}

: null} +
+
+ ); +} diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 63e2ca2..a109321 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -5,7 +5,7 @@ import type { UserStatus } from "../../../shared/types/user"; import type { EnhancementId } from "../../../shared/types/enhancements"; import type { CreateInstanceInput } from "../../../shared/types/instance"; import type { PreferredRegion } from "../../../shared/types/appConfig"; -import type { AvatarEdit } from "../../../shared/types/avatar"; +import type { AvatarEdit, FavoriteGroupEdit } from "../../../shared/types/avatar"; export class ApiException extends Error { constructor(public readonly error: ApiError) { @@ -78,8 +78,12 @@ export const api = { 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 }), + favorite: (avatarId: string, folder?: string) => call("avatar:favorite", { avatarId, folder }), + 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: { byUser: (userId: string) => call("group:byUser", userId), diff --git a/src/renderer/src/lib/i18n/locales/en/avatar.json b/src/renderer/src/lib/i18n/locales/en/avatar.json index 40a1192..e5dac62 100644 --- a/src/renderer/src/lib/i18n/locales/en/avatar.json +++ b/src/renderer/src/lib/i18n/locales/en/avatar.json @@ -21,6 +21,9 @@ "wearing": "Wearing", "favorite": "Favorite", "unfavorite": "Unfavorite", + "manageFavorite": "Manage favorite", + "folderCount": "{{count}} avatars", + "folderFull": "Full", "edit": "Edit", "delete": "Delete", "save": "Save", @@ -32,6 +35,17 @@ "deleteTitle": "Delete avatar", "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": { "Excellent": "Excellent", "Good": "Good", diff --git a/src/renderer/src/lib/i18n/locales/ja/avatar.json b/src/renderer/src/lib/i18n/locales/ja/avatar.json index 06e1096..3bdbcc0 100644 --- a/src/renderer/src/lib/i18n/locales/ja/avatar.json +++ b/src/renderer/src/lib/i18n/locales/ja/avatar.json @@ -21,6 +21,9 @@ "wearing": "使用中", "favorite": "お気に入り登録", "unfavorite": "お気に入り解除", + "manageFavorite": "お気に入りを管理", + "folderCount": "{{count}} 体", + "folderFull": "満杯", "edit": "編集", "delete": "削除", "save": "保存", @@ -32,6 +35,17 @@ "deleteTitle": "アバターを削除", "deleteBody": "「{{name}}」を完全に削除しますか?元に戻せません。" }, + "folder": { + "edit": "フォルダを編集", + "editTitle": "フォルダを編集", + "name": "フォルダ名", + "visibility": "公開設定" + }, + "visibility": { + "private": "非公開", + "friends": "フレンド", + "public": "公開" + }, "performance": { "Excellent": "非常に良い", "Good": "良い", diff --git a/src/renderer/src/lib/i18n/locales/th/avatar.json b/src/renderer/src/lib/i18n/locales/th/avatar.json index e1a34cd..42c7386 100644 --- a/src/renderer/src/lib/i18n/locales/th/avatar.json +++ b/src/renderer/src/lib/i18n/locales/th/avatar.json @@ -21,6 +21,9 @@ "wearing": "กำลังสวมใส่", "favorite": "เพิ่มรายการโปรด", "unfavorite": "เอาออกจากรายการโปรด", + "manageFavorite": "จัดการรายการโปรด", + "folderCount": "{{count}} ตัว", + "folderFull": "เต็ม", "edit": "แก้ไข", "delete": "ลบ", "save": "บันทึก", @@ -32,6 +35,17 @@ "deleteTitle": "ลบอวตาร", "deleteBody": "ลบ \"{{name}}\" อย่างถาวรหรือไม่? ไม่สามารถย้อนกลับได้" }, + "folder": { + "edit": "แก้ไขโฟลเดอร์", + "editTitle": "แก้ไขโฟลเดอร์", + "name": "ชื่อโฟลเดอร์", + "visibility": "การมองเห็น" + }, + "visibility": { + "private": "ส่วนตัว", + "friends": "เพื่อน", + "public": "สาธารณะ" + }, "performance": { "Excellent": "ดีเยี่ยม", "Good": "ดี", diff --git a/src/renderer/src/store/avatars.ts b/src/renderer/src/store/avatars.ts index 8920054..0df94ba 100644 --- a/src/renderer/src/store/avatars.ts +++ b/src/renderer/src/store/avatars.ts @@ -1,13 +1,21 @@ import { useMemo } from "react"; import { create } from "zustand"; 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"; +const DEFAULT_LIMITS: FavoriteLimits = { maxGroups: 6, maxPerGroup: 50 }; + interface AvatarState { avatars: Record; mineIds: string[]; favorites: FavoriteAvatarFolder[]; + favoriteLimits: FavoriteLimits; seed: (s: AvatarSnapshot) => void; upsert: (a: Avatar) => void; } @@ -16,11 +24,13 @@ export const useAvatars = create((set) => ({ avatars: {}, mineIds: [], favorites: [], + favoriteLimits: DEFAULT_LIMITS, seed: (s) => set({ avatars: Object.fromEntries(s.avatars.map((a) => [a.id, a])), mineIds: s.mineIds, favorites: s.favorites, + favoriteLimits: s.favoriteLimits, }), upsert: (a) => set((st) => ({ avatars: { ...st.avatars, [a.id]: a } })), })); @@ -53,19 +63,61 @@ export const useMyAvatars = (): Avatar[] => export interface FavoriteFolder { name: string; displayName: string; + visibility: FavoriteAvatarFolder["visibility"]; + count: number; + full: boolean; avatars: Avatar[]; } export function useFavoriteAvatars(): FavoriteFolder[] { const favorites = useAvatars((s) => s.favorites); 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, + 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( () => favorites.map((f) => ({ name: f.name, 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], ); } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3eec90d..f0ec072 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -9,7 +9,7 @@ import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user"; import type { DiscoverCategory, FavoriteWorldFolder, World, WorldSnapshot } from "./types/world"; import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance"; import type { UnityStatus } from "./types/unity"; -import type { Avatar, AvatarEdit, AvatarSnapshot } from "./types/avatar"; +import type { Avatar, AvatarEdit, AvatarSnapshot, FavoriteGroupEdit } from "./types/avatar"; import type { RepoStats, StoredEntity } from "./types/repository"; import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from "./types/settings"; import type { Group, GroupSnapshot } from "./types/group"; @@ -64,7 +64,10 @@ export interface IpcRequests { "avatar:select": (avatarId: string) => IpcResult; "avatar:update": (p: { avatarId: string; edit: AvatarEdit }) => IpcResult; "avatar:delete": (avatarId: string) => IpcResult; - "avatar:setFavorited": (p: { avatarId: string; favorited: boolean }) => IpcResult; + "avatar:favorite": (p: { avatarId: string; folder?: string }) => IpcResult; + "avatar:unfavorite": (avatarId: string) => IpcResult; + "avatar:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult; + "avatar:updateFavoriteFolder": (p: { folder: string; edit: FavoriteGroupEdit }) => IpcResult; "group:byUser": (userId: string) => IpcResult; "group:represented": (userId: string) => IpcResult; diff --git a/src/shared/types/avatar.ts b/src/shared/types/avatar.ts index ac5c04f..724c882 100644 --- a/src/shared/types/avatar.ts +++ b/src/shared/types/avatar.ts @@ -29,14 +29,28 @@ export interface AvatarEdit { releaseStatus?: string; } +export type FavoriteVisibility = "private" | "friends" | "public"; + export interface FavoriteAvatarFolder { name: string; displayName: string; + visibility: FavoriteVisibility; avatarIds: string[]; } +export interface FavoriteLimits { + maxGroups: number; + maxPerGroup: number; +} + +export interface FavoriteGroupEdit { + displayName?: string; + visibility?: FavoriteVisibility; +} + export interface AvatarSnapshot { avatars: Avatar[]; mineIds: string[]; favorites: FavoriteAvatarFolder[]; + favoriteLimits: FavoriteLimits; }