カスタム絵文字の管理を権限を持つユーザーが行えるように

Resolve #9542
This commit is contained in:
syuilo
2023-01-13 14:58:27 +09:00
parent 0a6e237d09
commit b250456814
21 changed files with 55 additions and 16 deletions

View File

@@ -1,106 +0,0 @@
<template>
<MkModalWindow
ref="dialog"
:width="370"
:with-ok-button="true"
@close="$refs.dialog.close()"
@closed="$emit('closed')"
@ok="ok()"
>
<template #header>:{{ emoji.name }}:</template>
<MkSpacer :margin-min="20" :margin-max="28">
<div class="yigymqpb _gaps_m">
<img :src="`/emoji/${emoji.name}.webp`" class="img"/>
<MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template>
</MkInput>
<MkInput v-model="category" :datalist="categories">
<template #label>{{ i18n.ts.category }}</template>
</MkInput>
<MkInput v-model="aliases">
<template #label>{{ i18n.ts.tags }}</template>
<template #caption>{{ i18n.ts.setMultipleBySeparatingWithSpace }}</template>
</MkInput>
<MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import * as os from '@/os';
import { unique } from '@/scripts/array';
import { i18n } from '@/i18n';
import { getCustomEmojiCategories } from '@/custom-emojis';
const props = defineProps<{
emoji: any,
}>();
let dialog = $ref(null);
let name: string = $ref(props.emoji.name);
let category: string = $ref(props.emoji.category);
let aliases: string = $ref(props.emoji.aliases.join(' '));
const categories = getCustomEmojiCategories();
const emit = defineEmits<{
(ev: 'done', v: { deleted?: boolean, updated?: any }): void,
(ev: 'closed'): void
}>();
function ok() {
update();
}
async function update() {
await os.apiWithDialog('admin/emoji/update', {
id: props.emoji.id,
name,
category,
aliases: aliases.split(' '),
});
emit('done', {
updated: {
id: props.emoji.id,
name,
category,
aliases: aliases.split(' '),
},
});
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" scoped>
.yigymqpb {
> .img {
display: block;
height: 64px;
margin: 0 auto;
}
}
</style>

View File

@@ -1,398 +0,0 @@
<template>
<div>
<MkStickyContainer>
<template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :content-max="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="addTagBulk">Add tag</MkButton>
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
<MkButton inline @click="setTagBulk">Set tag</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>
</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>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, defineComponent, ref, shallowRef } from 'vue';
import XHeader from './_header_.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkPagination from '@/components/MkPagination.vue';
import MkTab from '@/components/MkTab.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSplit from '@/components/form/split.vue';
import { selectFile, selectFiles } 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 = emojisPaginationComponent.value.items.map(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 add = async (ev: MouseEvent) => {
const files = await selectFiles(ev.currentTarget ?? ev.target, null);
const promise = Promise.all(files.map(file => os.api('admin/emoji/add', {
fileId: file.id,
})));
promise.then(() => {
emojisPaginationComponent.value.reload();
});
os.promiseDialog(promise);
};
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((item) => item.id === 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',
text: i18n.ts.export,
action: async () => {
os.api('export-custom-emojis', {
})
.then(() => {
os.alert({
type: 'info',
text: i18n.ts.exportRequested,
});
}).catch((err) => {
os.alert({
type: 'error',
text: err.message,
});
});
},
}, {
icon: 'ti ti-upload',
text: i18n.ts.import,
action: async () => {
const file = await selectFile(ev.currentTarget ?? ev.target);
os.api('admin/emoji/import-zip', {
fileId: file.id,
})
.then(() => {
os.alert({
type: 'info',
text: i18n.ts.importRequested,
});
}).catch((err) => {
os.alert({
type: 'error',
text: err.message,
});
});
},
}], 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 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 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',
text: i18n.ts.addEmoji,
handler: add,
}, {
icon: 'ti ti-dots',
handler: menu,
}]);
const headerTabs = $computed(() => [{
key: 'local',
title: i18n.ts.local,
}, {
key: 'remote',
title: i18n.ts.remote,
}]);
definePageMetadata(computed(() => ({
title: i18n.ts.customEmojis,
icon: 'ti ti-icons',
})));
</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

@@ -90,6 +90,19 @@
</div>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ options_canManageCustomEmojis_useDefault ? i18n.ts._role.useBaseValue : (options_canManageCustomEmojis_value ? i18n.ts.yes : i18n.ts.no) }}</template>
<div class="_gaps">
<MkSwitch v-model="options_canManageCustomEmojis_useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="options_canManageCustomEmojis_value" :disabled="options_canManageCustomEmojis_useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</div>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>{{ options_driveCapacityMb_useDefault ? i18n.ts._role.useBaseValue : (options_driveCapacityMb_value + 'MB') }}</template>
@@ -175,6 +188,8 @@ let options_canPublicNote_useDefault = $ref(role?.options?.canPublicNote?.useDef
let options_canPublicNote_value = $ref(role?.options?.canPublicNote?.value ?? false);
let options_canInvite_useDefault = $ref(role?.options?.canInvite?.useDefault ?? true);
let options_canInvite_value = $ref(role?.options?.canInvite?.value ?? false);
let options_canManageCustomEmojis_useDefault = $ref(role?.options?.canManageCustomEmojis?.useDefault ?? true);
let options_canManageCustomEmojis_value = $ref(role?.options?.canManageCustomEmojis?.value ?? false);
let options_driveCapacityMb_useDefault = $ref(role?.options?.driveCapacityMb?.useDefault ?? true);
let options_driveCapacityMb_value = $ref(role?.options?.driveCapacityMb?.value ?? 0);
let options_antennaLimit_useDefault = $ref(role?.options?.antennaLimit?.useDefault ?? true);
@@ -192,6 +207,7 @@ function getOptions() {
ltlAvailable: { useDefault: options_ltlAvailable_useDefault, value: options_ltlAvailable_value },
canPublicNote: { useDefault: options_canPublicNote_useDefault, value: options_canPublicNote_value },
canInvite: { useDefault: options_canInvite_useDefault, value: options_canInvite_value },
canManageCustomEmojis: { useDefault: options_canManageCustomEmojis_useDefault, value: options_canManageCustomEmojis_value },
driveCapacityMb: { useDefault: options_driveCapacityMb_useDefault, value: options_driveCapacityMb_value },
antennaLimit: { useDefault: options_antennaLimit_useDefault, value: options_antennaLimit_value },
};

View File

@@ -40,6 +40,14 @@
</MkSwitch>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ options_canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="options_canManageCustomEmojis">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder>
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
<template #suffix>{{ options_driveCapacityMb }}MB</template>
@@ -90,6 +98,7 @@ let options_gtlAvailable = $ref(instance.baseRole.gtlAvailable);
let options_ltlAvailable = $ref(instance.baseRole.ltlAvailable);
let options_canPublicNote = $ref(instance.baseRole.canPublicNote);
let options_canInvite = $ref(instance.baseRole.canInvite);
let options_canManageCustomEmojis = $ref(instance.baseRole.canManageCustomEmojis);
let options_driveCapacityMb = $ref(instance.baseRole.driveCapacityMb);
let options_antennaLimit = $ref(instance.baseRole.antennaLimit);
@@ -100,6 +109,7 @@ async function updateBaseRole() {
ltlAvailable: options_ltlAvailable,
canPublicNote: options_canPublicNote,
canInvite: options_canInvite,
canManageCustomEmojis: options_canManageCustomEmojis,
driveCapacityMb: options_driveCapacityMb,
antennaLimit: options_antennaLimit,
},