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

# Conflicts:
#	CHANGELOG.md
#	locales/index.d.ts
#	locales/ja-JP.yml
#	packages/backend/src/core/CustomEmojiService.ts
#	packages/backend/src/models/RepositoryModule.ts
#	packages/backend/src/server/api/EndpointsModule.ts
#	packages/backend/src/server/api/endpoints.ts
#	packages/backend/src/server/api/endpoints/admin/emoji/update.ts
#	packages/frontend/src/components/MkCustomEmojiEditLocal.vue
#	packages/frontend/src/components/MkCustomEmojiEditRemote.vue
#	packages/frontend/src/components/MkEmojiEditDialog.vue
#	packages/frontend/src/pages/about.emojis.vue
#	packages/frontend/src/pages/admin/roles.editor.vue
#	packages/frontend/src/pages/custom-emojis-manager.vue
#	packages/frontend/src/pages/emojis.emoji.vue
This commit is contained in:
mattyatea 2023-12-23 09:30:48 +09:00
commit 68b48bc16f
35 changed files with 1286 additions and 476 deletions

View file

@ -1,5 +1,5 @@
<template>
<MkInput v-model="query" :debounce="true" type="search">
<MkInput v-model="query" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
@ -20,22 +20,22 @@
<MkPagination ref="emojisPaginationComponent" :pagination="pagination" :displayLimit="100">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<div class="ldhfsamy">
<div :class="$style.root">
<div v-for="emoji in items" :key="emoji.id">
<button v-if="emoji.draft" class="emoji _panel _button emoji-draft" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
<button v-else class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<button v-if="emoji.request" class="_panel _button" :class="[{ selected: selectedEmojis.includes(emoji.id) },$style.emoji,$style.emojirequest]" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="emoji.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.category }}</div>
</div>
</button>
<button v-else class="_panel _button" :class="[{ selected: selectedEmojis.includes(emoji.id) },$style.emoji]" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
<img :src="emoji.url" :class="$style.img" :alt="emoji.name"/>
<div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.category }}</div>
</div>
</button>
</div>
</div>
</template>
@ -64,6 +64,14 @@ const pagination = {
query: (query.value && query.value !== '') ? query.value : null,
})),
};
const selectAll = () => {
if (selectedEmojis.value.length > 0) {
selectedEmojis.value = [];
} else {
selectedEmojis.value = emojisPaginationComponent.value.items.map(item => item.id);
}
};
const setisSensitiveBulk = async () => {
const { canceled, result } = await os.switch1({
title: 'isSensitive',
@ -221,53 +229,48 @@ const delBulk = async () => {
};
</script>
<style lang="scss" scoped>
.ldhfsamy {
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: var(--margin);
}
.emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
width: 100%;
div > .emoji {
display: flex;
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
width: 100%;
&:hover {
border-color: var(--inputBorderHover);
}
&: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;
}
}
}
&.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;
}
.emoji-draft {
.info {
opacity: 0.5;
text-overflow: ellipsis;
overflow: hidden;
}
.emojirequest {
--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;

View file

@ -1,6 +1,6 @@
<template>
<FormSplit>
<MkInput v-model="queryRemote" :debounce="true" type="search">
<MkInput v-model="queryRemote" :debounce="true" type="search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template>
<template #label>{{ i18n.ts.search }}</template>
</MkInput>
@ -8,15 +8,15 @@
<template #label>{{ i18n.ts.host }}</template>
</MkInput>
</FormSplit>
<MkPagination :pagination="remotePagination" :displayLimit="100">
<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.url" class="img" :alt="emoji.name"/>
<div class="body">
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.host }}</div>
<div :class="$style.root">
<div v-for="emoji in items" :key="emoji.id" :class="$style.emoji" class="_panel _button" @click="remoteMenu(emoji, $event)">
<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" :class="$style.img" :alt="emoji.name"/>
<div :class="$style.body">
<div :class="$style.name" class="_monospace">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.host }}</div>
</div>
</div>
</div>
@ -62,49 +62,45 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
};
</script>
<style lang="scss" scoped>
.empty {
margin: var(--margin);
<style lang="scss" module>
.root {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
grid-gap: 12px;
margin: var(--margin) 0;
}
.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;
> .emoji {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
&:hover {
color: var(--accent);
}
}
&:hover {
color: var(--accent);
}
.img {
width: 32px;
height: 32px;
}
> .img {
width: 32px;
height: 32px;
}
.body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
}
.name {
text-overflow: ellipsis;
overflow: hidden;
}
> .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;
}
}
}
.info {
opacity: 0.5;
font-size: 90%;
text-overflow: ellipsis;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,198 @@
<template>
<MkPagination ref="emojisRequestPaginationComponent" :pagination="paginationRequest">
<template #empty><span>{{ i18n.ts.noCustomEmojis }}</span></template>
<template #default="{items}">
<template v-for="emoji in items" :key="emoji.id">
<div :class="$style.emoji" class="_panel">
<div :class="$style.img">
<div :class="$style.imgLight"><img :src="emoji.url" :alt="emoji.name"/></div>
<div :class="$style.imgDark"><img :src="emoji.url" :alt="emoji.name"/></div>
</div>
<div :class="$style.info">
<div :class="$style.name">{{ i18n.ts.name }}: {{ emoji.name }}</div>
<div :class="$style.category">{{ i18n.ts.category }}:{{ emoji.category }}</div>
<div :class="$style.aliases">{{ i18n.ts.tags }}:{{ emoji.aliases.join(' ') }}</div>
<div :class="$style.license">{{ i18n.ts.license }}:{{ emoji.license }}</div>
</div>
<div :class="$style.editbutton">
<MkButton primary :class="$style.edit" @click="editRequest(emoji)">
{{ i18n.ts.edit }}
</MkButton>
<MkButton :class="$style.request" @click="unrequested(emoji)">
{{ i18n.ts.approval }}
</MkButton>
<MkButton danger :class="$style.delete" @click="deleteRequest(emoji)">
{{ i18n.ts.delete }}
</MkButton>
</div>
</div>
</template>
</template>
</MkPagination>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
import MkPagination from '@/components/MkPagination.vue';
import * as os from '@/os';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
const emojisRequestPaginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const query = ref(null);
const paginationRequest = {
endpoint: 'admin/emoji/list-request' as const,
limit: 30,
params: computed(() => ({
query: (query.value && query.value !== '') ? query.value : null,
})),
};
function editRequest(emoji) {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
emoji: emoji,
isRequest: true,
}, {
done: result => {
if (result.updated) {
emojisRequestPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({
...oldEmoji,
...result.updated,
}));
emojisRequestPaginationComponent.value.reload();
} else if (result.deleted) {
emojisRequestPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisRequestPaginationComponent.value.reload();
}
},
}, 'closed');
}
async function unrequested(emoji) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('requestApprovalAreYouSure', { x: emoji.name }),
});
if (canceled) return;
await os.api('admin/emoji/update-request', {
id: emoji.id,
fileId: emoji.fileId,
name: emoji.name,
category: emoji.category,
aliases: emoji.aliases,
license: emoji.license,
isSensitive: emoji.isSensitive,
localOnly: emoji.localOnly,
isRequest: false,
});
emojisRequestPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisRequestPaginationComponent.value.reload();
}
async function deleteRequest(emoji) {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: emoji.name }),
});
if (canceled) return;
os.api('admin/emoji/delete', {
id: emoji.id,
}).then(() => {
emojisRequestPaginationComponent.value.removeItem((item) => item.id === emoji.id);
emojisRequestPaginationComponent.value.reload();
});
}
</script>
<style lang="scss" module>
.emoji {
align-items: center;
padding: 11px;
text-align: left;
border: solid 1px var(--panel);
margin: 10px;
}
.img {
display: grid;
grid-row: 1;
grid-column: 1/ span 2;
grid-template-columns: 50% 50%;
place-content: center;
place-items: center;
}
.imgLight {
display: grid;
grid-column: 1;
background-color: #fff;
margin-bottom: 12px;
img {
max-height: 64px;
max-width: 100%;
}
}
.imgDark {
display: grid;
grid-column: 2;
background-color: #000;
margin-bottom: 12px;
img {
max-height: 64px;
max-width: 100%;
}
}
.info {
display: grid;
grid-row: 2;
grid-template-rows: 30px 30px 30px;
}
.name {
grid-row: 1;
text-overflow: ellipsis;
overflow: hidden;
}
.category {
grid-row: 2;
text-overflow: ellipsis;
overflow: hidden;
}
.aliases {
grid-row: 3;
text-overflow: ellipsis;
overflow: hidden;
}
.license {
grid-row: 4;
text-overflow: ellipsis;
overflow: hidden;
}
.editbutton {
display: grid;
grid-template-rows: 42px;
margin-top: 6px;
}
.edit {
grid-row: 1;
width: 100%;
margin: 6px 0;
}
.request {
grid-row: 2;
width: 100%;
margin: 6px 0;
}
.delete {
grid-row: 3;
width: 100%;
margin: 6px 0;
}
</style>

