feat: bulk edit avatar favorites

This commit is contained in:
2026-06-30 02:22:09 +07:00
parent b6e1730d54
commit 53d1cb9444
13 changed files with 383 additions and 28 deletions
+4
View File
@@ -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)),
+77 -4
View File
@@ -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<void> {
await reloadFavorites();
}
export async function moveAvatarToFolder(avatarId: string, folder: string): Promise<void> {
export async function moveAvatarToFolder(avatarId: string, folder: string): Promise<MoveResult> {
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<void> {
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<MoveResult> {
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<boolean> {
try {
await getAvatarRaw(vrc, avatarId);
return true;
} catch {
return false;
}
}
export async function clearFavoriteFolder(folder: string): Promise<void> {
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<void> {
@@ -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<Map<string, string>> {
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<void> {
@@ -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 (
<Card onClick={() => openAvatar(avatar.id)}>
<Card onClick={selectable ? onToggleSelect : () => openAvatar(avatar.id)}>
<div className="relative aspect-[4/3] bg-surface-hover">
{img ? <HoverImage src={img} loading="lazy" /> : null}
{current ? (
{selectable ? (
<span
className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${
selected
? "border-accent bg-accent text-on-accent"
: "border-border bg-surface-2/80"
}`}
>
{selected ? <Check size={13} /> : null}
</span>
) : null}
{current && !selectable ? (
<span className="absolute left-1.5 top-1.5">
<Tag color="var(--accent)">{t("avatar:current")}</Tag>
</span>
@@ -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)}
/>
<div className="rise-in" key={tab}>
{tab === "uploaded" ? <UploadedTab filter={filter} /> : <FavoritesTab filter={filter} />}
{tab === "uploaded" ? (
<UploadedTab filter={filter} />
) : (
<FavoritesTab filter={filter} searching={query.trim().length > 0} />
)}
</div>
</div>
</div>
);
}
function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
const t = useT();
const [busy, setBusy] = useState<null | "move" | "unfavorite">(null);
const [moveOpen, setMoveOpen] = useState(false);
const unfavorite = async () => {
setBusy("unfavorite");
try {
await api.avatar.unfavoriteMany(ids);
onDone();
} finally {
setBusy(null);
}
};
return (
<div className="sticky bottom-4 z-10 mx-auto flex items-center gap-3 rounded-xl border border-border bg-surface-2/95 px-4 py-2.5 shadow-lg backdrop-blur">
<span className="text-[13px] font-medium tabular-nums">
{t("avatar:bulk.selected", { count: ids.length })}
</span>
<Button onClick={() => setMoveOpen(true)} loading={busy === "move"}>
{t("avatar:bulk.move")}
</Button>
<Button variant="ghost" onClick={unfavorite} loading={busy === "unfavorite"}>
{t("avatar:actions.unfavorite")}
</Button>
{moveOpen ? (
<BulkMoveModal
ids={ids}
onClose={() => setMoveOpen(false)}
onMoved={() => {
setMoveOpen(false);
onDone();
}}
/>
) : null}
</div>
);
}
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<FavoriteFolder | null>(null);
const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(() => new Set());
if (!folders.length) return <SkeletonGrid count={6} />;
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 <p className="text-[13px] text-faint">{t("avatar:empty")}</p>;
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 (
<div className="flex flex-col gap-5">
<div className="flex justify-end">
<Button variant="ghost" onClick={() => (selecting ? exitSelect() : setSelecting(true))}>
<CheckSquare size={15} />
{selecting ? t("avatar:bulk.done") : t("avatar:bulk.select")}
</Button>
</div>
{shown.map((folder) => (
<CollapsibleCard
key={folder.name}
@@ -146,17 +213,32 @@ function FavoritesTab({ filter }: { filter: AvatarFilter }) {
</IconButton>
}
>
<CardGrid>
{folder.avatars.map((a) => (
<AvatarCard key={a.id} avatar={a} showAuthor />
))}
</CardGrid>
{folder.avatars.length ? (
<CardGrid>
{folder.avatars.map((a) => (
<AvatarCard
key={a.id}
avatar={a}
showAuthor
selectable={selecting}
selected={selected.has(a.id)}
onToggleSelect={() => toggle(a.id)}
/>
))}
</CardGrid>
) : (
<p className="text-[13px] text-faint">{t("avatar:folder.empty")}</p>
)}
</CollapsibleCard>
))}
{editFolder ? (
<FolderEditModal folder={editFolder} onClose={() => setEditFolder(null)} />
) : null}
{selecting && selected.size > 0 ? (
<BulkBar ids={[...selected]} onDone={exitSelect} />
) : null}
</div>
);
}
@@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [skipped, setSkipped] = useState<number | null>(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 (
<Modal
open
onClose={onClose}
title={t("avatar:bulk.moveTitle", { count: ids.length })}
icon={<FolderInput size={16} />}
>
<div className="flex flex-col gap-1.5 text-left">
{slots.map((slot) => (
<button
key={slot.name}
onClick={() => move(slot.name)}
disabled={busy !== null}
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 })}
</span>
</span>
</button>
))}
{skipped ? (
<p className="text-[12px] text-muted">{t("avatar:bulk.skipped", { count: skipped })}</p>
) : null}
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div>
</Modal>
);
}
@@ -19,6 +19,7 @@ export function FavoriteModal({
const slots = useFolderSlots();
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [skipped, setSkipped] = useState(false);
const run = async (key: string, fn: () => Promise<void>) => {
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({
</Button>
) : null}
{skipped ? <p className="text-[12px] text-muted">{t("avatar:bulk.skipped", { count: 1 })}</p> : null}
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div>
</Modal>
@@ -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<FavoriteVisibility>(folder.visibility);
const [busy, setBusy] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Modal
open
@@ -64,6 +78,29 @@ export function FolderEditModal({
))}
</select>
</label>
{folder.count > 0 ? (
<div className="mt-1 border-t border-border pt-3">
{confirmClear ? (
<div className="flex items-center justify-between gap-3">
<span className="text-[12px] text-muted">
{t("avatar:folder.clearConfirm", { count: folder.count })}
</span>
<Button variant="danger" onClick={clear} loading={busy}>
{t("avatar:folder.clear")}
</Button>
</div>
) : (
<button
onClick={() => setConfirmClear(true)}
className="flex items-center gap-1.5 text-[12px] font-medium text-danger transition-opacity hover:opacity-80"
>
<Trash2 size={13} /> {t("avatar:folder.clear")}
</button>
)}
</div>
) : null}
{error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div>
</Modal>
+6 -2
View File
@@ -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<MoveResult> =>
call("avatar:moveFavorite", { avatarId, folder }),
unfavoriteMany: (avatarIds: string[]) => call("avatar:unfavoriteMany", avatarIds),
moveFavoriteMany: (avatarIds: string[], folder: string): Promise<MoveResult> =>
call("avatar:moveFavoriteMany", { avatarIds, folder }),
clearFavoriteFolder: (folder: string) => call("avatar:clearFavoriteFolder", folder),
updateFavoriteFolder: (folder: string, edit: FavoriteGroupEdit) =>
call("avatar:updateFavoriteFolder", { folder, edit }),
},
@@ -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",
@@ -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": "良い",
@@ -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": "ดี",
+11 -2
View File
@@ -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<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:moveFavorite": (p: { avatarId: string; folder: string }) => IpcResult<MoveResult>;
"avatar:unfavoriteMany": (avatarIds: string[]) => IpcResult<void>;
"avatar:moveFavoriteMany": (p: { avatarIds: string[]; folder: string }) => IpcResult<MoveResult>;
"avatar:clearFavoriteFolder": (folder: string) => IpcResult<void>;
"avatar:updateFavoriteFolder": (p: { folder: string; edit: FavoriteGroupEdit }) => IpcResult<void>;
"group:byUser": (userId: string) => IpcResult<Group[]>;
+5
View File
@@ -48,6 +48,11 @@ export interface FavoriteGroupEdit {
visibility?: FavoriteVisibility;
}
export interface MoveResult {
moved: number;
skipped: string[];
}
export interface AvatarSnapshot {
avatars: Avatar[];
mineIds: string[];