Merge remote-tracking branch 'misskey-mattyatea/emoji-request' into develop

# Conflicts:
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/backend/src/core/UserFollowingService.ts
#	packages/frontend/src/components/MkEmojiPicker.vue
#	packages/frontend/src/pages/custom-emojis-manager.vue
This commit is contained in:
mattyatea 2023-10-18 22:37:42 +09:00
commit c7c70c1c30
36 changed files with 1089 additions and 421 deletions

View file

@ -4,52 +4,96 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps">
<MkButton v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link to="/custom-emojis-manager">{{ i18n.ts.manageCustomEmojis }}</MkButton>
<MkStickyContainer>
<template #header>
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer v-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
<MkButton
v-if="$i && ($i.isModerator || $i.policies.canManageCustomEmojis)" primary link
to="/custom-emojis-manager"
>
{{ i18n.ts.manageCustomEmojis }}
</MkButton>
<MkButton
v-if="$i && (!$i.isModerator || !$i.policies.canManageCustomEmojis || $i.policies.canRequestCustomEmojis)"
primary style="margin-top: 8px;" @click="edit"
>
{{ i18n.ts.requestCustomEmojis }}
</MkButton>
<div class="query">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<div class="query" style="margin-top: 10px;">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search">
<template #prefix><i class="ti ti-search"></i></template>
</MkInput>
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
</div>
-->
</div>
-->
</div>
<MkFoldableSection v-if="searchEmojis">
<template #header>{{ i18n.ts.searchResult }}</template>
<MkFoldableSection v-if="searchEmojis">
<template #header>{{ i18n.ts.searchResult }}</template>
<div :class="$style.emojis">
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
</div>
</MkFoldableSection>
<MkFoldableSection v-for="category in filteredCategories" v-once :key="category">
<template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.emojis">
<XEmoji
v-for="emoji in customEmojis.filter(e => e.category === category && !e.draft)" :key="emoji.name"
:emoji="emoji" :draft="emoji.draft"
/>
</div>
</MkFoldableSection>
</MkSpacer>
<MkSpacer v-if="tab === 'draft'" :contentMax="1000" :marginMin="20">
<div :class="$style.emojis">
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/>
<XEmoji v-for="emoji in draftEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
</div>
</MkFoldableSection>
<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category">
<template #header>{{ category || i18n.ts.other }}</template>
<div :class="$style.emojis">
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/>
</div>
</MkFoldableSection>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { watch } from 'vue';
import { watch, defineAsyncComponent, ref, computed } from 'vue';
import * as Misskey from 'misskey-js';
import XEmoji from './emojis.emoji.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js';
import { customEmojis, customEmojiCategories } from '@/custom-emojis.js';
import { i18n } from '@/i18n.js';
import * as os from '@/os';
import { $i } from '@/account.js';
import { definePageMetadata } from '@/scripts/page-metadata';
let tab = $ref('emojis');
const headerActions = $computed(() => []);
const headerTabs = $computed(() => [{
key: 'emojis',
title: i18n.ts.list,
}, {
key: 'draft',
title: i18n.ts.draftEmojis,
}]);
const filteredCategories = computed(() => {
return customEmojiCategories.value.filter((category: any) => {
return customEmojis.value.some((em: any) => em.category === category && !em.draft);
});
});
definePageMetadata(ref({}));
const customEmojiTags = getCustomEmojiTags();
let q = $ref('');
let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null);
let selectedTags = $ref(new Set());
const draftEmojis = customEmojis.value.filter(emoji => emoji.draft);
function search() {
if ((q === '' || q == null) && selectedTags.size === 0) {
@ -80,6 +124,16 @@ function toggleTag(tag) {
}
}
const edit = () => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
isRequest: true,
}, {
done: result => {
window.location.reload();
},
}, 'closed');
};
watch($$(q), () => {
search();
});
@ -87,6 +141,11 @@ watch($$(q), () => {
watch($$(selectedTags), () => {
search();
}, { deep: true });
definePageMetadata({
title: i18n.ts.customEmojis,
icon: null,
});
</script>
<style lang="scss" module>

View file

@ -42,7 +42,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination">
<div :class="$style.items">
<MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`" :class="$style.item" :to="`/instance-info/${instance.host}`">
<MkA
v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Status: ${getStatus(instance)}`"
:class="$style.item" :to="`/instance-info/${instance.host}`"
>
<MkInstanceCardMini :instance="instance"/>
</MkA>
</div>

View file

@ -89,9 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSection>
</div>
</MkSpacer>
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
<XEmojis/>
</MkSpacer>
<XEmojis v-else-if="tab === 'emojis'"/>
<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
<XFederation/>
</MkSpacer>

View file

@ -5,7 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<template #header>
<XHeader :actions="headerActions" :tabs="headerTabs"/>
</template>
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
<FormSuspense :p="init">
<MkTextarea v-if="tab === 'block'" v-model="blockedHosts">

View file

@ -279,6 +279,66 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template>
<template #suffix>
<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template>
<template #suffix>
<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template>
<template #suffix>
<span v-if="role.policies.canRequestCustomEmojis.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canRequestCustomEmojis.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canRequestCustomEmojis)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canRequestCustomEmojis.value" :disabled="role.policies.canRequestCustomEmojis.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canRequestCustomEmojis.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>

View file

@ -95,6 +95,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canRequestCustomEmojis, 'canRequestCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canRequestCustomEmojis }}</template>
<template #suffix>{{ policies.canRequestCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canRequestCustomEmojis">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canSearchNotes, 'canSearchNotes'])">
<template #label>{{ i18n.ts._role._options.canSearchNotes }}</template>
<template #suffix>{{ policies.canSearchNotes ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -10,64 +10,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkInput v-model="query" :debounce="true" type="search">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
<MkSwitch v-model="selectMode" style="margin: 8px 0;">
<template #label>Select mode</template>
</MkSwitch>
<div v-if="selectMode" class="_buttons">
<MkButton inline @click="selectAll">Select all</MkButton>
<MkButton inline @click="setCategoryBulk">Set category</MkButton>
<MkButton inline @click="setTagBulk">Set tag</MkButton>
<MkButton inline @click="setisSensitiveBulk">Set isSensitive</MkButton>
<MkButton inline @click="setlocalOnlyBulk">Set localOnly</MkButton>
<MkButton inline @click="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
<MkButton inline @click="setLicenseBulk">Set License</MkButton>
<MkButton inline danger @click="delBulk">Delete</MkButton>
</div>
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
</div>
</template>
</MkPagination>
<MkCustomEmojiEditLocal/>
</div>
<div v-if="tab === 'draft'" class="draft">
<MkCustomEmojiEditDraft/>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<FormSplit>
<MkInput v-model="queryRemote" :debounce="true" type="search">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
<MkInput v-model="host" :debounce="true">
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
</FormSplit>
<MkPagination :pagination="remotePagination">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
</div>
</div>
</div>
</template>
</MkPagination>
<MkCustomEmojiEditRemote/>
</div>
</div>
</MkSpacer>
@ -76,105 +25,30 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSplit from '@/components/form/split.vue';
import { selectFile, selectFiles } from '@/scripts/select-file.js';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import {switch1, swtch} from "@/os.js";
import { computed, defineAsyncComponent, ref } from 'vue';
import MkCustomEmojiEditDraft from '@/components/MkCustomEmojiEditDraft.vue';
import MkCustomEmojiEditLocal from '@/components/MkCustomEmojiEditLocal.vue';
import MkCustomEmojiEditRemote from '@/components/MkCustomEmojiEditRemote.vue';
import { selectFile } from '@/scripts/select-file';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const emojisPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const tab = ref('local');
const query = ref(null);
const queryRemote = ref(null);
const host = ref(null);
const selectMode = ref(false);
const selectedEmojis = ref<string[]>([]);
const pagination = {
endpoint: 'admin/emoji/list' as const,
limit: 30,
params: computed(() => ({
query: (query.value && query.value !== '') ? query.value : null,
})),
};
const remotePagination = {
endpoint: 'admin/emoji/list-remote' as const,
limit: 30,
params: computed(() => ({
query: (queryRemote.value && queryRemote.value !== '') ? queryRemote.value : null,
host: (host.value && host.value !== '') ? host.value : null,
})),
};
const selectAll = () => {
if (selectedEmojis.value.length > 0) {
selectedEmojis.value = [];
} else {
selectedEmojis.value = Array.from(emojisPaginationComponent.value.items.values(), item => item.id);
}
};
const toggleSelect = (emoji) => {
if (selectedEmojis.value.includes(emoji.id)) {
selectedEmojis.value = selectedEmojis.value.filter(x => x !== emoji.id);
} else {
selectedEmojis.value.push(emoji.id);
}
};
const tab = ref('draft');
const add = async (ev: MouseEvent) => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
}, {
done: result => {
if (result.created) {
emojisPaginationComponent.value.prepend(result.created);
}
//TODO: emit
// if (result.created) {
// emojisPaginationComponent.value.prepend(result.created);
// emojisPaginationComponent.value.reload();
// }
},
}, 'closed');
};
const edit = (emoji) => {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), {
emoji: emoji,
}, {
done: result => {
if (result.updated) {
emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
...oldEmoji,
...result.updated,
}));
} else if (result.deleted) {
emojisPaginationComponent.value.removeItem(emoji.id);
}
},
}, 'closed');
};
const im = (emoji) => {
os.apiWithDialog('admin/emoji/copy', {
emojiId: emoji.id,
});
};
const remoteMenu = (emoji, ev: MouseEvent) => {
os.popupMenu([{
type: 'label',
text: ':' + emoji.name + ':',
}, {
text: i18n.ts.import,
icon: 'ti ti-plus',
action: () => { im(emoji); },
}], ev.currentTarget ?? ev.target);
};
const menu = (ev: MouseEvent) => {
os.popupMenu([{
icon: 'ti ti-download',
@ -217,101 +91,6 @@ const menu = (ev: MouseEvent) => {
}], ev.currentTarget ?? ev.target);
};
const setCategoryBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Category',
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/set-category-bulk', {
ids: selectedEmojis.value,
category: result,
});
emojisPaginationComponent.value.reload();
};
const setLicenseBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'License',
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/set-license-bulk', {
ids: selectedEmojis.value,
license: result,
});
emojisPaginationComponent.value.reload();
};
const addTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/add-aliases-bulk', {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value.reload();
};
const removeTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/remove-aliases-bulk', {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value.reload();
};
const setTagBulk = async () => {
const { canceled, result } = await os.inputText({
title: 'Tag',
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/set-aliases-bulk', {
ids: selectedEmojis.value,
aliases: result.split(' '),
});
emojisPaginationComponent.value.reload();
};
const setisSensitiveBulk = async () => {
const { canceled, result } = await os.switch1({
title: 'isSensitive',
type: "mksw"
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/set-issensitive-bulk', {
ids: selectedEmojis.value,
isSensitive: result
});
emojisPaginationComponent.value.reload();
};
const setlocalOnlyBulk = async () => {
const { canceled, result } = await os.switch1({
title: 'localOnly',
type: "mksw"
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/set-localonly-bulk', {
ids: selectedEmojis.value,
localOnly: result
});
emojisPaginationComponent.value.reload();
};
const delBulk = async () => {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.ts.deleteConfirm,
});
if (canceled) return;
await os.apiWithDialog('admin/emoji/delete-bulk', {
ids: selectedEmojis.value,
});
emojisPaginationComponent.value.reload();
};
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
@ -323,6 +102,9 @@ const headerActions = $computed(() => [{
}]);
const headerTabs = $computed(() => [{
key: 'draft',
title: i18n.ts.draftEmojis,
}, {
key: 'local',
title: i18n.ts.local,
}, {
@ -337,103 +119,4 @@ definePageMetadata(computed(() => ({
</script>
<style lang="scss" scoped>
.ogwlenmc {
> .local {
.empty {
margin: var(--margin);
}
.ldhfsamy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
> .emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
&:hover {
border-color: var(--inputBorderHover);
}
&.selected {
border-color: var(--accent);
}
> .img {
width: 42px;
height: 42px;
}
> .body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
> .name {
text-overflow: ellipsis;
overflow: hidden;
}
> .info {
opacity: 0.5;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
}
> .remote {
.empty {
margin: var(--margin);
}
.ldhfsamy {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
> .emoji {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
&:hover {
color: var(--accent);
}
> .img {
width: 32px;
height: 32px;
}
> .body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
> .name {
text-overflow: ellipsis;
overflow: hidden;
}
> .info {
opacity: 0.5;
font-size: 90%;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
}
}
}
</style>

View file

@ -1,243 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
@close="dialog.close()"
@closed="$emit('closed')"
>
<template v-if="emoji" #header>:{{ emoji.name }}:</template>
<template v-else #header>New emoji</template>
<div>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<div v-if="imgUrl != null" :class="$style.imgs">
<div style="background: #000;" :class="$style.imgContainer">
<img :src="imgUrl" :class="$style.img"/>
</div>
<div style="background: #222;" :class="$style.imgContainer">
<img :src="imgUrl" :class="$style.img"/>
</div>
<div style="background: #ddd;" :class="$style.imgContainer">
<img :src="imgUrl" :class="$style.img"/>
</div>
<div style="background: #fff;" :class="$style.imgContainer">
<img :src="imgUrl" :class="$style.img"/>
</div>
</div>
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
<MkInput v-model="name" pattern="[a-z0-9_]">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkInput v-model="category" :datalist="customEmojiCategories">
<template #label>{{ i18n.ts.category }}</template>
</MkInput>
<MkInput v-model="aliases">
<template #label>{{ i18n.ts.tags }}</template>
<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
</MkInput>
<MkInput v-model="license">
<template #label>{{ i18n.ts.license }}</template>
</MkInput>
<MkFolder>
<template #label>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction }}</template>
<template #suffix>{{ rolesThatCanBeUsedThisEmojiAsReaction.length === 0 ? i18n.ts.all : rolesThatCanBeUsedThisEmojiAsReaction.length }}</template>
<div class="_gaps">
<MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<div v-for="role in rolesThatCanBeUsedThisEmojiAsReaction" :key="role.id" :class="$style.roleItem">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
<button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="removeRole(role, $event)"><i class="ti ti-x"></i></button>
<button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
</div>
<MkInfo>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription }}</MkInfo>
<MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo>
</div>
</MkFolder>
<MkSwitch v-model="isSensitive">isSensitive</MkSwitch>
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkButton v-if="emoji" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</MkSpacer>
<div :class="$style.footer">
<MkButton primary rounded style="margin: 0 auto;" @click="done"><i class="ti ti-check"></i> {{ props.emoji ? i18n.ts.update : i18n.ts.create }}</MkButton>
</div>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkFolder from '@/components/MkFolder.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { customEmojiCategories } from '@/custom-emojis.js';
import MkSwitch from '@/components/MkSwitch.vue';
import { selectFile, selectFiles } from '@/scripts/select-file.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{
emoji?: any,
}>();
let dialog = $ref(null);
let name: string = $ref(props.emoji ? props.emoji.name : '');
let category: string = $ref(props.emoji ? props.emoji.category : '');
let aliases: string = $ref(props.emoji ? props.emoji.aliases.join(' ') : '');
let license: string = $ref(props.emoji ? (props.emoji.license ?? '') : '');
let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref<Misskey.entities.DriveFile>();
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
}, { immediate: true });
const imgUrl = computed(() => file ? file.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
async function changeImage(ev) {
file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) {
name = candidate;
}
}
async function addRole() {
const roles = await os.api('admin/roles/list');
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id);
const { canceled, result: role } = await os.select({
items: roles.filter(r => r.isPublic).filter(r => !currentRoleIds.includes(r.id)).map(r => ({ text: r.name, value: r })),
});
if (canceled) return;
rolesThatCanBeUsedThisEmojiAsReaction.push(role);
}
async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id);
}
async function done() {
const params = {
name,
category: category === '' ? null : category,
aliases: aliases.split(' ').filter(x => x !== ''),
license: license === '' ? null : license,
isSensitive,
localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
};
if (file) {
params.fileId = file.id;
}
if (props.emoji) {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
...params,
});
emit('done', {
updated: {
id: props.emoji.id,
...params,
},
});
dialog.close();
} else {
const created = await os.apiWithDialog('admin/emoji/add', params);
emit('done', {
created: created,
});
dialog.close();
}
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: name }),
});
if (canceled) return;
os.api('admin/emoji/delete', {
id: props.emoji.id,
}).then(() => {
emit('done', {
deleted: true,
});
dialog.close();
});
}
</script>
<style lang="scss" module>
.imgs {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.imgContainer {
padding: 8px;
border-radius: 6px;
}
.img {
display: block;
height: 64px;
width: 64px;
object-fit: contain;
}
.roleItem {
display: flex;
}
.role {
flex: 1;
}
.roleUnassign {
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
}
.footer {
position: sticky;
bottom: 0;
left: 0;
padding: 12px;
border-top: solid 0.5px var(--divider);
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));
}
</style>

View file

@ -4,10 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<button class="_button" :class="$style.root" @click="menu">
<button v-if="emoji.draft" class="zuvgdzyu _button emoji-draft" @click="menu">
<img style="max-height: 64px;object-fit: contain;" :src="emoji.url" class="img" loading="lazy"/>
<div class="body">
<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div>
</div>
</button>
<button v-else class="_button" :class="$style.root" @click="menu">
<img :src="emoji.url" :class="$style.img" loading="lazy"/>
<div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
<div :class="$style.name">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.aliases.join(' ') }}</div>
</div>
</button>
@ -25,6 +32,7 @@ const props = defineProps<{
aliases: string[];
category: string;
url: string;
draft: boolean;
};
}>();
@ -91,4 +99,12 @@ function menu(ev) {
text-overflow: ellipsis;
overflow: hidden;
}
.emoji-draft {
--c: rgb(255 196 0 / 15%);;
background-image: linear-gradient(45deg,var(--c) 16.67%,transparent 16.67%,transparent 50%,var(--c) 50%,var(--c) 66.67%,transparent 66.67%,transparent 100%);
background-size: 16px 16px;
max-width: 64px;
width: 100%;
}
</style>