diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts index fae2828..8aaf1a2 100644 --- a/src/main/vrchat/avatarService.ts +++ b/src/main/vrchat/avatarService.ts @@ -145,7 +145,7 @@ export async function deleteAvatar(avatarId: string): Promise { await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true }); invalidateAvatar(avatarId); avatarStore.removeAvatar(avatarId); - await refreshLists(vrc); + await refreshLists(vrc, new Set([avatarId])); } export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise { @@ -278,8 +278,11 @@ function invalidateAvatar(avatarId: string): void { userCache.invalidate(cacheKeys.avatarFavorites()); } -async function refreshLists(vrc: VRChat): Promise { - avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar)); +async function refreshLists(vrc: VRChat, exclude = new Set()): Promise { + avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar).filter((a) => !exclude.has(a.id))); const { folders, limits } = await fetchFavorites(vrc); - avatarStore.setFavorites(folders, limits); + avatarStore.setFavorites( + folders.map((f) => ({ ...f, avatars: f.avatars.filter((a) => !exclude.has(a.id)) })), + limits, + ); } diff --git a/src/renderer/src/components/ui/Button.tsx b/src/renderer/src/components/ui/Button.tsx index e24c8b9..c64660c 100644 --- a/src/renderer/src/components/ui/Button.tsx +++ b/src/renderer/src/components/ui/Button.tsx @@ -19,6 +19,7 @@ export function Button({ loading, block, className = "", + disabled, ...rest }: ButtonHTMLAttributes & { variant?: keyof typeof VARIANT; @@ -28,8 +29,8 @@ export function Button({ return ( - + + {busy ? ( +
+ + {t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })} + + + + +
+ ) : null} + + {t("avatar:bulk.done")} + + setMoveOpen(true)} disabled={busy}> + {t("avatar:bulk.move")} + + setConfirmDelete(true)} disabled={busy}> + {t("avatar:actions.unfavorite")} + + {confirmDelete ? ( + { + if (!busy) setConfirmDelete(false); + }} + title={t("avatar:bulk.unfavoriteTitle")} + icon={} + danger + confirmLabel={t("avatar:actions.unfavorite")} + confirmLoading={busy} + onConfirm={unfavorite} + > +
+

+ {t("avatar:bulk.unfavoriteConfirm", { count: ids.length })} +

+ {busy ? ( +
+ + {t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })} + +
+
+
+
+ ) : null} +
+ + ) : null} {moveOpen ? ( void }) { }} /> ) : null} -
+
); } function CurrentAvatarSection() { const t = useT(); + const nav = useNav(); const self = useSelf(); const { avatar } = useAvatar(self?.currentAvatarId); + const folder = useAvatarFolder(avatar?.id ?? ""); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + const [favoriteOpen, setFavoriteOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); if (!avatar) return null; + + const isOwner = avatar.authorId === self?.id; + const menuItems: ContextMenuEntry[] = [ + { label: t("avatar:context.open"), icon: , onClick: () => nav.openAvatar(avatar.id) }, + { label: t("avatar:actions.wearing"), icon: , disabled: true, onClick: () => {} }, + { separator: true }, + { + label: folder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite"), + icon: , + onClick: () => setFavoriteOpen(true), + }, + ...(isOwner + ? [ + { label: t("avatar:actions.edit"), icon: , onClick: () => setEditOpen(true) }, + { + label: t("avatar:actions.delete"), + icon: , + danger: true, + onClick: () => setDeleteOpen(true), + }, + ] satisfies ContextMenuEntry[] + : []), + ]; + return (

{t("avatar:current")}

- + { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }} + />
+ {favoriteOpen ? ( + setFavoriteOpen(false)} /> + ) : null} + {editOpen ? setEditOpen(false)} /> : null} + setDeleteOpen(false)} + onDeleted={() => setDeleteOpen(false)} + /> + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null}
); } function UploadedTab({ filter }: { filter: AvatarFilter }) { const t = useT(); + const nav = useNav(); const mine = useMyAvatars(); + const [contextMenu, setContextMenu] = useState<{ x: number; y: number; avatar: Avatar } | null>(null); + const [editAvatar, setEditAvatar] = useState(null); + const [deleteAvatar, setDeleteAvatar] = useState(null); if (!mine.length) return ; const shown = mine.filter(filter); if (!shown.length) return

{t("avatar:empty")}

; + + const menuItems = (avatar: Avatar): ContextMenuEntry[] => [ + { label: t("avatar:context.open"), icon: , onClick: () => nav.openAvatar(avatar.id) }, + { label: t("avatar:actions.wear"), icon: , onClick: () => void api.avatar.select(avatar.id) }, + { label: t("avatar:actions.edit"), icon: , onClick: () => setEditAvatar(avatar) }, + { separator: true }, + { + label: t("avatar:actions.delete"), + icon: , + danger: true, + onClick: () => setDeleteAvatar(avatar), + }, + ]; + return ( - - {shown.map((a) => ( - - ))} - + <> + + {shown.map((a) => ( + { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, avatar: a }); + }} + /> + ))} + + + {editAvatar ? ( + setEditAvatar(null)} /> + ) : null} + + {deleteAvatar ? ( + setDeleteAvatar(null)} + onDeleted={() => setDeleteAvatar(null)} + /> + ) : null} + + {contextMenu ? ( + setContextMenu(null)} + /> + ) : null} + ); } function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: boolean }) { const t = useT(); + const nav = useNav(); const folders = useFavoriteAvatars(); const { maxPerGroup } = useFavoriteLimits(); const [editFolder, setEditFolder] = useState(null); + const [favoriteMenu, setFavoriteMenu] = useState<{ avatar: Avatar; folder: string } | null>(null); + const [removeAvatar, setRemoveAvatar] = useState(null); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + avatar: Avatar; + folder: string; + } | null>(null); const [selecting, setSelecting] = useState(false); const [selected, setSelected] = useState>(() => new Set()); @@ -184,6 +358,46 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: return next; }); + const selectReleaseStatus = (status: string) => { + setSelected( + new Set( + shown.flatMap((folder) => + folder.avatars.filter((avatar) => avatar.releaseStatus === status).map((avatar) => avatar.id), + ), + ), + ); + setSelecting(true); + }; + + const selectOne = (id: string) => { + setSelected(new Set([id])); + setSelecting(true); + }; + + const removeFavorite = async () => { + if (!removeAvatar) return; + await api.avatar.unfavorite(removeAvatar.id); + setRemoveAvatar(null); + }; + + const menuItems = (avatar: Avatar, folder: string): ContextMenuEntry[] => [ + { label: t("avatar:context.open"), icon: , onClick: () => nav.openAvatar(avatar.id) }, + { label: t("avatar:context.select"), icon: , onClick: () => selectOne(avatar.id) }, + { label: t("avatar:actions.wear"), icon: , onClick: () => void api.avatar.select(avatar.id) }, + { separator: true }, + { + label: t("avatar:actions.manageFavorite"), + icon: , + onClick: () => setFavoriteMenu({ avatar, folder }), + }, + { + label: t("avatar:actions.unfavorite"), + icon: , + danger: true, + onClick: () => setRemoveAvatar(avatar), + }, + ]; + const exitSelect = () => { setSelecting(false); setSelected(new Set()); @@ -192,10 +406,22 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: return (
- +
+ {selecting ? ( + <> + + + + ) : null} + +
{shown.map((folder) => ( @@ -223,6 +449,14 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: selectable={selecting} selected={selected.has(a.id)} onToggleSelect={() => toggle(a.id)} + onContextMenu={ + selecting + ? undefined + : (e) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, avatar: a, folder: folder.name }); + } + } /> ))} @@ -236,6 +470,39 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: setEditFolder(null)} /> ) : null} + {favoriteMenu ? ( + setFavoriteMenu(null)} + /> + ) : null} + + {removeAvatar ? ( + setRemoveAvatar(null)} + title={t("avatar:bulk.unfavoriteTitle")} + icon={} + danger + confirmLabel={t("avatar:actions.unfavorite")} + onConfirm={removeFavorite} + > +

+ {t("avatar:context.unfavoriteConfirm", { name: removeAvatar.name })} +

+
+ ) : null} + + {contextMenu ? ( + setContextMenu(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 index 20b37b0..7a8f6fd 100644 --- a/src/renderer/src/features/avatar/BulkMoveModal.tsx +++ b/src/renderer/src/features/avatar/BulkMoveModal.tsx @@ -18,26 +18,34 @@ export function BulkMoveModal({ const slots = useFolderSlots(); const [busy, setBusy] = useState(null); const [error, setError] = useState(null); - const [skipped, setSkipped] = useState(null); + const [done, setDone] = useState(0); + const [skipped, setSkipped] = useState(0); const move = async (folder: string) => { setBusy(folder); setError(null); - setSkipped(null); + setDone(0); + setSkipped(0); try { - const result = await api.avatar.moveFavoriteMany(ids, folder); - if (result.skipped.length) { - setSkipped(result.skipped.length); - setBusy(null); - } else { + let skippedCount = 0; + for (const id of ids) { + const result = await api.avatar.moveFavorite(id, folder); + if (result.skipped.length) skippedCount += result.skipped.length; + setSkipped(skippedCount); + setDone((n) => n + 1); + } + if (!skippedCount) { onMoved(); } } catch (err) { setError(errorMessage(err, t("avatar:actions.failed"))); + } finally { setBusy(null); } }; + const progress = ids.length ? Math.round((done / ids.length) * 100) : 0; + return ( } >
+ {busy ? ( +
+ + {t("avatar:bulk.movingProgress", { done, total: ids.length })} + + {skipped ? ( +

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

+ ) : null} +
+
+
+
+ ) : null} {slots.map((slot) => ( ))} - {skipped ? ( -

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

+ {!busy && 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 71bbbfa..aae8985 100644 --- a/src/renderer/src/features/avatar/FavoriteModal.tsx +++ b/src/renderer/src/features/avatar/FavoriteModal.tsx @@ -62,6 +62,19 @@ export function FavoriteModal({ icon={} >
+ {skipped ? ( +

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

+ ) : null} + {busy ? ( +
+ {t("avatar:bulk.moving")} +
+
+
+
+ ) : null} {slots.map((slot) => { const isCurrent = slot.name === currentFolder; const disabled = busy !== null || (slot.full && !isCurrent); @@ -99,7 +112,6 @@ export function FavoriteModal({ ) : null} - {skipped ?

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

: null} {error ?

{error}

: null}
diff --git a/src/renderer/src/features/gallery/GalleryView.tsx b/src/renderer/src/features/gallery/GalleryView.tsx index 7563a61..7fff9a0 100644 --- a/src/renderer/src/features/gallery/GalleryView.tsx +++ b/src/renderer/src/features/gallery/GalleryView.tsx @@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Trans } from "react-i18next"; import { Check, Globe, Images, RefreshCw, Search, Trash2, X } from "lucide-react"; import type { Photo } from "../../../../shared/types/gallery"; -import { Banner, Loader } from "../../components/ui"; +import { Banner, Loader, SelectionBar, SelectionBarButton } from "../../components/ui"; import { useI18n } from "../../lib/i18n"; import { useGallery } from "./useGallery"; import { Lightbox } from "./Lightbox"; @@ -293,17 +293,14 @@ export function GalleryView() { ) : null} {selecting ? ( -
- - {t("gallery:selectedCount", { count: selected.size })} - - - -
+ + ) : null} {activeIndex >= 0 ? ( diff --git a/src/renderer/src/features/gallery/gallery.css b/src/renderer/src/features/gallery/gallery.css index 7950b96..657ffc4 100644 --- a/src/renderer/src/features/gallery/gallery.css +++ b/src/renderer/src/features/gallery/gallery.css @@ -323,57 +323,6 @@ border-radius: 3px; } -.gallery__selbar { - position: absolute; - left: 50%; - bottom: 20px; - transform: translateX(-50%); - z-index: 5; - display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px 8px 16px; - border-radius: 999px; - border: 1px solid var(--border); - background: color-mix(in srgb, var(--surface) 86%, transparent); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - box-shadow: var(--shadow-2); - animation: pop-in var(--dur) var(--ease-out) both; -} -.gallery__selcount { - font-size: 13px; - font-weight: 600; - color: var(--text); - margin-right: 4px; -} -.gallery__selbtn { - display: inline-flex; - align-items: center; - gap: 6px; - height: 32px; - padding: 0 14px; - border-radius: 999px; - border: 1px solid var(--border); - background: var(--surface-2); - color: var(--text); - font-size: 12.5px; - font-weight: 600; - transition: - border-color var(--dur) var(--ease), - background var(--dur) var(--ease), - color var(--dur) var(--ease); -} -.gallery__selbtn:hover { - border-color: var(--border-strong); - background: var(--surface-hover); -} -.gallery__selbtn.is-danger:hover { - border-color: var(--danger); - background: color-mix(in srgb, var(--danger) 14%, transparent); - color: var(--danger); -} - .gallery__none { font-size: 13px; color: var(--muted); @@ -629,7 +578,6 @@ .lightbox, .lightbox__body, .lightbox__layer--full, - .gallery__selbar, .tile { animation: none; transition: none; diff --git a/src/renderer/src/lib/i18n/locales/en/avatar.json b/src/renderer/src/lib/i18n/locales/en/avatar.json index 59b74b2..46ca21f 100644 --- a/src/renderer/src/lib/i18n/locales/en/avatar.json +++ b/src/renderer/src/lib/i18n/locales/en/avatar.json @@ -49,11 +49,23 @@ "friends": "Friends", "public": "Public" }, + "context": { + "open": "Open avatar", + "select": "Select avatar", + "unfavoriteConfirm": "Remove \"{{name}}\" from your favorites?" + }, "bulk": { "select": "Select", "done": "Done", + "selectPrivate": "Select private", + "selectHidden": "Select hidden", "selected": "{{count}} selected", "move": "Move", + "moving": "Moving favorites...", + "movingProgress": "Moving {{done}} / {{total}} favorites...", + "unfavoritingProgress": "Removing {{done}} / {{total}} favorites...", + "unfavoriteTitle": "Remove favorites", + "unfavoriteConfirm": "Remove {{count}} selected avatars from your favorites?", "moveTitle": "Move {{count}} avatars", "skipped": "{{count}} couldn't be moved (private or deleted) and were left in place." }, diff --git a/src/renderer/src/lib/i18n/locales/ja/avatar.json b/src/renderer/src/lib/i18n/locales/ja/avatar.json index 40082dd..885590f 100644 --- a/src/renderer/src/lib/i18n/locales/ja/avatar.json +++ b/src/renderer/src/lib/i18n/locales/ja/avatar.json @@ -49,11 +49,23 @@ "friends": "フレンド", "public": "公開" }, + "context": { + "open": "アバターを開く", + "select": "アバターを選択", + "unfavoriteConfirm": "「{{name}}」をお気に入りから削除しますか?" + }, "bulk": { "select": "選択", "done": "完了", + "selectPrivate": "非公開を選択", + "selectHidden": "Hiddenを選択", "selected": "{{count}} 件選択中", "move": "移動", + "moving": "お気に入りを移動中...", + "movingProgress": "{{done}} / {{total}} 件を移動中...", + "unfavoritingProgress": "{{done}} / {{total}} 件を削除中...", + "unfavoriteTitle": "お気に入りから削除", + "unfavoriteConfirm": "選択した {{count}} 体をお気に入りから削除しますか?", "moveTitle": "{{count}} 体を移動", "skipped": "{{count}} 体は移動できず(非公開または削除済み)、そのままになりました。" }, diff --git a/src/renderer/src/lib/i18n/locales/th/avatar.json b/src/renderer/src/lib/i18n/locales/th/avatar.json index ce9bd38..4f5bbf8 100644 --- a/src/renderer/src/lib/i18n/locales/th/avatar.json +++ b/src/renderer/src/lib/i18n/locales/th/avatar.json @@ -49,11 +49,23 @@ "friends": "เพื่อน", "public": "สาธารณะ" }, + "context": { + "open": "เปิดอวตาร", + "select": "เลือกอวตาร", + "unfavoriteConfirm": "ลบ \"{{name}}\" ออกจากรายการโปรดหรือไม่?" + }, "bulk": { "select": "เลือก", "done": "เสร็จ", + "selectPrivate": "เลือกส่วนตัว", + "selectHidden": "เลือกที่ซ่อนไว้", "selected": "เลือก {{count}} รายการ", "move": "ย้าย", + "moving": "กำลังย้ายรายการโปรด...", + "movingProgress": "กำลังย้าย {{done}} / {{total}} รายการ...", + "unfavoritingProgress": "กำลังลบ {{done}} / {{total}} รายการ...", + "unfavoriteTitle": "ลบจากรายการโปรด", + "unfavoriteConfirm": "ลบอวตารที่เลือก {{count}} ตัวออกจากรายการโปรดหรือไม่?", "moveTitle": "ย้าย {{count}} ตัว", "skipped": "ย้ายไม่ได้ {{count}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม" }, diff --git a/src/renderer/src/styles/global.css b/src/renderer/src/styles/global.css index ad82e2d..0a8a95a 100644 --- a/src/renderer/src/styles/global.css +++ b/src/renderer/src/styles/global.css @@ -147,6 +147,87 @@ body, animation: rise-in var(--dur-lg) var(--ease-out) both; } +.selection-bar { + position: fixed; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + z-index: 50; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 8px 16px; + border-radius: 999px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 86%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: var(--shadow-2); + animation: pop-in var(--dur) var(--ease-out) both; +} +.selection-bar__count { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-right: 4px; +} +.selection-bar__button { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 12.5px; + font-weight: 600; + transition: + border-color var(--dur) var(--ease), + background var(--dur) var(--ease), + color var(--dur) var(--ease), + opacity var(--dur) var(--ease); +} +.selection-bar__button:hover { + border-color: var(--border-strong); + background: var(--surface-hover); +} +.selection-bar__button:disabled { + cursor: not-allowed; + opacity: 0.6; +} +.selection-bar__button.is-danger:hover { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 14%, transparent); + color: var(--danger); +} +.selection-bar__progress { + display: flex; + min-width: 180px; + flex-direction: column; + gap: 4px; + margin-right: 4px; +} +.selection-bar__progress-label { + font-size: 11.5px; + font-weight: 600; + color: var(--muted); +} +.selection-bar__progress-track { + height: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--surface-hover); +} +.selection-bar__progress-fill { + display: block; + height: 100%; + border-radius: 999px; + background: var(--accent); + transition: width 200ms var(--ease); +} + @media (prefers-reduced-motion: reduce) { * { animation: none !important;