mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
✨ feat: refine avatar selection actions
This commit is contained in:
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}` : ""}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}
|
||||||
|
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}
|
{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 ${
|
||||||
|
|||||||
@@ -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 className="selection-bar__progress-label">
|
||||||
|
{t("avatar:bulk.unfavoritingProgress", { done: deleteProgress, total: ids.length })}
|
||||||
</span>
|
</span>
|
||||||
<Button onClick={() => setMoveOpen(true)} loading={busy === "move"}>
|
<span className="selection-bar__progress-track">
|
||||||
{t("avatar:bulk.move")}
|
<span className="selection-bar__progress-fill" style={{ width: `${deletePercent}%` }} />
|
||||||
</Button>
|
</span>
|
||||||
<Button variant="ghost" onClick={unfavorite} loading={busy === "unfavorite"}>
|
</div>
|
||||||
{t("avatar:actions.unfavorite")}
|
) : null}
|
||||||
</Button>
|
<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>
|
<CardGrid>
|
||||||
{shown.map((a) => (
|
{shown.map((a) => (
|
||||||
<AvatarCard key={a.id} avatar={a} />
|
<AvatarCard
|
||||||
|
key={a.id}
|
||||||
|
avatar={a}
|
||||||
|
onContextMenu={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({ x: e.clientX, y: e.clientY, avatar: a });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</CardGrid>
|
</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,11 +406,23 @@ 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">
|
||||||
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
|
{selecting ? (
|
||||||
|
<>
|
||||||
|
<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))}>
|
<Button variant="ghost" onClick={() => (selecting ? exitSelect() : setSelecting(true))}>
|
||||||
<CheckSquare size={15} />
|
<CheckSquare size={15} />
|
||||||
{selecting ? t("avatar:bulk.done") : t("avatar:bulk.select")}
|
{selecting ? t("avatar:bulk.done") : t("avatar:bulk.select")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{shown.map((folder) => (
|
{shown.map((folder) => (
|
||||||
<CollapsibleCard
|
<CollapsibleCard
|
||||||
@@ -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}} ตัว (ส่วนตัวหรือถูกลบแล้ว) และยังอยู่ที่เดิม"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user