View file

@ -7,12 +7,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkModalWindow
ref="dialog"
:width="400"
:withOkButton="false "
@close="dialog.close()"
@closed="$emit('closed')"
>
<template v-if="emoji" #header>:{{ emoji.name }}:</template>
<template v-else-if="isRequest" #header>{{ i18n.ts.requestCustomEmojis }}</template>
<template v-else-if="isRequest && !emoji" #header>{{ i18n.ts.requestCustomEmojis }}</template>
<template v-else #header>New emoji</template>
<div>
@ -64,14 +63,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo warn>{{ i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionPublicRoleWarn }}</MkInfo>
</div>
</MkFolder>
<MkSwitch v-model="isSensitive">isSensitive</MkSwitch>
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkSwitch v-model="isSensitive">{{ i18n.ts.isSensitive }}</MkSwitch>
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
<MkSwitch v-model="isNotifyIsHome">
{{ i18n.ts.isNotifyIsHome }}
</MkSwitch>
<MkSwitch v-if="!isRequest" v-model="draft" >
{{ i18n.ts.draft }}
</MkSwitch>
</div>
</MkSpacer>
@ -87,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<script lang="ts" setup>
import { computed, watch, ref } from 'vue';
import { computed, watch } from 'vue';
import * as Misskey from 'misskey-js';
import { DriveFile } from 'misskey-js/built/entities.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
@ -99,144 +98,91 @@ 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 } from '@/scripts/select-file.js';
import { selectFile, selectFiles } from '@/scripts/select-file.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
const props = defineProps<{
emoji?: any,
isRequest: boolean,
emoji?: any,
isRequest: boolean,
}>();
const dialog = ref(null);
const name = ref<string>(props.emoji ? props.emoji.name : '');
const category = ref<string>(props.emoji ? props.emoji.category : '');
const aliases = ref<string>(props.emoji ? props.emoji.aliases.join(' ') : '');
const license = ref<string>(props.emoji ? (props.emoji.license ?? '') : '');
const isSensitive = ref(props.emoji ? props.emoji.isSensitive : false);
const localOnly = ref(props.emoji ? props.emoji.localOnly : false);
const roleIdsThatCanBeUsedThisEmojiAsReaction = ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
const rolesThatCanBeUsedThisEmojiAsReaction = ref([]);
const file = ref<Misskey.entities.DriveFile>();
let chooseFile = ref(null);
let draft = ref(props.emoji ? props.emoji.draft : false);
let isRequest = ref(props.isRequest);
let isNotifyIsHome = ref(false);
let url;
watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
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) ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
let file = $ref<Misskey.entities.DriveFile>();
let chooseFile: DriveFile|null = $ref(null);
let isRequest = $ref(props.isRequest ?? false);
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.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
const imgUrl = computed(() => file ? file.url : props.emoji && !isRequest ? `/emoji/${props.emoji.name}.webp` : props.emoji && props.emoji.url ? props.emoji.url : null);
const validation = computed(() => {
return name.value.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null;
return name.match(/^[a-zA-Z0-9_]+$/) && imgUrl.value != null;
});
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void,
(ev: 'closed'): void
}>();
function ok() {
if (isRequest) {
if (chooseFile.value !== null && name.value.match(/^[a-zA-Z0-9_]+$/)) {
add();
}
} else {
update();
}
}
async function add() {
const ret = await os.api('admin/emoji/add-draft', {
name: name,
category: category.value,
aliases: aliases.value.split(' '),
license: license.value === '' ? null : license.value,
fileId: chooseFile.value.id,
isNotifyIsHome: isNotifyIsHome.value,
});
emit('done', {
updated: {
id: ret.value.id,
name,
category,
aliases: aliases.value.split(' '),
license: license.value === '' ? null : license,
draft: true,
},
});
dialog.value.close();
}
async function changeImage(ev) {
file.value = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.value.name.replace(/\.(.+)$/, '');
file = await selectFile(ev.currentTarget ?? ev.target, null);
const candidate = file.name.replace(/\.(.+)$/, '');
if (candidate.match(/^[a-z0-9_]+$/)) {
name.value = candidate;
name = candidate;
}
}
async function addRole() {
const roles = await os.api('admin/roles/list');
const currentRoleIds = rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id);
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.value.push(role);
rolesThatCanBeUsedThisEmojiAsReaction.push(role);
}
async function removeRole(role, ev) {
rolesThatCanBeUsedThisEmojiAsReaction.value = rolesThatCanBeUsedThisEmojiAsReaction.value.filter(x => x.id !== role.id);
rolesThatCanBeUsedThisEmojiAsReaction = rolesThatCanBeUsedThisEmojiAsReaction.filter(x => x.id !== role.id);
}
async function update() {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
name: name.value,
category: category.value === '' ? null : category.value,
aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license.value === '' ? null : license.value,
fileId: chooseFile.value?.id,
draft: draft.value,
});
emit('done', {
updated: {
id: props.emoji.id,
name: name.value,
category: category.value === '' ? null : category.value,
aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license.value === '' ? null : license.value,
draft: draft.value,
},
});
dialog.value.close();
}
async function done() {
const params = {
name: name.value,
category: category.value === '' ? null : category.value,
aliases: aliases.value.split(' ').filter(x => x !== ''),
license: license.value === '' ? null : license.value,
isSensitive: isSensitive.value,
draft: draft.value,
localOnly: localOnly.value,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.value.map(x => x.id),
isNotifyIsHome: isNotifyIsHome.value,
};
name,
category: category === '' ? null : category,
aliases: aliases.replace(' ', ' ').split(' ').filter(x => x !== ''),
license: license === '' ? null : license,
Request: isRequest,
isSensitive,
localOnly,
roleIdsThatCanBeUsedThisEmojiAsReaction: rolesThatCanBeUsedThisEmojiAsReaction.map(x => x.id),
};
if (file.value) {
params.fileId = file.value.id;
if (file) {
params.fileId = file.id;
}
console.log(props.emoji);
if (props.emoji) {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
...params,
});
if (isRequest) {
await os.apiWithDialog('admin/emoji/update-request', {
id: props.emoji.id,
...params,
});
} else {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
...params,
});
}
emit('done', {
updated: {
@ -245,31 +191,24 @@ async function done() {
},
});
dialog.value.close();
dialog.close();
} else {
const created = isRequest
? await os.apiWithDialog('admin/emoji/add-draft', params)
? await os.apiWithDialog('admin/emoji/add-request', params)
: await os.apiWithDialog('admin/emoji/add', params);
emit('done', {
created: created,
});
dialog.value.close();
dialog.close();
}
}
function chooseFileFrom(ev) {
selectFiles(ev.currentTarget ?? ev.target, i18n.ts.attachFile).then(files_ => {
chooseFile.value = files_[0];
url = chooseFile.value.url;
});
}
async function del() {
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: name.value }),
text: i18n.t('removeAreYouSure', { x: name }),
});
if (canceled) return;
@ -279,60 +218,60 @@ async function del() {
emit('done', {
deleted: true,
});
dialog.value.close();
dialog.close();
});
}
</script>
<style lang="scss" module>
.imgs {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.imgContainer {
padding: 8px;
border-radius: 6px;
padding: 8px;
border-radius: 6px;
}
.img {
display: block;
height: 64px;
width: 64px;
object-fit: contain;
display: block;
height: 64px;
width: 64px;
object-fit: contain;
}
.roleItem {
display: flex;
display: flex;
}
.role {
flex: 1;
flex: 1;
}
.roleUnassign {
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
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));
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));
}
.footerButtons {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
</style>

View file

@ -212,7 +212,6 @@ async function init(): Promise<void> {
const item = res[i];
if (i === 3) item._shouldInsertAd_ = true;
}
if (res.length === 0 || props.pagination.noPaging) {
concatItems(res);
more.value = false;
@ -221,7 +220,6 @@ async function init(): Promise<void> {
concatItems(res);
more.value = true;
}
offset.value = res.length;
error.value = false;
fetching.value = false;

View file

@ -5,138 +5,112 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkStickyContainer>
<template #header>
<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
</template>
<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>
<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.canRequestCustomEmojis)" primary @click="edit">{{ i18n.ts.requestCustomEmojis }}</MkButton>
<div class="query" style="margin-top: 10px;">
<MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off">
<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>
-->
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
</div>
-->
</div>
<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"/>
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji" :request="emoji.request"/>
</div>
</MkFoldableSection>
<MkFoldableSection v-for="category in filteredCategories" v-once :key="category">
<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 && !e.draft)" :key="emoji.name"
:emoji="emoji" :draft="emoji.draft"
/>
<XEmoji v-for="emoji in customEmojis.filter(e => e.category === category)" :key="emoji.name" :emoji="emoji"/>
</div>
</MkFoldableSection>
</MkSpacer>
<MkSpacer v-if="tab === 'draft'" :contentMax="1000" :marginMin="20">
<MkSpacer v-if="tab === 'request'" :contentMax="1000" :marginMin="20">
<div :class="$style.emojis">
<XEmoji v-for="emoji in draftEmojis" :key="emoji.name" :emoji="emoji" :draft="emoji.draft"/>
<XEmoji v-for="emoji in requestEmojis.emojis" :key="emoji.name" :emoji="emoji" :request="true"/>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { watch, defineAsyncComponent, ref, computed } from 'vue';
import { watch, defineAsyncComponent, ref } 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(() => []);
let tab = $ref('emojis');
const headerActions = $computed(() => []);
const customEmojiTags = getCustomEmojiTags();
const q = ref('');
const searchEmojis = ref<Misskey.entities.EmojiSimple[]>(null);
const selectedTags = ref(new Set());
const headerTabs = computed(() => [{
key: 'emojis',
title: i18n.ts.list,
const headerTabs = $computed(() => [{
key: 'emojis',
title: i18n.ts.list,
}, {
key: 'draft',
title: i18n.ts.draftEmojis,
key: 'request',
title: i18n.ts.requestingEmojis,
}]);
const filteredCategories = computed(() => {
return customEmojiCategories.value.filter((category: any) => {
return customEmojis.value.some((em: any) => em.category === category && !em.draft);
});
});
definePageMetadata(ref({}));
const draftEmojis = customEmojis.value.filter(emoji => emoji.draft);
let q = $ref('');
let searchEmojis = $ref<Misskey.entities.CustomEmoji[]>(null);
let selectedTags = $ref(new Set());
const requestEmojis = await os.apiGet('emoji-requests');
function search() {
if ((q.value === '' || q.value == null) && selectedTags.value.size === 0) {
searchEmojis.value = null;
if ((q === '' || q == null) && selectedTags.size === 0) {
searchEmojis = null;
return;
}
if (selectedTags.value.size === 0) {
const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g);
if (selectedTags.size === 0) {
const queryarry = q.match(/\:([a-z0-9_]*)\:/g);
if (queryarry) {
searchEmojis.value = customEmojis.value.filter(emoji =>
searchEmojis = customEmojis.value.filter(emoji =>
queryarry.includes(`:${emoji.name}:`),
);
} else {
searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value));
searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q));
}
} else {
searchEmojis.value = customEmojis.value.filter(emoji => (emoji.name.includes(q.value) || emoji.aliases.includes(q.value)) && [...selectedTags.value].every(t => emoji.aliases.includes(t)));
searchEmojis = customEmojis.value.filter(emoji => (emoji.name.includes(q) || emoji.aliases.includes(q)) && [...selectedTags].every(t => emoji.aliases.includes(t)));
}
}
function toggleTag(tag) {
if (selectedTags.value.has(tag)) {
selectedTags.value.delete(tag);
} else {
selectedTags.value.add(tag);
}
}
const edit = () => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
isRequest: true,
}, {
done: result => {
window.location.reload();
},
}, 'closed');
};
watch(q, () => {
watch($$(q), () => {
search();
});
const edit = () => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
isRequest: true,
}, {
done: result => {
window.location.reload();
},
}, 'closed');
};
watch(selectedTags, () => {
watch($$(selectedTags), () => {
search();
}, { deep: true });

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

@ -319,46 +319,6 @@ 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.canManageAvatarDecorations, 'canManageAvatarDecorations'])">
<template #label>{{ i18n.ts._role._options.canManageAvatarDecorations }}</template>
<template #suffix>

