mirror of
https://github.com/MrUnknownDE/VRCX.git
synced 2026-05-07 14:56:06 +02:00
feat: Add my avatars tab
This commit is contained in:
@@ -231,8 +231,12 @@
|
|||||||
:aria-label="themeColorDisplayName(theme)"
|
:aria-label="themeColorDisplayName(theme)"
|
||||||
:title="themeColorDisplayName(theme)"
|
:title="themeColorDisplayName(theme)"
|
||||||
@click="handleThemeColorSelect(theme)"
|
@click="handleThemeColorSelect(theme)"
|
||||||
class="h-3.5 w-3.5 shrink-0 rounded-sm"
|
class="h-3.5 w-3.5 shrink-0 rounded-sm transition-transform hover:scale-125"
|
||||||
:class="currentThemeColor === theme.key ? 'ring-1 --ring' : ''"
|
:class="
|
||||||
|
currentThemeColor === theme.key
|
||||||
|
? 'ring-1 ring-ring ring-offset-1 ring-offset-background'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
:style="{ backgroundColor: theme.swatch }"></button>
|
:style="{ backgroundColor: theme.swatch }"></button>
|
||||||
</TooltipWrapper>
|
</TooltipWrapper>
|
||||||
</div>
|
</div>
|
||||||
@@ -381,6 +385,7 @@
|
|||||||
items: ['friend-log', 'friend-list', 'moderation']
|
items: ['friend-log', 'friend-list', 'moderation']
|
||||||
},
|
},
|
||||||
{ type: 'item', key: 'notification' },
|
{ type: 'item', key: 'notification' },
|
||||||
|
{ type: 'item', key: 'my-avatars' },
|
||||||
{
|
{
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
id: 'default-folder-charts',
|
id: 'default-folder-charts',
|
||||||
|
|||||||
@@ -34,9 +34,36 @@
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<template v-if="table.getRowModel().rows?.length">
|
<template v-if="table.getRowModel().rows?.length">
|
||||||
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
<template v-for="row in table.getRowModel().rows" :key="row.id">
|
||||||
|
<ContextMenu v-if="$slots['row-context-menu']">
|
||||||
|
<ContextMenuTrigger as-child>
|
||||||
<TableRow
|
<TableRow
|
||||||
@click="handleRowClick(row)"
|
@click="handleRowClick(row)"
|
||||||
:class="isDataTableStriped ? 'even:bg-muted/20' : ''">
|
:class="[
|
||||||
|
'group/row',
|
||||||
|
isDataTableStriped ? 'even:bg-muted/20' : '',
|
||||||
|
rowClass?.(row) ?? ''
|
||||||
|
]">
|
||||||
|
<TableCell
|
||||||
|
v-for="cell in row.getVisibleCells()"
|
||||||
|
:key="cell.id"
|
||||||
|
:class="getCellClass(cell)"
|
||||||
|
:style="getPinnedStyle(cell.column)">
|
||||||
|
<FlexRender
|
||||||
|
:render="cell.column.columnDef.cell"
|
||||||
|
:props="cell.getContext()" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<slot name="row-context-menu" :row="row" />
|
||||||
|
</ContextMenu>
|
||||||
|
<TableRow
|
||||||
|
v-else
|
||||||
|
@click="handleRowClick(row)"
|
||||||
|
:class="[
|
||||||
|
'group/row',
|
||||||
|
isDataTableStriped ? 'even:bg-muted/20' : '',
|
||||||
|
rowClass?.(row) ?? ''
|
||||||
|
]">
|
||||||
<TableCell
|
<TableCell
|
||||||
v-for="cell in row.getVisibleCells()"
|
v-for="cell in row.getVisibleCells()"
|
||||||
:key="cell.id"
|
:key="cell.id"
|
||||||
@@ -133,6 +160,7 @@
|
|||||||
} from '../pagination';
|
} from '../pagination';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../table';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../select';
|
||||||
|
import { ContextMenu, ContextMenuTrigger } from '../context-menu';
|
||||||
|
|
||||||
import DataTableEmpty from './DataTableEmpty.vue';
|
import DataTableEmpty from './DataTableEmpty.vue';
|
||||||
|
|
||||||
@@ -183,6 +211,10 @@
|
|||||||
onRowClick: {
|
onRowClick: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
rowClass: {
|
||||||
|
type: Function,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"favorite_friends": "Favorite Friends",
|
"favorite_friends": "Favorite Friends",
|
||||||
"favorite_worlds": "Favorite Worlds",
|
"favorite_worlds": "Favorite Worlds",
|
||||||
"favorite_avatars": "Favorite Avatars",
|
"favorite_avatars": "Favorite Avatars",
|
||||||
|
"my_avatars": "My Avatars",
|
||||||
"social": "Social",
|
"social": "Social",
|
||||||
"friend_log": "Friend Log",
|
"friend_log": "Friend Log",
|
||||||
"moderation": "Moderation",
|
"moderation": "Moderation",
|
||||||
@@ -176,6 +177,10 @@
|
|||||||
"status_tooltip": "VRCX Companion Status"
|
"status_tooltip": "VRCX Companion Status"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"my_avatars": {
|
||||||
|
"filter": "Filter",
|
||||||
|
"clear_filters": "Clear All"
|
||||||
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"search_placeholder": "Search",
|
"search_placeholder": "Search",
|
||||||
"clear_results_tooltip": "Clear Search Results",
|
"clear_results_tooltip": "Clear Search Results",
|
||||||
@@ -195,6 +200,7 @@
|
|||||||
"refresh_tooltip": "Refresh own avatars",
|
"refresh_tooltip": "Refresh own avatars",
|
||||||
"result_count": "Results {count}",
|
"result_count": "Results {count}",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
"all_tags": "All Tags",
|
||||||
"public": "Public",
|
"public": "Public",
|
||||||
"private": "Private",
|
"private": "Private",
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
@@ -1294,7 +1300,10 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"delete_impostor": "Delete Impostor",
|
"delete_impostor": "Delete Impostor",
|
||||||
"regenerate_impostor": "Regenerate Impostor",
|
"regenerate_impostor": "Regenerate Impostor",
|
||||||
"create_impostor": "Create Impostor"
|
"create_impostor": "Create Impostor",
|
||||||
|
"manage_tags": "Manage Tags",
|
||||||
|
"manage_tags_placeholder": "Add a tag...",
|
||||||
|
"manage_tags_hint": "Click a tag to change its color"
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
"header": "Info",
|
"header": "Info",
|
||||||
@@ -1306,7 +1315,11 @@
|
|||||||
"last_updated": "Last Updated",
|
"last_updated": "Last Updated",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"platform": "Platform",
|
"platform": "Platform",
|
||||||
|
"pc_performance": "PC Performance",
|
||||||
|
"android_performance": "Android Performance",
|
||||||
|
"ios_performance": "iOS Performance",
|
||||||
"time_spent": "Time Spent",
|
"time_spent": "Time Spent",
|
||||||
|
"tags": "Tags",
|
||||||
"memo": "Memo",
|
"memo": "Memo",
|
||||||
"memo_placeholder": "Click to add a memo",
|
"memo_placeholder": "Click to add a memo",
|
||||||
"listings": "Listings",
|
"listings": "Listings",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import GameLog from './../views/GameLog/GameLog.vue';
|
|||||||
import Login from './../views/Login/Login.vue';
|
import Login from './../views/Login/Login.vue';
|
||||||
import MainLayout from '../views/Layout/MainLayout.vue';
|
import MainLayout from '../views/Layout/MainLayout.vue';
|
||||||
import Moderation from './../views/Moderation/Moderation.vue';
|
import Moderation from './../views/Moderation/Moderation.vue';
|
||||||
|
import MyAvatars from './../views/MyAvatars/MyAvatars.vue';
|
||||||
import Notification from './../views/Notifications/Notification.vue';
|
import Notification from './../views/Notifications/Notification.vue';
|
||||||
import PlayerList from './../views/PlayerList/PlayerList.vue';
|
import PlayerList from './../views/PlayerList/PlayerList.vue';
|
||||||
import ScreenshotMetadata from './../views/Tools/ScreenshotMetadata.vue';
|
import ScreenshotMetadata from './../views/Tools/ScreenshotMetadata.vue';
|
||||||
@@ -68,6 +69,11 @@ const routes = [
|
|||||||
name: 'moderation',
|
name: 'moderation',
|
||||||
component: Moderation
|
component: Moderation
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'my-avatars',
|
||||||
|
name: 'my-avatars',
|
||||||
|
component: MyAvatars
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'notification',
|
path: 'notification',
|
||||||
name: 'notification',
|
name: 'notification',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { avatarFavorites } from './database/avatarFavorites.js';
|
import { avatarFavorites } from './database/avatarFavorites.js';
|
||||||
|
import { avatarTags } from './database/avatarTags.js';
|
||||||
import { feed } from './database/feed.js';
|
import { feed } from './database/feed.js';
|
||||||
import { friendFavorites } from './database/friendFavorites.js';
|
import { friendFavorites } from './database/friendFavorites.js';
|
||||||
import { friendLogCurrent } from './database/friendLogCurrent.js';
|
import { friendLogCurrent } from './database/friendLogCurrent.js';
|
||||||
@@ -31,6 +32,7 @@ const database = {
|
|||||||
...friendLogCurrent,
|
...friendLogCurrent,
|
||||||
...memos,
|
...memos,
|
||||||
...avatarFavorites,
|
...avatarFavorites,
|
||||||
|
...avatarTags,
|
||||||
...friendFavorites,
|
...friendFavorites,
|
||||||
...worldFavorites,
|
...worldFavorites,
|
||||||
...tableAlter,
|
...tableAlter,
|
||||||
@@ -143,6 +145,9 @@ const database = {
|
|||||||
await sqliteService.executeNonQuery(
|
await sqliteService.executeNonQuery(
|
||||||
`CREATE TABLE IF NOT EXISTS avatar_memos (avatar_id TEXT PRIMARY KEY, edited_at TEXT, memo TEXT)`
|
`CREATE TABLE IF NOT EXISTS avatar_memos (avatar_id TEXT PRIMARY KEY, edited_at TEXT, memo TEXT)`
|
||||||
);
|
);
|
||||||
|
await sqliteService.executeNonQuery(
|
||||||
|
`CREATE TABLE IF NOT EXISTS avatar_tags (avatar_id TEXT NOT NULL, tag TEXT NOT NULL, color TEXT, PRIMARY KEY (avatar_id, tag))`
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
begin() {
|
begin() {
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import sqliteService from '../sqlite.js';
|
||||||
|
|
||||||
|
const avatarTags = {
|
||||||
|
async getAvatarTags(avatarId) {
|
||||||
|
const tags = [];
|
||||||
|
await sqliteService.execute(
|
||||||
|
(dbRow) => {
|
||||||
|
tags.push({ tag: dbRow[0], color: dbRow[1] || null });
|
||||||
|
},
|
||||||
|
`SELECT tag, color FROM avatar_tags WHERE avatar_id = @avatar_id`,
|
||||||
|
{
|
||||||
|
'@avatar_id': avatarId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return tags;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllAvatarTags() {
|
||||||
|
const map = new Map();
|
||||||
|
await sqliteService.execute((dbRow) => {
|
||||||
|
const avatarId = dbRow[0];
|
||||||
|
const tag = dbRow[1];
|
||||||
|
const color = dbRow[2] || null;
|
||||||
|
if (!map.has(avatarId)) {
|
||||||
|
map.set(avatarId, []);
|
||||||
|
}
|
||||||
|
map.get(avatarId).push({ tag, color });
|
||||||
|
}, `SELECT avatar_id, tag, color FROM avatar_tags`);
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAllDistinctTags() {
|
||||||
|
const tags = [];
|
||||||
|
await sqliteService.execute((dbRow) => {
|
||||||
|
tags.push(dbRow[0]);
|
||||||
|
}, `SELECT DISTINCT tag FROM avatar_tags ORDER BY tag`);
|
||||||
|
return tags;
|
||||||
|
},
|
||||||
|
|
||||||
|
async addAvatarTag(avatarId, tag, color = null) {
|
||||||
|
await sqliteService.executeNonQuery(
|
||||||
|
`INSERT OR IGNORE INTO avatar_tags (avatar_id, tag, color) VALUES (@avatar_id, @tag, @color)`,
|
||||||
|
{
|
||||||
|
'@avatar_id': avatarId,
|
||||||
|
'@tag': tag,
|
||||||
|
'@color': color
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAvatarTagColor(avatarId, tag, color) {
|
||||||
|
await sqliteService.executeNonQuery(
|
||||||
|
`UPDATE avatar_tags SET color = @color WHERE avatar_id = @avatar_id AND tag = @tag`,
|
||||||
|
{
|
||||||
|
'@avatar_id': avatarId,
|
||||||
|
'@tag': tag,
|
||||||
|
'@color': color
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeAvatarTag(avatarId, tag) {
|
||||||
|
await sqliteService.executeNonQuery(
|
||||||
|
`DELETE FROM avatar_tags WHERE avatar_id = @avatar_id AND tag = @tag`,
|
||||||
|
{
|
||||||
|
'@avatar_id': avatarId,
|
||||||
|
'@tag': tag
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeAllAvatarTags(avatarId) {
|
||||||
|
await sqliteService.executeNonQuery(
|
||||||
|
`DELETE FROM avatar_tags WHERE avatar_id = @avatar_id`,
|
||||||
|
{
|
||||||
|
'@avatar_id': avatarId
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { avatarTags };
|
||||||
@@ -12,3 +12,4 @@ export * from './fonts';
|
|||||||
export * from './link';
|
export * from './link';
|
||||||
export * from './ui';
|
export * from './ui';
|
||||||
export * from './accessType';
|
export * from './accessType';
|
||||||
|
export * from './tags';
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Predefined color palette for user-defined avatar tags.
|
||||||
|
* Colors are derived from the app's theme primary colors (oklch).
|
||||||
|
* - `bg`: low-opacity background for badge display
|
||||||
|
* - `text`: foreground text color for readability
|
||||||
|
*/
|
||||||
|
export const TAG_COLORS = Object.freeze([
|
||||||
|
{
|
||||||
|
name: 'default',
|
||||||
|
label: 'Default',
|
||||||
|
bg: 'oklch(0.4 0 0 / 0.2)',
|
||||||
|
text: 'oklch(0.7 0 0)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'blue',
|
||||||
|
label: 'Blue',
|
||||||
|
bg: 'oklch(0.488 0.243 264 / 0.2)',
|
||||||
|
text: 'oklch(0.65 0.2 264)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'green',
|
||||||
|
label: 'Green',
|
||||||
|
bg: 'oklch(0.648 0.2 132 / 0.2)',
|
||||||
|
text: 'oklch(0.7 0.18 132)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'orange',
|
||||||
|
label: 'Orange',
|
||||||
|
bg: 'oklch(0.646 0.222 41 / 0.2)',
|
||||||
|
text: 'oklch(0.72 0.19 41)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'red',
|
||||||
|
label: 'Red',
|
||||||
|
bg: 'oklch(0.577 0.245 27 / 0.2)',
|
||||||
|
text: 'oklch(0.68 0.2 27)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rose',
|
||||||
|
label: 'Rose',
|
||||||
|
bg: 'oklch(0.586 0.253 18 / 0.2)',
|
||||||
|
text: 'oklch(0.7 0.2 18)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'violet',
|
||||||
|
label: 'Violet',
|
||||||
|
bg: 'oklch(0.541 0.281 293 / 0.2)',
|
||||||
|
text: 'oklch(0.68 0.22 293)'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'yellow',
|
||||||
|
label: 'Yellow',
|
||||||
|
bg: 'oklch(0.852 0.199 92 / 0.2)',
|
||||||
|
text: 'oklch(0.82 0.17 92)'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically map a tag name to a color from the palette.
|
||||||
|
* Uses djb2 hash so the same tag always gets the same color.
|
||||||
|
*/
|
||||||
|
export function getTagColor(tagName) {
|
||||||
|
let hash = 5381;
|
||||||
|
for (let i = 0; i < tagName.length; i++) {
|
||||||
|
hash = ((hash << 5) + hash + tagName.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
// skip index 0 (default gray), use indices 1..length-1
|
||||||
|
const index = (hash % (TAG_COLORS.length - 1)) + 1;
|
||||||
|
return TAG_COLORS[index];
|
||||||
|
}
|
||||||
@@ -83,6 +83,13 @@ const navDefinitions = [
|
|||||||
labelKey: 'nav_tooltip.notification',
|
labelKey: 'nav_tooltip.notification',
|
||||||
routeName: 'notification'
|
routeName: 'notification'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'my-avatars',
|
||||||
|
icon: 'ri-contacts-book-3-line',
|
||||||
|
tooltip: 'nav_tooltip.my_avatars',
|
||||||
|
labelKey: 'nav_tooltip.my_avatars',
|
||||||
|
routeName: 'my-avatars'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'charts-instance',
|
key: 'charts-instance',
|
||||||
icon: 'ri-bar-chart-horizontal-line',
|
icon: 'ri-bar-chart-horizontal-line',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { AppDebug } from '../../service/appConfig.js';
|
|||||||
import { extractFileId } from './index.js';
|
import { extractFileId } from './index.js';
|
||||||
import { imageRequest } from '../../api';
|
import { imageRequest } from '../../api';
|
||||||
|
|
||||||
const UPLOAD_TIMEOUT_MS = 20_000;
|
const UPLOAD_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export function withUploadTimeout(promise) {
|
export function withUploadTimeout(promise) {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<template>
|
||||||
|
<Dialog v-model:open="dialogOpen">
|
||||||
|
<DialogContent class="x-dialog sm:max-w-110">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ t('dialog.avatar.actions.manage_tags') }}</DialogTitle>
|
||||||
|
<DialogDescription>{{ avatarName }}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('dialog.avatar.actions.manage_tags_hint') }}</p>
|
||||||
|
<TagsInput v-model="tagNames" :delimiter="','" add-on-blur class="mt-1">
|
||||||
|
<template v-for="entry in tagEntries" :key="entry.tag">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<TagsInputItem
|
||||||
|
:value="entry.tag"
|
||||||
|
class="cursor-pointer"
|
||||||
|
:style="{
|
||||||
|
backgroundColor: resolveColor(entry).bg,
|
||||||
|
color: resolveColor(entry).text
|
||||||
|
}">
|
||||||
|
<TagsInputItemText />
|
||||||
|
<TagsInputItemDelete />
|
||||||
|
</TagsInputItem>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-2" side="top" :side-offset="8">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="color in TAG_COLORS"
|
||||||
|
:key="color.name"
|
||||||
|
type="button"
|
||||||
|
class="h-4 w-4 shrink-0 rounded-sm transition-transform hover:scale-125"
|
||||||
|
:class="
|
||||||
|
isColorSelected(entry, color)
|
||||||
|
? 'ring-1 ring-ring ring-offset-1 ring-offset-background'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
:style="{ backgroundColor: color.bg.replace('/ 0.2)', '/ 1)') }"
|
||||||
|
:title="color.label"
|
||||||
|
@click="setEntryColor(entry, color)" />
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<TagsInputInput :placeholder="t('dialog.avatar.actions.manage_tags_placeholder')" />
|
||||||
|
</TagsInput>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" class="mr-2" @click="dialogOpen = false">
|
||||||
|
{{ t('prompt.rename_avatar.cancel') }}
|
||||||
|
</Button>
|
||||||
|
<Button @click="save">
|
||||||
|
{{ t('prompt.rename_avatar.ok') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
TagsInput,
|
||||||
|
TagsInputInput,
|
||||||
|
TagsInputItem,
|
||||||
|
TagsInputItemDelete,
|
||||||
|
TagsInputItemText
|
||||||
|
} from '@/components/ui/tags-input';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { TAG_COLORS, getTagColor } from '@/shared/constants';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
open: { type: Boolean, default: false },
|
||||||
|
avatarName: { type: String, default: '' },
|
||||||
|
avatarId: { type: String, default: '' },
|
||||||
|
initialTags: { type: Array, default: () => [] }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:open', 'save']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const dialogOpen = computed({
|
||||||
|
get: () => props.open,
|
||||||
|
set: (val) => emit('update:open', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
const tagEntries = ref([]);
|
||||||
|
|
||||||
|
const tagNames = computed({
|
||||||
|
get: () => tagEntries.value.map((e) => e.tag),
|
||||||
|
set: (names) => {
|
||||||
|
const existingMap = Object.fromEntries(tagEntries.value.map((e) => [e.tag, e]));
|
||||||
|
tagEntries.value = names.map((name) => existingMap[name] || { tag: name, color: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen) {
|
||||||
|
tagEntries.value = props.initialTags.map((entry) => ({ ...entry }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function resolveColor(entry) {
|
||||||
|
if (entry.color) {
|
||||||
|
return {
|
||||||
|
bg: entry.color,
|
||||||
|
text: typeof entry.color === 'string' ? entry.color.replace(/\/ [\d.]+\)$/, ')') : entry.color
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return getTagColor(entry.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColorSelected(entry, color) {
|
||||||
|
if (entry.color) {
|
||||||
|
return entry.color === color.bg;
|
||||||
|
}
|
||||||
|
const hashColor = getTagColor(entry.tag);
|
||||||
|
return hashColor.name === color.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEntryColor(entry, color) {
|
||||||
|
const hashColor = getTagColor(entry.tag);
|
||||||
|
entry.color = hashColor.name === color.name ? null : color.bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit('save', {
|
||||||
|
avatarId: props.avatarId,
|
||||||
|
tags: tagEntries.value.map((e) => ({ tag: e.tag, color: e.color }))
|
||||||
|
});
|
||||||
|
dialogOpen.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
<template>
|
||||||
|
<div class="x-container" ref="containerRef">
|
||||||
|
<DataTableLayout
|
||||||
|
:table="table"
|
||||||
|
:table-style="tableHeightStyle"
|
||||||
|
:page-sizes="pageSizes"
|
||||||
|
:total-items="filteredAvatars.length"
|
||||||
|
:on-page-size-change="handlePageSizeChange"
|
||||||
|
:on-row-click="handleRowClick"
|
||||||
|
:row-class="getRowClass"
|
||||||
|
class="cursor-pointer">
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="mb-2.5 flex items-center gap-2">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="outline" size="sm" class="h-8 gap-1.5">
|
||||||
|
<ListFilter class="size-4" />
|
||||||
|
{{ t('view.my_avatars.filter') }}
|
||||||
|
<Badge
|
||||||
|
v-if="activeFilterCount"
|
||||||
|
variant="secondary"
|
||||||
|
class="ml-0.5 h-4.5 min-w-4.5 rounded-full px-1 text-xs">
|
||||||
|
{{ activeFilterCount }}
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-auto p-3" align="start">
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{{ t('dialog.avatar.tags.public') }}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
:model-value="releaseStatusFilter"
|
||||||
|
variant="outline"
|
||||||
|
@update:model-value="releaseStatusFilter = $event">
|
||||||
|
<ToggleGroupItem
|
||||||
|
v-for="opt in releaseStatusOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
class="px-2.5">
|
||||||
|
{{ opt.label }}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>{{ t('dialog.avatar.info.platform') }}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<ToggleGroup
|
||||||
|
type="single"
|
||||||
|
:model-value="platformFilter"
|
||||||
|
variant="outline"
|
||||||
|
@update:model-value="platformFilter = $event">
|
||||||
|
<ToggleGroupItem value="all" class="px-2.5">
|
||||||
|
{{ t('view.search.avatar.all') }}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
<ToggleGroupItem
|
||||||
|
v-for="plat in platformOptions"
|
||||||
|
:key="plat.value"
|
||||||
|
:value="plat.value"
|
||||||
|
class="px-2.5">
|
||||||
|
{{ plat.label }}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
</ToggleGroup>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field v-if="allTags.length">
|
||||||
|
<FieldLabel>{{ t('dialog.avatar.info.tags') }}</FieldLabel>
|
||||||
|
<FieldContent>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<Badge
|
||||||
|
v-for="tag in allTags"
|
||||||
|
:key="tag"
|
||||||
|
:variant="tagFilters.has(tag) ? 'default' : 'outline'"
|
||||||
|
class="cursor-pointer select-none"
|
||||||
|
:style="
|
||||||
|
tagFilters.has(tag)
|
||||||
|
? {
|
||||||
|
backgroundColor: getTagColor(tag).bg,
|
||||||
|
color: getTagColor(tag).text
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
borderColor: getTagColor(tag).bg,
|
||||||
|
color: getTagColor(tag).text
|
||||||
|
}
|
||||||
|
"
|
||||||
|
@click="toggleTagFilter(tag)">
|
||||||
|
{{ tag }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="activeFilterCount"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
class="w-full"
|
||||||
|
@click="clearFilters">
|
||||||
|
{{ t('view.my_avatars.clear_filters') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div class="flex-1" />
|
||||||
|
|
||||||
|
<span v-if="isLoading" class="text-muted-foreground text-sm">
|
||||||
|
{{ t('view.friends_locations.loading_more') }}
|
||||||
|
</span>
|
||||||
|
<Input v-model="searchText" :placeholder="t('view.search.search_placeholder')" class="h-8 w-80" />
|
||||||
|
<Button size="icon-sm" variant="ghost" :disabled="isLoading" @click="refreshAvatars">
|
||||||
|
<RefreshCw :class="{ 'animate-spin': isLoading }" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #row-context-menu="{ row }">
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('manageTags', row.original)">
|
||||||
|
<Tag class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.manage_tags') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuSeparator />
|
||||||
|
<ContextMenuItem
|
||||||
|
@click="
|
||||||
|
handleContextMenuAction(
|
||||||
|
row.original.releaseStatus === 'public' ? 'makePrivate' : 'makePublic',
|
||||||
|
row.original
|
||||||
|
)
|
||||||
|
">
|
||||||
|
<User class="size-4" />
|
||||||
|
{{
|
||||||
|
row.original.releaseStatus === 'public'
|
||||||
|
? t('dialog.avatar.actions.make_private')
|
||||||
|
: t('dialog.avatar.actions.make_public')
|
||||||
|
}}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('rename', row.original)">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.rename') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('changeDescription', row.original)">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.change_description') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('changeTags', row.original)">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.change_content_tags') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('changeStyles', row.original)">
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.change_styles_author_tags') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('changeImage', row.original)">
|
||||||
|
<ImageIcon class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.change_image') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem @click="handleContextMenuAction('createImpostor', row.original)">
|
||||||
|
<RefreshCw class="size-4" />
|
||||||
|
{{ t('dialog.avatar.actions.create_impostor') }}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</template>
|
||||||
|
</DataTableLayout>
|
||||||
|
<input
|
||||||
|
ref="imageUploadInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style="display: none"
|
||||||
|
@change="onFileChangeAvatarImage" />
|
||||||
|
<ImageCropDialog
|
||||||
|
:open="cropDialogOpen"
|
||||||
|
:title="t('dialog.change_content_image.avatar')"
|
||||||
|
:aspect-ratio="4 / 3"
|
||||||
|
:file="cropDialogFile"
|
||||||
|
@update:open="cropDialogOpen = $event"
|
||||||
|
@confirm="onCropConfirmAvatar" />
|
||||||
|
<ManageTagsDialog
|
||||||
|
v-model:open="manageTagsOpen"
|
||||||
|
:avatar-name="manageTagsAvatar?.name || ''"
|
||||||
|
:avatar-id="manageTagsAvatar?.id || ''"
|
||||||
|
:initial-tags="manageTagsAvatar?.$tags || []"
|
||||||
|
@save="onSaveTags" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Image as ImageIcon, ListFilter, Pencil, RefreshCw, Tag, User } from 'lucide-vue-next';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { storeToRefs } from 'pinia';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import {
|
||||||
|
handleImageUploadInput,
|
||||||
|
readFileAsBase64,
|
||||||
|
resizeImageToFitLimits,
|
||||||
|
uploadImageLegacy,
|
||||||
|
withUploadTimeout
|
||||||
|
} from '../../shared/utils/imageUpload';
|
||||||
|
import { useAppearanceSettingsStore, useAvatarStore, useModalStore, useUserStore } from '../../stores';
|
||||||
|
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from '../../components/ui/context-menu';
|
||||||
|
import { Field, FieldContent, FieldLabel } from '../../components/ui/field';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '../../components/ui/popover';
|
||||||
|
import { ToggleGroup, ToggleGroupItem } from '../../components/ui/toggle-group';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { DataTableLayout } from '../../components/ui/data-table';
|
||||||
|
import { Input } from '../../components/ui/input';
|
||||||
|
import { avatarRequest } from '../../api';
|
||||||
|
import { database } from '../../service/database';
|
||||||
|
import { getColumns } from './columns';
|
||||||
|
import { getPlatformInfo } from '../../shared/utils/avatar';
|
||||||
|
import { getTagColor } from '../../shared/constants';
|
||||||
|
import { processBulk } from '../../service/request';
|
||||||
|
import { useDataTableScrollHeight } from '../../composables/useDataTableScrollHeight';
|
||||||
|
import { useVrcxVueTable } from '../../lib/table/useVrcxVueTable';
|
||||||
|
|
||||||
|
import ImageCropDialog from '../../components/dialogs/ImageCropDialog.vue';
|
||||||
|
import ManageTagsDialog from './ManageTagsDialog.vue';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const appearanceSettingsStore = useAppearanceSettingsStore();
|
||||||
|
const avatarStore = useAvatarStore();
|
||||||
|
const modalStore = useModalStore();
|
||||||
|
const { showAvatarDialog, selectAvatarWithoutConfirmation, applyAvatar } = avatarStore;
|
||||||
|
const { currentUser } = storeToRefs(useUserStore());
|
||||||
|
|
||||||
|
const pageSizes = computed(() => appearanceSettingsStore.tablePageSizes);
|
||||||
|
const pageSize = computed(() => appearanceSettingsStore.tablePageSize);
|
||||||
|
|
||||||
|
const containerRef = ref(null);
|
||||||
|
const searchText = ref('');
|
||||||
|
const releaseStatusFilter = ref('all');
|
||||||
|
const tagFilters = ref(new Set());
|
||||||
|
const platformFilter = ref('all');
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const avatars = ref([]);
|
||||||
|
const avatarTagsMap = ref(new Map());
|
||||||
|
const imageUploadInput = ref(null);
|
||||||
|
const cropDialogOpen = ref(false);
|
||||||
|
const cropDialogFile = ref(null);
|
||||||
|
const changeImageAvatarRef = ref(null);
|
||||||
|
const manageTagsOpen = ref(false);
|
||||||
|
const manageTagsAvatar = ref(null);
|
||||||
|
|
||||||
|
const { tableStyle: tableHeightStyle } = useDataTableScrollHeight(containerRef, {
|
||||||
|
offset: 30,
|
||||||
|
toolbarHeight: 54,
|
||||||
|
paginationHeight: 52
|
||||||
|
});
|
||||||
|
|
||||||
|
const allTags = computed(() => {
|
||||||
|
const tagSet = new Set();
|
||||||
|
for (const tags of avatarTagsMap.value.values()) {
|
||||||
|
for (const entry of tags) {
|
||||||
|
tagSet.add(entry.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(tagSet).sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const releaseStatusOptions = [
|
||||||
|
{ value: 'all', label: t('view.search.avatar.all') },
|
||||||
|
{ value: 'public', label: t('view.search.avatar.public') },
|
||||||
|
{ value: 'private', label: t('view.search.avatar.private') }
|
||||||
|
];
|
||||||
|
|
||||||
|
const platformOptions = [
|
||||||
|
{ value: 'pc', label: 'PC' },
|
||||||
|
{ value: 'android', label: 'Android' },
|
||||||
|
{ value: 'ios', label: 'iOS' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let count = 0;
|
||||||
|
if (releaseStatusFilter.value !== 'all') count++;
|
||||||
|
count += tagFilters.value.size;
|
||||||
|
if (platformFilter.value !== 'all') count++;
|
||||||
|
return count;
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleTagFilter(tag) {
|
||||||
|
const next = new Set(tagFilters.value);
|
||||||
|
if (next.has(tag)) next.delete(tag);
|
||||||
|
else next.add(tag);
|
||||||
|
tagFilters.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
releaseStatusFilter.value = 'all';
|
||||||
|
tagFilters.value = new Set();
|
||||||
|
platformFilter.value = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredAvatars = computed(() => {
|
||||||
|
let list = avatars.value;
|
||||||
|
|
||||||
|
if (releaseStatusFilter.value === 'public') {
|
||||||
|
list = list.filter((a) => a.releaseStatus === 'public');
|
||||||
|
} else if (releaseStatusFilter.value === 'private') {
|
||||||
|
list = list.filter((a) => a.releaseStatus === 'private');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagFilters.value.size) {
|
||||||
|
list = list.filter((a) => a.$tags?.some((t) => tagFilters.value.has(t.tag)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (platformFilter.value !== 'all') {
|
||||||
|
list = list.filter((a) => {
|
||||||
|
const info = getPlatformInfo(a.unityPackages);
|
||||||
|
return !!info[platformFilter.value]?.performanceRating;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// search filter
|
||||||
|
if (searchText.value) {
|
||||||
|
const query = searchText.value.toLowerCase();
|
||||||
|
list = list.filter((a) => a.name?.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleShowAvatarDialog(avatarId) {
|
||||||
|
showAvatarDialog(avatarId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWearAvatar(avatarId) {
|
||||||
|
if (currentUser.value.currentAvatar === avatarId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectAvatarWithoutConfirmation(avatarId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAndRun(command, labelKey, fn) {
|
||||||
|
modalStore
|
||||||
|
.confirm({
|
||||||
|
title: t('confirm.title'),
|
||||||
|
description: t('confirm.command_question', {
|
||||||
|
command: t(labelKey)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(({ ok }) => {
|
||||||
|
if (ok) fn();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContextMenuAction(action, avatarRef) {
|
||||||
|
switch (action) {
|
||||||
|
case 'details':
|
||||||
|
showAvatarDialog(avatarRef.id);
|
||||||
|
break;
|
||||||
|
case 'wear':
|
||||||
|
handleWearAvatar(avatarRef.id);
|
||||||
|
break;
|
||||||
|
case 'makePrivate':
|
||||||
|
confirmAndRun(action, 'dialog.avatar.actions.make_private', () => {
|
||||||
|
avatarRequest.saveAvatar({ id: avatarRef.id, releaseStatus: 'private' }).then((args) => {
|
||||||
|
applyAvatar(args.json);
|
||||||
|
toast.success(t('message.avatar.updated_private'));
|
||||||
|
refreshAvatars();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'makePublic':
|
||||||
|
confirmAndRun(action, 'dialog.avatar.actions.make_public', () => {
|
||||||
|
avatarRequest.saveAvatar({ id: avatarRef.id, releaseStatus: 'public' }).then((args) => {
|
||||||
|
applyAvatar(args.json);
|
||||||
|
toast.success(t('message.avatar.updated_public'));
|
||||||
|
refreshAvatars();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'rename':
|
||||||
|
modalStore
|
||||||
|
.prompt({
|
||||||
|
title: t('prompt.rename_avatar.header'),
|
||||||
|
description: t('prompt.rename_avatar.description'),
|
||||||
|
confirmText: t('prompt.rename_avatar.ok'),
|
||||||
|
cancelText: t('prompt.rename_avatar.cancel'),
|
||||||
|
inputValue: avatarRef.name,
|
||||||
|
errorMessage: t('prompt.rename_avatar.input_error')
|
||||||
|
})
|
||||||
|
.then(({ ok, value }) => {
|
||||||
|
if (!ok) return;
|
||||||
|
if (value && value !== avatarRef.name) {
|
||||||
|
avatarRequest.saveAvatar({ id: avatarRef.id, name: value }).then((args) => {
|
||||||
|
applyAvatar(args.json);
|
||||||
|
toast.success(t('prompt.rename_avatar.message.success'));
|
||||||
|
refreshAvatars();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
break;
|
||||||
|
case 'changeDescription':
|
||||||
|
modalStore
|
||||||
|
.prompt({
|
||||||
|
title: t('prompt.change_avatar_description.header'),
|
||||||
|
description: t('prompt.change_avatar_description.description'),
|
||||||
|
confirmText: t('prompt.change_avatar_description.ok'),
|
||||||
|
cancelText: t('prompt.change_avatar_description.cancel'),
|
||||||
|
inputValue: avatarRef.description,
|
||||||
|
errorMessage: t('prompt.change_avatar_description.input_error')
|
||||||
|
})
|
||||||
|
.then(({ ok, value }) => {
|
||||||
|
if (!ok) return;
|
||||||
|
if (value && value !== avatarRef.description) {
|
||||||
|
avatarRequest.saveAvatar({ id: avatarRef.id, description: value }).then((args) => {
|
||||||
|
applyAvatar(args.json);
|
||||||
|
toast.success(t('prompt.change_avatar_description.message.success'));
|
||||||
|
refreshAvatars();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
break;
|
||||||
|
case 'createImpostor':
|
||||||
|
confirmAndRun(action, 'dialog.avatar.actions.create_impostor', () => {
|
||||||
|
avatarRequest.createImposter({ avatarId: avatarRef.id }).then(() => {
|
||||||
|
toast.success(t('message.avatar.impostor_queued'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'changeImage':
|
||||||
|
changeImageAvatarRef.value = avatarRef;
|
||||||
|
imageUploadInput.value?.click();
|
||||||
|
break;
|
||||||
|
case 'manageTags':
|
||||||
|
manageTagsAvatar.value = avatarRef;
|
||||||
|
manageTagsOpen.value = true;
|
||||||
|
break;
|
||||||
|
case 'changeTags':
|
||||||
|
case 'changeStyles':
|
||||||
|
showAvatarDialog(avatarRef.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveTags({ avatarId, tags: newEntries }) {
|
||||||
|
const avatar = avatars.value.find((a) => a.id === avatarId);
|
||||||
|
const oldEntries = avatar?.$tags || [];
|
||||||
|
const oldMap = new Map(oldEntries.map((e) => [e.tag, e]));
|
||||||
|
const newMap = new Map(newEntries.map((e) => [e.tag, e]));
|
||||||
|
|
||||||
|
for (const [name] of oldMap) {
|
||||||
|
if (!newMap.has(name)) {
|
||||||
|
await database.removeAvatarTag(avatarId, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, entry] of newMap) {
|
||||||
|
const old = oldMap.get(name);
|
||||||
|
if (!old) {
|
||||||
|
await database.addAvatarTag(avatarId, name, entry.color);
|
||||||
|
} else if (old.color !== entry.color) {
|
||||||
|
await database.updateAvatarTagColor(avatarId, name, entry.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avatar) {
|
||||||
|
avatar.$tags = newEntries;
|
||||||
|
}
|
||||||
|
avatarTagsMap.value.set(avatarId, newEntries);
|
||||||
|
avatarTagsMap.value = new Map(avatarTagsMap.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChangeAvatarImage(e) {
|
||||||
|
const { file, clearInput } = handleImageUploadInput(e, {
|
||||||
|
inputSelector: imageUploadInput.value,
|
||||||
|
tooLargeMessage: () => t('message.file.too_large'),
|
||||||
|
invalidTypeMessage: () => t('message.file.not_image')
|
||||||
|
});
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearInput();
|
||||||
|
cropDialogFile.value = file;
|
||||||
|
cropDialogOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCropConfirmAvatar(blob) {
|
||||||
|
const avatarRef = changeImageAvatarRef.value;
|
||||||
|
if (!avatarRef) return;
|
||||||
|
try {
|
||||||
|
await withUploadTimeout(
|
||||||
|
(async () => {
|
||||||
|
const base64Body = await readFileAsBase64(blob);
|
||||||
|
const base64File = await resizeImageToFitLimits(base64Body);
|
||||||
|
if (typeof LINUX !== 'undefined' && LINUX) {
|
||||||
|
const args = await avatarRequest.uploadAvatarImage(base64File);
|
||||||
|
const fileUrl = args.json.versions[args.json.versions.length - 1].file.url;
|
||||||
|
await avatarRequest.saveAvatar({
|
||||||
|
id: avatarRef.id,
|
||||||
|
imageUrl: fileUrl
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await uploadImageLegacy('avatar', {
|
||||||
|
entityId: avatarRef.id,
|
||||||
|
imageUrl: avatarRef.imageUrl,
|
||||||
|
base64File,
|
||||||
|
blob
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
toast.success(t('message.upload.success'));
|
||||||
|
refreshAvatars();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('avatar image upload process failed:', error);
|
||||||
|
toast.error(t('message.upload.error'));
|
||||||
|
} finally {
|
||||||
|
cropDialogOpen.value = false;
|
||||||
|
changeImageAvatarRef.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentAvatarId = computed(() => currentUser.value?.currentAvatar);
|
||||||
|
|
||||||
|
const columns = getColumns({
|
||||||
|
onShowAvatarDialog: handleShowAvatarDialog,
|
||||||
|
onContextMenuAction: handleContextMenuAction,
|
||||||
|
currentAvatarId
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleRowClick(row) {
|
||||||
|
handleWearAvatar(row.original.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRowClass(row) {
|
||||||
|
if (row.original.id === currentAvatarId.value) {
|
||||||
|
return 'bg-primary/10 hover:bg-primary/15';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const { table, pagination } = useVrcxVueTable({
|
||||||
|
get data() {
|
||||||
|
return filteredAvatars.value;
|
||||||
|
},
|
||||||
|
persistKey: 'my-avatars',
|
||||||
|
columns,
|
||||||
|
initialSorting: [{ id: 'updated_at', desc: true }],
|
||||||
|
initialPagination: {
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: pageSize.value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePageSizeChange = (size) => {
|
||||||
|
appearanceSettingsStore.setTablePageSize(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(pageSize, (size) => {
|
||||||
|
if (pagination.value.pageSize === size) return;
|
||||||
|
pagination.value = { ...pagination.value, pageIndex: 0, pageSize: size };
|
||||||
|
table.setPageSize(size);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshAvatars() {
|
||||||
|
if (isLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
const params = {
|
||||||
|
n: 50,
|
||||||
|
offset: 0,
|
||||||
|
sort: 'updated',
|
||||||
|
order: 'descending',
|
||||||
|
releaseStatus: 'all',
|
||||||
|
user: 'me'
|
||||||
|
};
|
||||||
|
|
||||||
|
await processBulk({
|
||||||
|
fn: avatarRequest.getAvatars,
|
||||||
|
N: -1,
|
||||||
|
params,
|
||||||
|
handle: (args) => {
|
||||||
|
for (const json of args.json) {
|
||||||
|
const ref = applyAvatar(json);
|
||||||
|
map.set(ref.id, ref);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
done: async () => {
|
||||||
|
const list = Array.from(map.values());
|
||||||
|
const currentAvatarId = currentUser.value.currentAvatar;
|
||||||
|
const swapTime = currentUser.value.$previousAvatarSwapTime;
|
||||||
|
const tagsMap = await database.getAllAvatarTags();
|
||||||
|
avatarTagsMap.value = tagsMap;
|
||||||
|
await Promise.all(
|
||||||
|
list.map(async (ref) => {
|
||||||
|
const aviTime = await database.getAvatarTimeSpent(ref.id);
|
||||||
|
ref.$timeSpent = aviTime.timeSpent;
|
||||||
|
if (ref.id === currentAvatarId && swapTime) {
|
||||||
|
ref.$timeSpent += Date.now() - swapTime;
|
||||||
|
}
|
||||||
|
ref.$tags = tagsMap.get(ref.id) || [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
avatars.value = list;
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshAvatars();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
import { getTagColor } from '../../shared/constants';
|
||||||
|
import { Badge } from '../../components/ui/badge';
|
||||||
|
import { Button } from '../../components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '../../components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Apple,
|
||||||
|
ArrowUpDown,
|
||||||
|
Check,
|
||||||
|
Ellipsis,
|
||||||
|
Image,
|
||||||
|
Monitor,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
Smartphone,
|
||||||
|
Tag,
|
||||||
|
User
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import {
|
||||||
|
formatDateFilter,
|
||||||
|
getAvailablePlatforms,
|
||||||
|
getPlatformInfo,
|
||||||
|
timeToText
|
||||||
|
} from '../../shared/utils';
|
||||||
|
import { i18n } from '../../plugin';
|
||||||
|
|
||||||
|
const { t } = i18n.global;
|
||||||
|
|
||||||
|
const sortButton = ({ column, label, descFirst = false }) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="-ml-2 h-8 px-2"
|
||||||
|
onClick={() => {
|
||||||
|
const sorted = column.getIsSorted();
|
||||||
|
if (!sorted && descFirst) {
|
||||||
|
column.toggleSorting(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
column.toggleSorting(sorted === 'asc');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<ArrowUpDown class="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getColumns({
|
||||||
|
onShowAvatarDialog,
|
||||||
|
onContextMenuAction,
|
||||||
|
currentAvatarId
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
header: () => null,
|
||||||
|
size: 40,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
const isActive = ref.id === currentAvatarId.value;
|
||||||
|
return (
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<Check
|
||||||
|
class={[
|
||||||
|
'h-4 w-4',
|
||||||
|
isActive
|
||||||
|
? 'text-primary'
|
||||||
|
: 'text-muted-foreground/0 group-hover/row:text-muted-foreground/40'
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'index',
|
||||||
|
accessorFn: (_row, index) => index + 1,
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({ column, label: 'No.', descFirst: true }),
|
||||||
|
size: 60,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: {
|
||||||
|
class: 'text-right'
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span class="text-muted-foreground">{row.index + 1}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'thumbnail',
|
||||||
|
header: () => null,
|
||||||
|
size: 64,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={ref.thumbnailImageUrl}
|
||||||
|
class="cursor-pointer rounded-sm object-cover"
|
||||||
|
style="width: 36px; height: 24px;"
|
||||||
|
loading="lazy"
|
||||||
|
onClick={() => onShowAvatarDialog(ref.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: ({ column }) => sortButton({ column, label: 'Name' }),
|
||||||
|
size: 200,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
class="cursor-pointer font-medium"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShowAvatarDialog(ref.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ref.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'platforms',
|
||||||
|
header: () => 'Platforms',
|
||||||
|
size: 120,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
const platforms = getAvailablePlatforms(ref.unityPackages);
|
||||||
|
return (
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{platforms.isPC && (
|
||||||
|
<Badge class="x-tag-platform-pc" variant="outline">
|
||||||
|
<Monitor class="h-3.5 w-3.5" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{platforms.isQuest && (
|
||||||
|
<Badge
|
||||||
|
class="x-tag-platform-quest"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Smartphone class="h-3.5 w-3.5" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{platforms.isIos && (
|
||||||
|
<Badge
|
||||||
|
class="text-[#8e8e93] border-[#8e8e93]"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Apple class="h-3.5 w-3.5" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customTags',
|
||||||
|
accessorFn: (row) => (row.$tags || []).map((t) => t.tag).join(', '),
|
||||||
|
header: () => t('dialog.avatar.info.tags'),
|
||||||
|
size: 150,
|
||||||
|
enableSorting: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const tags = row.original.$tags || [];
|
||||||
|
if (!tags.length) return null;
|
||||||
|
return (
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{tags.map((entry) => {
|
||||||
|
const hashColor = getTagColor(entry.tag);
|
||||||
|
const storedColor =
|
||||||
|
typeof entry.color === 'string'
|
||||||
|
? entry.color
|
||||||
|
: null;
|
||||||
|
const bg = storedColor || hashColor.bg;
|
||||||
|
const text = storedColor
|
||||||
|
? storedColor.replace(/\/ [\d.]+\)$/, ')')
|
||||||
|
: hashColor.text;
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={entry.tag}
|
||||||
|
variant="secondary"
|
||||||
|
class="text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: bg,
|
||||||
|
color: text
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.tag}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'releaseStatus',
|
||||||
|
accessorKey: 'releaseStatus',
|
||||||
|
header: () => t('dialog.avatar.tags.public'),
|
||||||
|
size: 120,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
return (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{ref.releaseStatus === 'public'
|
||||||
|
? t('dialog.avatar.tags.public')
|
||||||
|
: t('dialog.avatar.tags.private')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'timeSpent',
|
||||||
|
accessorFn: (row) => row?.$timeSpent ?? 0,
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.time_spent'),
|
||||||
|
descFirst: true
|
||||||
|
}),
|
||||||
|
size: 140,
|
||||||
|
meta: {
|
||||||
|
class: 'text-right'
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const time = row.original?.$timeSpent;
|
||||||
|
return time ? (
|
||||||
|
<span class=" text-sm">{timeToText(time)}</span>
|
||||||
|
) : (
|
||||||
|
<span class=" text-sm">-</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version',
|
||||||
|
accessorKey: 'version',
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.version'),
|
||||||
|
descFirst: true
|
||||||
|
}),
|
||||||
|
size: 90,
|
||||||
|
meta: {
|
||||||
|
class: 'text-right'
|
||||||
|
},
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span class=" text-sm">{row.original.version ?? '-'}</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pcPerf',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
getPlatformInfo(row.unityPackages)?.pc?.performanceRating ?? '',
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.pc_performance')
|
||||||
|
}),
|
||||||
|
size: 140,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const perf = getPlatformInfo(row.original.unityPackages)?.pc
|
||||||
|
?.performanceRating;
|
||||||
|
return perf ? (
|
||||||
|
<span class="text-sm">{perf}</span>
|
||||||
|
) : (
|
||||||
|
<span class="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'androidPerf',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
getPlatformInfo(row.unityPackages)?.android
|
||||||
|
?.performanceRating ?? '',
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.android_performance')
|
||||||
|
}),
|
||||||
|
size: 140,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const perf = getPlatformInfo(row.original.unityPackages)
|
||||||
|
?.android?.performanceRating;
|
||||||
|
return perf ? (
|
||||||
|
<span class="text-sm">{perf}</span>
|
||||||
|
) : (
|
||||||
|
<span class="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'iosPerf',
|
||||||
|
accessorFn: (row) =>
|
||||||
|
getPlatformInfo(row.unityPackages)?.ios?.performanceRating ??
|
||||||
|
'',
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.ios_performance')
|
||||||
|
}),
|
||||||
|
size: 140,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const perf = getPlatformInfo(row.original.unityPackages)?.ios
|
||||||
|
?.performanceRating;
|
||||||
|
return perf ? (
|
||||||
|
<span class="text-sm">{perf}</span>
|
||||||
|
) : (
|
||||||
|
<span class="text-muted-foreground">-</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updated_at',
|
||||||
|
accessorKey: 'updated_at',
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.last_updated'),
|
||||||
|
descFirst: true
|
||||||
|
}),
|
||||||
|
size: 160,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
return (
|
||||||
|
<span class=" text-sm">
|
||||||
|
{formatDateFilter(ref.updated_at, 'long')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'created_at',
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: ({ column }) =>
|
||||||
|
sortButton({
|
||||||
|
column,
|
||||||
|
label: t('dialog.avatar.info.created_at'),
|
||||||
|
descFirst: true
|
||||||
|
}),
|
||||||
|
size: 160,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
return (
|
||||||
|
<span class=" text-sm">
|
||||||
|
{formatDateFilter(ref.created_at, 'long')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: () => null,
|
||||||
|
size: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const ref = row.original;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
class="rounded-full"
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<Ellipsis class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction('manageTags', ref)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tag class="size-4" />
|
||||||
|
{t('dialog.avatar.actions.manage_tags')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{ref.releaseStatus === 'public' ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction(
|
||||||
|
'makePrivate',
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<User class="size-4" />
|
||||||
|
{t(
|
||||||
|
'dialog.avatar.actions.make_private'
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction(
|
||||||
|
'makePublic',
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<User class="size-4" />
|
||||||
|
{t('dialog.avatar.actions.make_public')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction('rename', ref)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{t('dialog.avatar.actions.rename')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction(
|
||||||
|
'changeDescription',
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{t(
|
||||||
|
'dialog.avatar.actions.change_description'
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction('changeTags', ref)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{t(
|
||||||
|
'dialog.avatar.actions.change_content_tags'
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction('changeStyles', ref)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil class="size-4" />
|
||||||
|
{t(
|
||||||
|
'dialog.avatar.actions.change_styles_author_tags'
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction('changeImage', ref)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Image class="size-4" />
|
||||||
|
{t('dialog.avatar.actions.change_image')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onContextMenuAction(
|
||||||
|
'createImpostor',
|
||||||
|
ref
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RefreshCw class="size-4" />
|
||||||
|
{t('dialog.avatar.actions.create_impostor')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user