feat: refine avatar selection actions

This commit is contained in:
2026-06-30 03:46:04 +07:00
parent 53d1cb9444
commit 6c048e8a28
15 changed files with 522 additions and 111 deletions
+7 -4
View File
@@ -145,7 +145,7 @@ export async function deleteAvatar(avatarId: string): Promise<void> {
await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true }); await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true });
invalidateAvatar(avatarId); invalidateAvatar(avatarId);
avatarStore.removeAvatar(avatarId); avatarStore.removeAvatar(avatarId);
await refreshLists(vrc); await refreshLists(vrc, new Set([avatarId]));
} }
export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise<void> { export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise<void> {
@@ -278,8 +278,11 @@ function invalidateAvatar(avatarId: string): void {
userCache.invalidate(cacheKeys.avatarFavorites()); userCache.invalidate(cacheKeys.avatarFavorites());
} }
async function refreshLists(vrc: VRChat): Promise<void> { async function refreshLists(vrc: VRChat, exclude = new Set<string>()): Promise<void> {
avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar)); avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar).filter((a) => !exclude.has(a.id)));
const { folders, limits } = await fetchFavorites(vrc); 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,
);
} }
+2 -1
View File
@@ -19,6 +19,7 @@ export function Button({
loading, loading,
block, block,
className = "", className = "",
disabled,
...rest ...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { }: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: keyof typeof VARIANT; variant?: keyof typeof VARIANT;
@@ -28,8 +29,8 @@ export function Button({
return ( return (
<button <button
className={`${BASE} ${VARIANT[variant]} ${block ? "w-full" : ""} ${className}`} className={`${BASE} ${VARIANT[variant]} ${block ? "w-full" : ""} ${className}`}
disabled={loading || rest.disabled}
{...rest} {...rest}
disabled={loading || disabled}
> >
{loading ? ( {loading ? (
<span className="size-3.5 animate-[spin_0.7s_linear_infinite] rounded-full border-2 border-[color-mix(in_srgb,currentColor_35%,transparent)] border-t-current" /> <span className="size-3.5 animate-[spin_0.7s_linear_infinite] rounded-full border-2 border-[color-mix(in_srgb,currentColor_35%,transparent)] border-t-current" />
@@ -0,0 +1,26 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { createPortal } from "react-dom";
export function SelectionBar({ label, children }: { label: ReactNode; children: ReactNode }) {
if (typeof document === "undefined") return null;
return createPortal(
<div className="selection-bar" role="toolbar">
<span className="selection-bar__count">{label}</span>
{children}
</div>,
document.body,
);
}
export function SelectionBarButton({
danger,
className = "",
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { danger?: boolean }) {
return (
<button
{...props}
className={`selection-bar__button${danger ? " is-danger" : ""}${className ? ` ${className}` : ""}`}
/>
);
}
+1
View File
@@ -27,4 +27,5 @@ export { Card } from "./Card";
export { HoverImage } from "./HoverImage"; export { HoverImage } from "./HoverImage";
export { ContextMenu } from "./ContextMenu"; export { ContextMenu } from "./ContextMenu";
export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu"; export type { ContextMenuEntry, ContextMenuItem } from "./ContextMenu";
export { SelectionBar, SelectionBarButton } from "./SelectionBar";
export { LABEL_HEADING, PAGE_TITLE } from "./styles"; export { LABEL_HEADING, PAGE_TITLE } from "./styles";
@@ -78,10 +78,10 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
) : null} ) : null}
{editOpen ? ( {editOpen ? (
<EditModal avatar={avatar} onClose={() => setEditOpen(false)} /> <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} />
) : null} ) : null}
<DeleteModal <DeleteAvatarModal
avatar={avatar} avatar={avatar}
open={deleteOpen} open={deleteOpen}
onClose={() => setDeleteOpen(false)} onClose={() => setDeleteOpen(false)}
@@ -91,7 +91,7 @@ export function AvatarActions({ avatar }: { avatar: Avatar }) {
); );
} }
function EditModal({ avatar, onClose }: { avatar: Avatar; onClose: () => void }) { export function EditAvatarModal({ avatar, onClose }: { avatar: Avatar; onClose: () => void }) {
const t = useT(); const t = useT();
const [name, setName] = useState(avatar.name); const [name, setName] = useState(avatar.name);
const [description, setDescription] = useState(avatar.description); const [description, setDescription] = useState(avatar.description);
@@ -160,7 +160,7 @@ function EditModal({ avatar, onClose }: { avatar: Avatar; onClose: () => void })
); );
} }
function DeleteModal({ export function DeleteAvatarModal({
avatar, avatar,
open, open,
onClose, onClose,
@@ -1,3 +1,4 @@
import type { MouseEvent } from "react";
import { Check, Star } from "lucide-react"; import { Check, Star } from "lucide-react";
import type { Avatar } from "../../../../shared/types/avatar"; import type { Avatar } from "../../../../shared/types/avatar";
import { Card, HoverImage, IconLabel, Tag } from "../../components/ui"; import { Card, HoverImage, IconLabel, Tag } from "../../components/ui";
@@ -12,6 +13,7 @@ export function AvatarCard({
selectable, selectable,
selected, selected,
onToggleSelect, onToggleSelect,
onContextMenu,
}: { }: {
avatar: Avatar; avatar: Avatar;
showAuthor?: boolean; showAuthor?: boolean;
@@ -19,14 +21,23 @@ export function AvatarCard({
selectable?: boolean; selectable?: boolean;
selected?: boolean; selected?: boolean;
onToggleSelect?: () => void; onToggleSelect?: () => void;
onContextMenu?: (e: MouseEvent) => void;
}) { }) {
const t = useT(); const t = useT();
const { openAvatar } = useNav(); const { openAvatar } = useNav();
const img = avatar.thumbnailImageUrl || avatar.imageUrl; const img = avatar.thumbnailImageUrl || avatar.imageUrl;
return ( return (
<Card onClick={selectable ? onToggleSelect : () => openAvatar(avatar.id)}> <Card
<div className="relative aspect-[4/3] bg-surface-hover"> onClick={selectable ? onToggleSelect : () => openAvatar(avatar.id)}
{img ? <HoverImage src={img} loading="lazy" /> : null} onContextMenu={onContextMenu}
className={selected ? "outline outline-[3px] -outline-offset-[3px] outline-accent" : ""}
>
<div className="relative aspect-[4/3] overflow-hidden bg-surface-hover">
<div
className={`h-full w-full transition-transform duration-200 ease-fluid ${selected ? "scale-90" : ""}`}
>
{img ? <HoverImage src={img} loading="lazy" /> : null}
</div>
{selectable ? ( {selectable ? (
<span <span
className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${ className={`absolute left-1.5 top-1.5 flex size-5 items-center justify-center rounded-md border transition-colors ${
+294 -27
View File
@@ -1,30 +1,38 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { Avatar } from "../../../../shared/types/avatar"; import type { Avatar } from "../../../../shared/types/avatar";
import { CheckSquare, Pencil } from "lucide-react"; import { CheckSquare, Eye, FolderInput, Pencil, Shirt, Star, Trash2, X } from "lucide-react";
import { import {
Button, Button,
CardGrid, CardGrid,
CollapsibleCard, CollapsibleCard,
ContextMenu,
Field, Field,
IconButton, IconButton,
LABEL_HEADING, LABEL_HEADING,
Modal,
PAGE_TITLE, PAGE_TITLE,
SelectionBar,
SelectionBarButton,
SkeletonGrid, SkeletonGrid,
Tabs, Tabs,
type ContextMenuEntry,
} from "../../components/ui"; } from "../../components/ui";
import { api } from "../../lib/api"; import { api } from "../../lib/api";
import { useT } from "../../lib/i18n"; import { useT } from "../../lib/i18n";
import { import {
useAvatar, useAvatar,
useAvatarFolder,
useFavoriteAvatars, useFavoriteAvatars,
useFavoriteLimits, useFavoriteLimits,
useMyAvatars, useMyAvatars,
type FavoriteFolder, type FavoriteFolder,
} from "../../store/avatars"; } from "../../store/avatars";
import { useSelf, useSocial } from "../../store/social"; import { useSelf, useSocial } from "../../store/social";
import { useViewState } from "../navigation/NavContext"; import { useNav, useViewState } from "../navigation/NavContext";
import { DeleteAvatarModal, EditAvatarModal } from "./AvatarActions";
import { AvatarCard } from "./AvatarCard"; import { AvatarCard } from "./AvatarCard";
import { BulkMoveModal } from "./BulkMoveModal"; import { BulkMoveModal } from "./BulkMoveModal";
import { FavoriteModal } from "./FavoriteModal";
import { FolderEditModal } from "./FolderEditModal"; import { FolderEditModal } from "./FolderEditModal";
const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10"; const SHELL = "mx-auto flex w-full max-w-[1100px] flex-col gap-5 px-12 pb-16 pt-10";
@@ -92,30 +100,82 @@ export function AvatarsView() {
function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) { function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
const t = useT(); const t = useT();
const [busy, setBusy] = useState<null | "move" | "unfavorite">(null); const [busy, setBusy] = useState(false);
const [moveOpen, setMoveOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleteProgress, setDeleteProgress] = useState(0);
const unfavorite = async () => { const unfavorite = async () => {
setBusy("unfavorite"); if (busy) return;
setBusy(true);
setDeleteProgress(0);
try { try {
await api.avatar.unfavoriteMany(ids); for (const id of ids) {
await api.avatar.unfavorite(id);
setDeleteProgress((n) => n + 1);
}
onDone(); onDone();
} finally { } finally {
setBusy(null); setBusy(false);
} }
}; };
const deletePercent = ids.length ? Math.round((deleteProgress / ids.length) * 100) : 0;
return ( 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"> <SelectionBar label={t("avatar:bulk.selected", { count: ids.length })}>
<span className="text-[13px] font-medium tabular-nums"> {busy ? (
{t("avatar:bulk.selected", { count: ids.length })} <div className="selection-bar__progress" aria-label={t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}>
</span> <span className="selection-bar__progress-label">
<Button onClick={() => setMoveOpen(true)} loading={busy === "move"}> {t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}
{t("avatar:bulk.move")} </span>
</Button> <span className="selection-bar__progress-track">
<Button variant="ghost" onClick={unfavorite} loading={busy === "unfavorite"}> <span className="selection-bar__progress-fill" style={{ width: `${deletePercent}%` }} />
{t("avatar:actions.unfavorite")} </span>
</Button> </div>
) : null}
<SelectionBarButton onClick={onDone} disabled={busy}>
<X size={15} /> {t("avatar:bulk.done")}
</SelectionBarButton>
<SelectionBarButton onClick={() => setMoveOpen(true)} disabled={busy}>
<FolderInput size={15} /> {t("avatar:bulk.move")}
</SelectionBarButton>
<SelectionBarButton danger onClick={() => setConfirmDelete(true)} disabled={busy}>
<Trash2 size={15} /> {t("avatar:actions.unfavorite")}
</SelectionBarButton>
{confirmDelete ? (
<Modal
open
onClose={() => {
if (!busy) setConfirmDelete(false);
}}
title={t("avatar:bulk.unfavoriteTitle")}
icon={<Trash2 size={16} />}
danger
confirmLabel={t("avatar:actions.unfavorite")}
confirmLoading={busy}
onConfirm={unfavorite}
>
<div className="flex flex-col gap-3">
<p className="text-[13px] text-muted">
{t("avatar:bulk.unfavoriteConfirm", { count: ids.length })}
</p>
{busy ? (
<div className="flex flex-col gap-1.5">
<span className="text-[12px] text-muted">
{t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}
</span>
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div
className="h-full rounded-full bg-danger transition-[width] duration-200 ease-fluid"
style={{ width: `${deletePercent}%` }}
/>
</div>
</div>
) : null}
</div>
</Modal>
) : null}
{moveOpen ? ( {moveOpen ? (
<BulkMoveModal <BulkMoveModal
ids={ids} ids={ids}
@@ -126,47 +186,161 @@ function BulkBar({ ids, onDone }: { ids: string[]; onDone: () => void }) {
}} }}
/> />
) : null} ) : null}
</div> </SelectionBar>
); );
} }
function CurrentAvatarSection() { function CurrentAvatarSection() {
const t = useT(); const t = useT();
const nav = useNav();
const self = useSelf(); const self = useSelf();
const { avatar } = useAvatar(self?.currentAvatarId); 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; if (!avatar) return null;
const isOwner = avatar.authorId === self?.id;
const menuItems: ContextMenuEntry[] = [
{ label: t("avatar:context.open"), icon: <Eye size={14} />, onClick: () => nav.openAvatar(avatar.id) },
{ label: t("avatar:actions.wearing"), icon: <Shirt size={14} />, disabled: true, onClick: () => {} },
{ separator: true },
{
label: folder ? t("avatar:actions.manageFavorite") : t("avatar:actions.favorite"),
icon: <Star size={14} />,
onClick: () => setFavoriteOpen(true),
},
...(isOwner
? [
{ label: t("avatar:actions.edit"), icon: <Pencil size={14} />, onClick: () => setEditOpen(true) },
{
label: t("avatar:actions.delete"),
icon: <Trash2 size={14} />,
danger: true,
onClick: () => setDeleteOpen(true),
},
] satisfies ContextMenuEntry[]
: []),
];
return ( return (
<section className="flex flex-col gap-2.5"> <section className="flex flex-col gap-2.5">
<h2 className={LABEL_HEADING}>{t("avatar:current")}</h2> <h2 className={LABEL_HEADING}>{t("avatar:current")}</h2>
<div className="w-1/2 sm:w-1/3"> <div className="w-1/2 sm:w-1/3">
<AvatarCard avatar={avatar} showAuthor current /> <AvatarCard
avatar={avatar}
showAuthor
current
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY });
}}
/>
</div> </div>
{favoriteOpen ? (
<FavoriteModal avatar={avatar} currentFolder={folder} onClose={() => setFavoriteOpen(false)} />
) : null}
{editOpen ? <EditAvatarModal avatar={avatar} onClose={() => setEditOpen(false)} /> : null}
<DeleteAvatarModal
avatar={avatar}
open={deleteOpen}
onClose={() => setDeleteOpen(false)}
onDeleted={() => setDeleteOpen(false)}
/>
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={menuItems}
onClose={() => setContextMenu(null)}
/>
) : null}
</section> </section>
); );
} }
function UploadedTab({ filter }: { filter: AvatarFilter }) { function UploadedTab({ filter }: { filter: AvatarFilter }) {
const t = useT(); const t = useT();
const nav = useNav();
const mine = useMyAvatars(); const mine = useMyAvatars();
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; avatar: Avatar } | null>(null);
const [editAvatar, setEditAvatar] = useState<Avatar | null>(null);
const [deleteAvatar, setDeleteAvatar] = useState<Avatar | null>(null);
if (!mine.length) return <SkeletonGrid count={6} />; if (!mine.length) return <SkeletonGrid count={6} />;
const shown = mine.filter(filter); const shown = mine.filter(filter);
if (!shown.length) return <p className="text-[13px] text-faint">{t("avatar:empty")}</p>; if (!shown.length) return <p className="text-[13px] text-faint">{t("avatar:empty")}</p>;
const menuItems = (avatar: Avatar): ContextMenuEntry[] => [
{ label: t("avatar:context.open"), icon: <Eye size={14} />, onClick: () => nav.openAvatar(avatar.id) },
{ label: t("avatar:actions.wear"), icon: <Shirt size={14} />, onClick: () => void api.avatar.select(avatar.id) },
{ label: t("avatar:actions.edit"), icon: <Pencil size={14} />, onClick: () => setEditAvatar(avatar) },
{ separator: true },
{
label: t("avatar:actions.delete"),
icon: <Trash2 size={14} />,
danger: true,
onClick: () => setDeleteAvatar(avatar),
},
];
return ( return (
<CardGrid> <>
{shown.map((a) => ( <CardGrid>
<AvatarCard key={a.id} avatar={a} /> {shown.map((a) => (
))} <AvatarCard
</CardGrid> key={a.id}
avatar={a}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, avatar: a });
}}
/>
))}
</CardGrid>
{editAvatar ? (
<EditAvatarModal avatar={editAvatar} onClose={() => setEditAvatar(null)} />
) : null}
{deleteAvatar ? (
<DeleteAvatarModal
avatar={deleteAvatar}
open
onClose={() => setDeleteAvatar(null)}
onDeleted={() => setDeleteAvatar(null)}
/>
) : null}
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={menuItems(contextMenu.avatar)}
onClose={() => setContextMenu(null)}
/>
) : null}
</>
); );
} }
function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: boolean }) { function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching: boolean }) {
const t = useT(); const t = useT();
const nav = useNav();
const folders = useFavoriteAvatars(); const folders = useFavoriteAvatars();
const { maxPerGroup } = useFavoriteLimits(); const { maxPerGroup } = useFavoriteLimits();
const [editFolder, setEditFolder] = useState<FavoriteFolder | null>(null); const [editFolder, setEditFolder] = useState<FavoriteFolder | null>(null);
const [favoriteMenu, setFavoriteMenu] = useState<{ avatar: Avatar; folder: string } | null>(null);
const [removeAvatar, setRemoveAvatar] = useState<Avatar | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
avatar: Avatar;
folder: string;
} | null>(null);
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(() => new Set()); const [selected, setSelected] = useState<Set<string>>(() => new Set());
@@ -184,6 +358,46 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
return next; 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: <Eye size={14} />, onClick: () => nav.openAvatar(avatar.id) },
{ label: t("avatar:context.select"), icon: <CheckSquare size={14} />, onClick: () => selectOne(avatar.id) },
{ label: t("avatar:actions.wear"), icon: <Shirt size={14} />, onClick: () => void api.avatar.select(avatar.id) },
{ separator: true },
{
label: t("avatar:actions.manageFavorite"),
icon: <Star size={14} />,
onClick: () => setFavoriteMenu({ avatar, folder }),
},
{
label: t("avatar:actions.unfavorite"),
icon: <Trash2 size={14} />,
danger: true,
onClick: () => setRemoveAvatar(avatar),
},
];
const exitSelect = () => { const exitSelect = () => {
setSelecting(false); setSelecting(false);
setSelected(new Set()); setSelected(new Set());
@@ -192,10 +406,22 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex justify-end"> <div className="flex justify-end">
<Button variant="ghost" onClick={() => (selecting ? exitSelect() : setSelecting(true))}> <div className="flex flex-wrap justify-end gap-2">
<CheckSquare size={15} /> {selecting ? (
{selecting ? t("avatar:bulk.done") : t("avatar:bulk.select")} <>
</Button> <Button variant="ghost" onClick={() => selectReleaseStatus("private")}>
{t("avatar:bulk.selectPrivate")}
</Button>
<Button variant="ghost" onClick={() => selectReleaseStatus("hidden")}>
{t("avatar:bulk.selectHidden")}
</Button>
</>
) : null}
<Button variant="ghost" onClick={() => (selecting ? exitSelect() : setSelecting(true))}>
<CheckSquare size={15} />
{selecting ? t("avatar:bulk.done") : t("avatar:bulk.select")}
</Button>
</div>
</div> </div>
{shown.map((folder) => ( {shown.map((folder) => (
@@ -223,6 +449,14 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
selectable={selecting} selectable={selecting}
selected={selected.has(a.id)} selected={selected.has(a.id)}
onToggleSelect={() => toggle(a.id)} onToggleSelect={() => toggle(a.id)}
onContextMenu={
selecting
? undefined
: (e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, avatar: a, folder: folder.name });
}
}
/> />
))} ))}
</CardGrid> </CardGrid>
@@ -236,6 +470,39 @@ function FavoritesTab({ filter, searching }: { filter: AvatarFilter; searching:
<FolderEditModal folder={editFolder} onClose={() => setEditFolder(null)} /> <FolderEditModal folder={editFolder} onClose={() => setEditFolder(null)} />
) : null} ) : null}
{favoriteMenu ? (
<FavoriteModal
avatar={favoriteMenu.avatar}
currentFolder={favoriteMenu.folder}
onClose={() => setFavoriteMenu(null)}
/>
) : null}
{removeAvatar ? (
<Modal
open
onClose={() => setRemoveAvatar(null)}
title={t("avatar:bulk.unfavoriteTitle")}
icon={<Trash2 size={16} />}
danger
confirmLabel={t("avatar:actions.unfavorite")}
onConfirm={removeFavorite}
>
<p className="text-[13px] text-muted">
{t("avatar:context.unfavoriteConfirm", { name: removeAvatar.name })}
</p>
</Modal>
) : null}
{contextMenu ? (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
items={menuItems(contextMenu.avatar, contextMenu.folder)}
onClose={() => setContextMenu(null)}
/>
) : null}
{selecting && selected.size > 0 ? ( {selecting && selected.size > 0 ? (
<BulkBar ids={[...selected]} onDone={exitSelect} /> <BulkBar ids={[...selected]} onDone={exitSelect} />
) : null} ) : null}
@@ -18,26 +18,34 @@ export function BulkMoveModal({
const slots = useFolderSlots(); const slots = useFolderSlots();
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [skipped, setSkipped] = useState<number | null>(null); const [done, setDone] = useState(0);
const [skipped, setSkipped] = useState(0);
const move = async (folder: string) => { const move = async (folder: string) => {
setBusy(folder); setBusy(folder);
setError(null); setError(null);
setSkipped(null); setDone(0);
setSkipped(0);
try { try {
const result = await api.avatar.moveFavoriteMany(ids, folder); let skippedCount = 0;
if (result.skipped.length) { for (const id of ids) {
setSkipped(result.skipped.length); const result = await api.avatar.moveFavorite(id, folder);
setBusy(null); if (result.skipped.length) skippedCount += result.skipped.length;
} else { setSkipped(skippedCount);
setDone((n) => n + 1);
}
if (!skippedCount) {
onMoved(); onMoved();
} }
} catch (err) { } catch (err) {
setError(errorMessage(err, t("avatar:actions.failed"))); setError(errorMessage(err, t("avatar:actions.failed")));
} finally {
setBusy(null); setBusy(null);
} }
}; };
const progress = ids.length ? Math.round((done / ids.length) * 100) : 0;
return ( return (
<Modal <Modal
open open
@@ -46,6 +54,24 @@ export function BulkMoveModal({
icon={<FolderInput size={16} />} icon={<FolderInput size={16} />}
> >
<div className="flex flex-col gap-1.5 text-left"> <div className="flex flex-col gap-1.5 text-left">
{busy ? (
<div className="mb-2 flex flex-col gap-1.5">
<span className="text-[12px] text-muted">
{t("avatar:bulk.movingProgress", { done, total: ids.length })}
</span>
{skipped ? (
<p className="text-[12px] font-medium text-danger">
{t("avatar:bulk.skipped", { count: skipped })}
</p>
) : null}
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div
className="h-full rounded-full bg-accent transition-[width] duration-200 ease-fluid"
style={{ width: `${progress}%` }}
/>
</div>
</div>
) : null}
{slots.map((slot) => ( {slots.map((slot) => (
<button <button
key={slot.name} key={slot.name}
@@ -64,8 +90,10 @@ export function BulkMoveModal({
</span> </span>
</button> </button>
))} ))}
{skipped ? ( {!busy && skipped ? (
<p className="text-[12px] text-muted">{t("avatar:bulk.skipped", { count: skipped })}</p> <p className="text-[12px] font-medium text-danger">
{t("avatar:bulk.skipped", { count: skipped })}
</p>
) : null} ) : null}
{error ? <p className="text-[12px] text-danger">{error}</p> : null} {error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div> </div>
@@ -62,6 +62,19 @@ export function FavoriteModal({
icon={<Star size={16} />} icon={<Star size={16} />}
> >
<div className="flex flex-col gap-1.5 text-left"> <div className="flex flex-col gap-1.5 text-left">
{skipped ? (
<p className="mb-1 text-[12px] font-medium text-danger">
{t("avatar:bulk.skipped", { count: 1 })}
</p>
) : null}
{busy ? (
<div className="mb-2 flex flex-col gap-1.5">
<span className="text-[12px] text-muted">{t("avatar:bulk.moving")}</span>
<div className="h-1 overflow-hidden rounded-full bg-surface-hover">
<div className="h-full w-full rounded-full bg-accent" />
</div>
</div>
) : null}
{slots.map((slot) => { {slots.map((slot) => {
const isCurrent = slot.name === currentFolder; const isCurrent = slot.name === currentFolder;
const disabled = busy !== null || (slot.full && !isCurrent); const disabled = busy !== null || (slot.full && !isCurrent);
@@ -99,7 +112,6 @@ export function FavoriteModal({
</Button> </Button>
) : null} ) : 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} {error ? <p className="text-[12px] text-danger">{error}</p> : null}
</div> </div>
</Modal> </Modal>
@@ -2,7 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import { Check, Globe, Images, RefreshCw, Search, Trash2, X } from "lucide-react"; import { Check, Globe, Images, RefreshCw, Search, Trash2, X } from "lucide-react";
import type { Photo } from "../../../../shared/types/gallery"; 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 { useI18n } from "../../lib/i18n";
import { useGallery } from "./useGallery"; import { useGallery } from "./useGallery";
import { Lightbox } from "./Lightbox"; import { Lightbox } from "./Lightbox";
@@ -293,17 +293,14 @@ export function GalleryView() {
) : null} ) : null}
{selecting ? ( {selecting ? (
<div className="gallery__selbar" role="toolbar"> <SelectionBar label={t("gallery:selectedCount", { count: selected.size })}>
<span className="gallery__selcount"> <SelectionBarButton onClick={clearSel}>
{t("gallery:selectedCount", { count: selected.size })}
</span>
<button className="gallery__selbtn" onClick={clearSel}>
<X size={15} /> {t("gallery:deselect")} <X size={15} /> {t("gallery:deselect")}
</button> </SelectionBarButton>
<button className="gallery__selbtn is-danger" onClick={deleteSelected}> <SelectionBarButton danger onClick={deleteSelected}>
<Trash2 size={15} /> {t("gallery:delete")} <Trash2 size={15} /> {t("gallery:delete")}
</button> </SelectionBarButton>
</div> </SelectionBar>
) : null} ) : null}
{activeIndex >= 0 ? ( {activeIndex >= 0 ? (
@@ -323,57 +323,6 @@
border-radius: 3px; 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 { .gallery__none {
font-size: 13px; font-size: 13px;
color: var(--muted); color: var(--muted);
@@ -629,7 +578,6 @@
.lightbox, .lightbox,
.lightbox__body, .lightbox__body,
.lightbox__layer--full, .lightbox__layer--full,
.gallery__selbar,
.tile { .tile {
animation: none; animation: none;
transition: none; transition: none;
@@ -49,11 +49,23 @@
"friends": "Friends", "friends": "Friends",
"public": "Public" "public": "Public"
}, },
"context": {
"open": "Open avatar",
"select": "Select avatar",
"unfavoriteConfirm": "Remove \"{{name}}\" from your favorites?"
},
"bulk": { "bulk": {
"select": "Select", "select": "Select",
"done": "Done", "done": "Done",
"selectPrivate": "Select private",
"selectHidden": "Select hidden",
"selected": "{{count}} selected", "selected": "{{count}} selected",
"move": "Move", "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", "moveTitle": "Move {{count}} avatars",
"skipped": "{{count}} couldn't be moved (private or deleted) and were left in place." "skipped": "{{count}} couldn't be moved (private or deleted) and were left in place."
}, },
@@ -49,11 +49,23 @@
"friends": "フレンド", "friends": "フレンド",
"public": "公開" "public": "公開"
}, },
"context": {
"open": "アバターを開く",
"select": "アバターを選択",
"unfavoriteConfirm": "「{{name}}」をお気に入りから削除しますか?"
},
"bulk": { "bulk": {
"select": "選択", "select": "選択",
"done": "完了", "done": "完了",
"selectPrivate": "非公開を選択",
"selectHidden": "Hiddenを選択",
"selected": "{{count}} 件選択中", "selected": "{{count}} 件選択中",
"move": "移動", "move": "移動",
"moving": "お気に入りを移動中...",
"movingProgress": "{{done}} / {{total}} 件を移動中...",
"unfavoritingProgress": "{{done}} / {{total}} 件を削除中...",
"unfavoriteTitle": "お気に入りから削除",
"unfavoriteConfirm": "選択した {{count}} 体をお気に入りから削除しますか?",
"moveTitle": "{{count}} 体を移動", "moveTitle": "{{count}} 体を移動",
"skipped": "{{count}} 体は移動できず(非公開または削除済み)、そのままになりました。" "skipped": "{{count}} 体は移動できず(非公開または削除済み)、そのままになりました。"
}, },
@@ -49,11 +49,23 @@
"friends": "เพื่อน", "friends": "เพื่อน",
"public": "สาธารณะ" "public": "สาธารณะ"
}, },
"context": {
"open": "เปิดอวตาร",
"select": "เลือกอวตาร",
"unfavoriteConfirm": "ลบ \"{{name}}\" ออกจากรายการโปรดหรือไม่?"
},
"bulk": { "bulk": {
"select": "เลือก", "select": "เลือก",
"done": "เสร็จ", "done": "เสร็จ",
"selectPrivate": "เลือกส่วนตัว",
"selectHidden": "เลือกที่ซ่อนไว้",
"selected": "เลือก {{count}} รายการ", "selected": "เลือก {{count}} รายการ",
"move": "ย้าย", "move": "ย้าย",
"moving": "กำลังย้ายรายการโปรด...",
"movingProgress": "กำลังย้าย {{done}} / {{total}} รายการ...",
"unfavoritingProgress": "กำลังลบ {{done}} / {{total}} รายการ...",
"unfavoriteTitle": "ลบจากรายการโปรด",
"unfavoriteConfirm": "ลบอวตารที่เลือก {{count}} ตัวออกจากรายการโปรดหรือไม่?",
"moveTitle": "ย้าย {{count}} ตัว", "moveTitle": "ย้าย {{count}} ตัว",
"skipped": "ย้ายไม่ได้ {{count}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม" "skipped": "ย้ายไม่ได้ {{count}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม"
}, },
+81
View File
@@ -147,6 +147,87 @@ body,
animation: rise-in var(--dur-lg) var(--ease-out) both; 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) { @media (prefers-reduced-motion: reduce) {
* { * {
animation: none !important; animation: none !important;