mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +00:00
✨ feat: bulk edit avatar favorites
This commit is contained in:
@@ -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)),
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
{folder.avatars.length ? (
|
||||
<CardGrid>
|
||||
{folder.avatars.map((a) => (
|
||||
<AvatarCard key={a.id} avatar={a} showAuthor />
|
||||
<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>
|
||||
|
||||
@@ -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
@@ -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[]>;
|
||||
|
||||
@@ -48,6 +48,11 @@ export interface FavoriteGroupEdit {
|
||||
visibility?: FavoriteVisibility;
|
||||
}
|
||||
|
||||
export interface MoveResult {
|
||||
moved: number;
|
||||
skipped: string[];
|
||||
}
|
||||
|
||||
export interface AvatarSnapshot {
|
||||
avatars: Avatar[];
|
||||
mineIds: string[];
|
||||
|
||||
Reference in New Issue
Block a user