diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 6712267..d521aca 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -71,6 +71,10 @@ const handlers = { "avatar:unfavorite": (avatarId) => guard(() => avatars.unfavoriteAvatar(avatarId)), "avatar:moveFavorite": ({ avatarId, folder }) => guard(() => avatars.moveAvatarToFolder(avatarId, folder)), + "avatar:unfavoriteMany": (avatarIds) => guard(() => avatars.unfavoriteAvatars(avatarIds)), + "avatar:moveFavoriteMany": ({ avatarIds, folder }) => + guard(() => avatars.moveAvatarsToFolder(avatarIds, folder)), + "avatar:clearFavoriteFolder": (folder) => guard(() => avatars.clearFavoriteFolder(folder)), "avatar:updateFavoriteFolder": ({ folder, edit }) => guard(() => avatars.updateFavoriteFolder(folder, edit)), diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts index e378f19..fae2828 100644 --- a/src/main/vrchat/avatarService.ts +++ b/src/main/vrchat/avatarService.ts @@ -6,6 +6,7 @@ import type { FavoriteGroupEdit, FavoriteLimits, FavoriteVisibility, + MoveResult, } from "../../shared/types/avatar"; import { toAvatar } from "./mappers"; import { httpStatusOf } from "./errors"; @@ -163,16 +164,72 @@ export async function unfavoriteAvatar(avatarId: string): Promise { await reloadFavorites(); } -export async function moveAvatarToFolder(avatarId: string, folder: string): Promise { +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?.tags?.includes(folder)) return { moved: 0, skipped: [] }; + if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [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(); + return { moved: 1, skipped: [] }; +} + +export async function unfavoriteAvatars(avatarIds: string[]): Promise { + const vrc = requireActiveClient(); + const records = await favoriteRecords(vrc); + for (const id of avatarIds) { + const fav = records.get(id); + if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true }); + } + await reloadFavorites(); +} + +export async function moveAvatarsToFolder( + avatarIds: string[], + folder: string, +): Promise { + const vrc = requireActiveClient(); + const records = await favoriteRecords(vrc); + const skipped: string[] = []; + let moved = 0; + for (const id of avatarIds) { + if (!(await canRefavorite(vrc, id))) { + skipped.push(id); + continue; + } + const fav = records.get(id); + if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true }); + await vrc.addFavorite({ + body: { type: "avatar", favoriteId: id, tags: [folder] }, + throwOnError: true, + }); + moved++; + } + await reloadFavorites(); + return { moved, skipped }; +} + +async function canRefavorite(vrc: VRChat, avatarId: string): Promise { + try { + await getAvatarRaw(vrc, avatarId); + return true; + } catch { + return false; + } +} + +export async function clearFavoriteFolder(folder: string): Promise { + const vrc = requireActiveClient(); + const me = await currentUser(); + await vrc.clearFavoriteGroup({ + path: { favoriteGroupType: "avatar", favoriteGroupName: folder, userId: me.id }, + throwOnError: true, + }); + await reloadFavorites(); } export async function updateFavoriteFolder(folder: string, edit: FavoriteGroupEdit): Promise { @@ -190,8 +247,24 @@ export async function updateFavoriteFolder(folder: string, edit: FavoriteGroupEd } 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); + return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === avatarId); +} + +async function favoriteRecords(vrc: VRChat): Promise> { + return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id])); +} + +async function favoriteRecordEntries(vrc: VRChat) { + const pageSize = 100; + const entries = []; + for (let offset = 0; ; offset += pageSize) { + const { data } = await vrc.getFavorites({ + query: { type: "avatar", n: pageSize, offset }, + throwOnError: true, + }); + entries.push(...data); + if (data.length < pageSize) return entries; + } } async function reloadFavorites(): Promise { diff --git a/src/renderer/src/features/avatar/AvatarCard.tsx b/src/renderer/src/features/avatar/AvatarCard.tsx index 2838fc0..f22e2f2 100644 --- a/src/renderer/src/features/avatar/AvatarCard.tsx +++ b/src/renderer/src/features/avatar/AvatarCard.tsx @@ -1,4 +1,4 @@ -import { Star } from "lucide-react"; +import { Check, Star } from "lucide-react"; import type { Avatar } from "../../../../shared/types/avatar"; import { Card, HoverImage, IconLabel, Tag } from "../../components/ui"; import { compactNumber } from "../../lib/format"; @@ -9,19 +9,36 @@ export function AvatarCard({ avatar, showAuthor, current, + selectable, + selected, + onToggleSelect, }: { avatar: Avatar; showAuthor?: boolean; current?: boolean; + selectable?: boolean; + selected?: boolean; + onToggleSelect?: () => void; }) { const t = useT(); const { openAvatar } = useNav(); const img = avatar.thumbnailImageUrl || avatar.imageUrl; return ( - openAvatar(avatar.id)}> + openAvatar(avatar.id)}>
{img ? : null} - {current ? ( + {selectable ? ( + + {selected ? : null} + + ) : null} + {current && !selectable ? ( {t("avatar:current")} diff --git a/src/renderer/src/features/avatar/AvatarsView.tsx b/src/renderer/src/features/avatar/AvatarsView.tsx index eac1a06..299868b 100644 --- a/src/renderer/src/features/avatar/AvatarsView.tsx +++ b/src/renderer/src/features/avatar/AvatarsView.tsx @@ -1,7 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import type { Avatar } from "../../../../shared/types/avatar"; -import { Pencil } from "lucide-react"; +import { CheckSquare, Pencil } from "lucide-react"; import { + Button, CardGrid, CollapsibleCard, Field, @@ -23,6 +24,7 @@ import { import { useSelf, useSocial } from "../../store/social"; import { useViewState } from "../navigation/NavContext"; import { AvatarCard } from "./AvatarCard"; +import { BulkMoveModal } from "./BulkMoveModal"; import { FolderEditModal } from "./FolderEditModal"; const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10"; @@ -77,13 +79,57 @@ export function AvatarsView() { onChange={(e) => setQuery(e.target.value)} />
- {tab === "uploaded" ? : } + {tab === "uploaded" ? ( + + ) : ( + 0} /> + )}
); } +function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) { + const t = useT(); + const [busy, setBusy] = useState(null); + const [moveOpen, setMoveOpen] = useState(false); + + const unfavorite = async () => { + setBusy("unfavorite"); + try { + await api.avatar.unfavoriteMany(ids); + onDone(); + } finally { + setBusy(null); + } + }; + + return ( +
+ + {t("avatar:bulk.selected", { count: ids.length })} + + + + {moveOpen ? ( + setMoveOpen(false)} + onMoved={() => { + setMoveOpen(false); + onDone(); + }} + /> + ) : null} +
+ ); +} + function CurrentAvatarSection() { const t = useT(); const self = useSelf(); @@ -116,21 +162,42 @@ function UploadedTab({ filter }: { filter: AvatarFilter }) { ); } -function FavoritesTab({ filter }: { filter: AvatarFilter }) { +function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: boolean }) { const t = useT(); const folders = useFavoriteAvatars(); const { maxPerGroup } = useFavoriteLimits(); const [editFolder, setEditFolder] = useState(null); + const [selecting, setSelecting] = useState(false); + const [selected, setSelected] = useState>(() => new Set()); if (!folders.length) return ; - const shown = folders - .map((f) => ({ ...f, avatars: f.avatars.filter(filter) })) - .filter((f) => f.avatars.length); + const filtered = folders.map((f) => ({ ...f, avatars: f.avatars.filter(filter) })); + const shown = searching ? filtered.filter((f) => f.avatars.length) : filtered; if (!shown.length) return

{t("avatar:empty")}

; + + const toggle = (id: string) => + setSelected((s) => { + const next = new Set(s); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + const exitSelect = () => { + setSelecting(false); + setSelected(new Set()); + }; + return (
+
+ +
+ {shown.map((folder) => ( } > - - {folder.avatars.map((a) => ( - - ))} - + {folder.avatars.length ? ( + + {folder.avatars.map((a) => ( + toggle(a.id)} + /> + ))} + + ) : ( +

{t("avatar:folder.empty")}

+ )}
))} {editFolder ? ( setEditFolder(null)} /> ) : null} + + {selecting && selected.size > 0 ? ( + + ) : null}
); } diff --git a/src/renderer/src/features/avatar/BulkMoveModal.tsx b/src/renderer/src/features/avatar/BulkMoveModal.tsx new file mode 100644 index 0000000..20b37b0 --- /dev/null +++ b/src/renderer/src/features/avatar/BulkMoveModal.tsx @@ -0,0 +1,74 @@ +import { useState } from "react"; +import { FolderInput, Star } from "lucide-react"; +import { Modal } from "../../components/ui"; +import { api, errorMessage } from "../../lib/api"; +import { useT } from "../../lib/i18n"; +import { useFolderSlots } from "../../store/avatars"; + +export function BulkMoveModal({ + ids, + onClose, + onMoved, +}: { + ids: string[]; + onClose: () => void; + onMoved: () => void; +}) { + const t = useT(); + const slots = useFolderSlots(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + const [skipped, setSkipped] = useState(null); + + const move = async (folder: string) => { + setBusy(folder); + setError(null); + setSkipped(null); + try { + const result = await api.avatar.moveFavoriteMany(ids, folder); + if (result.skipped.length) { + setSkipped(result.skipped.length); + setBusy(null); + } else { + onMoved(); + } + } catch (err) { + setError(errorMessage(err, t("avatar:actions.failed"))); + setBusy(null); + } + }; + + return ( + } + > +
+ {slots.map((slot) => ( + + ))} + {skipped ? ( +

{t("avatar:bulk.skipped", { count: skipped })}

+ ) : null} + {error ?

{error}

: null} +
+
+ ); +} diff --git a/src/renderer/src/features/avatar/FavoriteModal.tsx b/src/renderer/src/features/avatar/FavoriteModal.tsx index 237da7c..71bbbfa 100644 --- a/src/renderer/src/features/avatar/FavoriteModal.tsx +++ b/src/renderer/src/features/avatar/FavoriteModal.tsx @@ -19,6 +19,7 @@ export function FavoriteModal({ const slots = useFolderSlots(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); + const [skipped, setSkipped] = useState(false); const run = async (key: string, fn: () => Promise) => { setBusy(key); @@ -34,7 +35,22 @@ export function FavoriteModal({ const pick = (folder: string) => { if (folder === currentFolder) return onClose(); - if (currentFolder) return run(folder, () => api.avatar.moveFavorite(avatar.id, folder)); + if (currentFolder) { + setBusy(folder); + setError(null); + setSkipped(false); + return api.avatar + .moveFavorite(avatar.id, folder) + .then((result) => { + if (result.skipped.length) { + setSkipped(true); + return; + } + onClose(); + }) + .catch((err) => setError(errorMessage(err, t("avatar:actions.failed")))) + .finally(() => setBusy(null)); + } return run(folder, () => api.avatar.favorite(avatar.id, folder)); }; @@ -83,6 +99,7 @@ export function FavoriteModal({ ) : null} + {skipped ?

{t("avatar:bulk.skipped", { count: 1 })}

: null} {error ?

{error}

: null} diff --git a/src/renderer/src/features/avatar/FolderEditModal.tsx b/src/renderer/src/features/avatar/FolderEditModal.tsx index 46a1d5e..0827ec9 100644 --- a/src/renderer/src/features/avatar/FolderEditModal.tsx +++ b/src/renderer/src/features/avatar/FolderEditModal.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { Pencil } from "lucide-react"; +import { Pencil, Trash2 } from "lucide-react"; import type { FavoriteVisibility } from "../../../../shared/types/avatar"; -import { Field, INPUT_CLASS, Modal } from "../../components/ui"; +import { Button, Field, INPUT_CLASS, Modal } from "../../components/ui"; import { api, errorMessage } from "../../lib/api"; import { useT } from "../../lib/i18n"; import type { FavoriteFolder } from "../../store/avatars"; @@ -19,6 +19,7 @@ export function FolderEditModal({ const [displayName, setDisplayName] = useState(folder.displayName); const [visibility, setVisibility] = useState(folder.visibility); const [busy, setBusy] = useState(false); + const [confirmClear, setConfirmClear] = useState(false); const [error, setError] = useState(null); const save = async () => { @@ -33,6 +34,19 @@ export function FolderEditModal({ } }; + const clear = async () => { + setBusy(true); + setError(null); + try { + await api.avatar.clearFavoriteFolder(folder.name); + onClose(); + } catch (err) { + setError(errorMessage(err, t("avatar:actions.failed"))); + setBusy(false); + setConfirmClear(false); + } + }; + return ( + + {folder.count > 0 ? ( +
+ {confirmClear ? ( +
+ + {t("avatar:folder.clearConfirm", { count: folder.count })} + + +
+ ) : ( + + )} +
+ ) : null} + {error ?

{error}

: null}
diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index a109321..7a5b9ac 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, FavoriteGroupEdit } from "../../../shared/types/avatar"; +import type { AvatarEdit, FavoriteGroupEdit, MoveResult } from "../../../shared/types/avatar"; export class ApiException extends Error { constructor(public readonly error: ApiError) { @@ -80,8 +80,12 @@ export const api = { delete: (avatarId: string) => call("avatar:delete", avatarId), favorite: (avatarId: string, folder?: string) => call("avatar:favorite", { avatarId, folder }), unfavorite: (avatarId: string) => call("avatar:unfavorite", avatarId), - moveFavorite: (avatarId: string, folder: string) => + moveFavorite: (avatarId: string, folder: string): Promise => call("avatar:moveFavorite", { avatarId, folder }), + unfavoriteMany: (avatarIds: string[]) => call("avatar:unfavoriteMany", avatarIds), + moveFavoriteMany: (avatarIds: string[], folder: string): Promise => + call("avatar:moveFavoriteMany", { avatarIds, folder }), + clearFavoriteFolder: (folder: string) => call("avatar:clearFavoriteFolder", folder), updateFavoriteFolder: (folder: string, edit: FavoriteGroupEdit) => call("avatar:updateFavoriteFolder", { folder, edit }), }, diff --git a/src/renderer/src/lib/i18n/locales/en/avatar.json b/src/renderer/src/lib/i18n/locales/en/avatar.json index e5dac62..59b74b2 100644 --- a/src/renderer/src/lib/i18n/locales/en/avatar.json +++ b/src/renderer/src/lib/i18n/locales/en/avatar.json @@ -39,13 +39,24 @@ "edit": "Edit folder", "editTitle": "Edit folder", "name": "Folder name", - "visibility": "Visibility" + "visibility": "Visibility", + "clear": "Empty folder", + "clearConfirm": "Remove all {{count}} avatars?", + "empty": "This folder is empty." }, "visibility": { "private": "Private", "friends": "Friends", "public": "Public" }, + "bulk": { + "select": "Select", + "done": "Done", + "selected": "{{count}} selected", + "move": "Move", + "moveTitle": "Move {{count}} avatars", + "skipped": "{{count}} couldn't be moved (private or deleted) and were left in place." + }, "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 3bdbcc0..40082dd 100644 --- a/src/renderer/src/lib/i18n/locales/ja/avatar.json +++ b/src/renderer/src/lib/i18n/locales/ja/avatar.json @@ -39,13 +39,24 @@ "edit": "フォルダを編集", "editTitle": "フォルダを編集", "name": "フォルダ名", - "visibility": "公開設定" + "visibility": "公開設定", + "clear": "フォルダを空にする", + "clearConfirm": "{{count}} 体すべて削除しますか?", + "empty": "このフォルダは空です。" }, "visibility": { "private": "非公開", "friends": "フレンド", "public": "公開" }, + "bulk": { + "select": "選択", + "done": "完了", + "selected": "{{count}} 件選択中", + "move": "移動", + "moveTitle": "{{count}} 体を移動", + "skipped": "{{count}} 体は移動できず(非公開または削除済み)、そのままになりました。" + }, "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 42c7386..ce9bd38 100644 --- a/src/renderer/src/lib/i18n/locales/th/avatar.json +++ b/src/renderer/src/lib/i18n/locales/th/avatar.json @@ -39,13 +39,24 @@ "edit": "แก้ไขโฟลเดอร์", "editTitle": "แก้ไขโฟลเดอร์", "name": "ชื่อโฟลเดอร์", - "visibility": "การมองเห็น" + "visibility": "การมองเห็น", + "clear": "ล้างโฟลเดอร์", + "clearConfirm": "ลบอวตารทั้งหมด {{count}} ตัวหรือไม่?", + "empty": "โฟลเดอร์นี้ว่างเปล่า" }, "visibility": { "private": "ส่วนตัว", "friends": "เพื่อน", "public": "สาธารณะ" }, + "bulk": { + "select": "เลือก", + "done": "เสร็จ", + "selected": "เลือก {{count}} รายการ", + "move": "ย้าย", + "moveTitle": "ย้าย {{count}} ตัว", + "skipped": "ย้ายไม่ได้ {{count}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม" + }, "performance": { "Excellent": "ดีเยี่ยม", "Good": "ดี", diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index f0ec072..444fb8a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -9,7 +9,13 @@ 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, FavoriteGroupEdit } from "./types/avatar"; +import type { + Avatar, + AvatarEdit, + AvatarSnapshot, + FavoriteGroupEdit, + MoveResult, +} 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"; @@ -66,7 +72,10 @@ export interface IpcRequests { "avatar:delete": (avatarId: string) => IpcResult; "avatar:favorite": (p: { avatarId: string; folder?: string }) => IpcResult; "avatar:unfavorite": (avatarId: string) => IpcResult; - "avatar:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult; + "avatar:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult; + "avatar:unfavoriteMany": (avatarIds: string[]) => IpcResult; + "avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult; + "avatar:clearFavoriteFolder": (folder: string) => IpcResult; "avatar:updateFavoriteFolder": (p: { folder: string; edit: FavoriteGroupEdit }) => IpcResult; "group:byUser": (userId: string) => IpcResult; diff --git a/src/shared/types/avatar.ts b/src/shared/types/avatar.ts index 724c882..db901d0 100644 --- a/src/shared/types/avatar.ts +++ b/src/shared/types/avatar.ts @@ -48,6 +48,11 @@ export interface FavoriteGroupEdit { visibility?: FavoriteVisibility; } +export interface MoveResult { + moved: number; + skipped: string[]; +} + export interface AvatarSnapshot { avatars: Avatar[]; mineIds: string[];