View file

@ -8,17 +8,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkCustomEmojiEditLocal/>
</div>
<div v-if="tab === 'request'" class="request">
<MkCustomEmojiEditDraft/>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<MkCustomEmojiEditRemote/>
</div>
</div>
<div class="ogwlenmc">
<div v-if="tab === 'local'" class="local">
<MkCustomEmojiEditLocal/>
</div>
<div v-if="tab === 'request'" class="request">
<MkCustomEmojiEditRequest/>
</div>
<div v-else-if="tab === 'remote'" class="remote">
<MkCustomEmojiEditRemote/>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref } from 'vue';
import MkCustomEmojiEditDraft from '@/components/MkCustomEmojiEditDraft.vue';
import MkCustomEmojiEditRequest from '@/components/MkCustomEmojiEditRequest.vue';
import MkCustomEmojiEditLocal from '@/components/MkCustomEmojiEditLocal.vue';
import MkCustomEmojiEditRemote from '@/components/MkCustomEmojiEditRemote.vue';
import { selectFile } from '@/scripts/select-file';
@ -34,15 +34,17 @@ import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
const tab = ref('draft');
const tab = ref('request');
const add = async (ev: MouseEvent) => {
os.popup(defineAsyncComponent(() => import('@/components/MkEmojiEditDialog.vue')), {
os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.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');
};
@ -89,7 +91,7 @@ const menu = (ev: MouseEvent) => {
}], ev.currentTarget ?? ev.target);
};
const headerActions = computed(() => [{
const headerActions = $computed(() => [{
asFullButton: true,
icon: 'ti ti-plus',
text: i18n.ts.addEmoji,
@ -99,9 +101,9 @@ const headerActions = computed(() => [{
handler: menu,
}]);
const headerTabs = computed(() => [{
key: 'draft',
title: i18n.ts.draftEmojis,
const headerTabs = $computed(() => [{
key: 'request',
title: i18n.ts.requestingEmojis,
}, {
key: 'local',
title: i18n.ts.local,

View file

@ -4,36 +4,35 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<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"/>
<button v-if="request" class="_button emoji-request" :class="$style.root" @click="menu">
<img :src="emoji.url" :class="$style.img" loading="lazy"/>
<div class="body">
<div class="name _monospace">{{ emoji.name + ' (draft)' }}</div>
<div class="name _monospace">{{ emoji.name + ' (request)' }}</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">{{ emoji.name }}</div>
<div :class="$style.info">{{ emoji.aliases.join(' ') }}</div>
<div class="name _monospace">{{ emoji.name }}</div>
<div class="info">{{ emoji.aliases.join(' ') }}</div>
</div>
</button>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as os from '@/os.js';
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
import { i18n } from '@/i18n.js';
const props = defineProps<{
emoji: {
name: string;
aliases: string[];
category: string;
url: string;
draft: boolean;
};
emoji: {
name: string;
aliases: string[];
category: string;
url: string;
};
request?: boolean;
}>();
function menu(ev) {
@ -51,10 +50,10 @@ function menu(ev) {
text: i18n.ts.info,
icon: 'ti ti-info-circle',
action: () => {
os.apiGet('emoji', { name: props.emoji.name }).then(res => {
os.apiGet('emoji-requests', { name: props.emoji.name }).then(res => {
os.alert({
type: 'info',
text: `Name: ${res.name}\nAliases: ${res.aliases.join(' ')}\nCategory: ${res.category}\nisSensitive: ${res.isSensitive}\nlocalOnly: ${res.localOnly}\nLicense: ${res.license}\nURL: ${res.url}`,
text: `License: ${res.license}`,
});
});
},
@ -64,47 +63,45 @@ function menu(ev) {
<style lang="scss" module>
.root {
display: flex;
align-items: center;
padding: 12px;
text-align: left;
background: var(--panel);
border-radius: 8px;
display: flex;
align-items: center;
padding: 12px;
text-align: left;
background: var(--panel);
border-radius: 8px;
&:hover {
border-color: var(--accent);
}
&:hover {
border-color: var(--accent);
}
}
.img {
width: 42px;
height: 42px;
object-fit: contain;
width: 42px;
height: 42px;
object-fit: contain;
}
.body {
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
padding: 0 0 0 8px;
white-space: nowrap;
overflow: hidden;
}
.name {
text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
}
.info {
opacity: 0.5;
font-size: 0.9em;
text-overflow: ellipsis;
overflow: hidden;
opacity: 0.5;
font-size: 0.9em;
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%;
.emoji-request {
--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;
}
</style>