feat: refine announcement (misskey-dev#11497)
This commit is contained in:
		| @@ -96,7 +96,6 @@ export async function removeAccount(idOrToken: Account['id']) { | ||||
|  | ||||
| function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> { | ||||
| 	return new Promise((done, fail) => { | ||||
| 		// Fetch user | ||||
| 		window.fetch(`${apiUrl}/i`, { | ||||
| 			method: 'POST', | ||||
| 			body: JSON.stringify({ | ||||
| @@ -108,8 +107,8 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr | ||||
| 		}) | ||||
| 			.then(res => new Promise<Account | { error: Record<string, any> }>((done2, fail2) => { | ||||
| 				if (res.status >= 500 && res.status < 600) { | ||||
| 				// サーバーエラー(5xx)の場合をrejectとする | ||||
| 				// (認証エラーなど4xxはresolve) | ||||
| 					// サーバーエラー(5xx)の場合をrejectとする | ||||
| 					// (認証エラーなど4xxはresolve) | ||||
| 					return fail2(res); | ||||
| 				} | ||||
| 				res.json().then(done2, fail2); | ||||
|   | ||||
| @@ -83,6 +83,21 @@ export async function mainBoot() { | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		for (const announcement of ($i.unreadAnnouncements ?? []).filter(x => x.display === 'dialog')) { | ||||
| 			popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { | ||||
| 				announcement, | ||||
| 			}, {}, 'closed'); | ||||
| 		} | ||||
|  | ||||
| 		stream.on('announcementCreated', (ev) => { | ||||
| 			const announcement = ev.announcement; | ||||
| 			if (announcement.display === 'dialog') { | ||||
| 				popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), { | ||||
| 					announcement, | ||||
| 				}, {}, 'closed'); | ||||
| 			} | ||||
| 		}); | ||||
|  | ||||
| 		if ($i.isDeleted) { | ||||
| 			alert({ | ||||
| 				type: 'warning', | ||||
|   | ||||
| @@ -0,0 +1,47 @@ | ||||
| /* | ||||
|  * SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
|  * SPDX-License-Identifier: AGPL-3.0-only | ||||
|  */ | ||||
|  | ||||
| /* eslint-disable @typescript-eslint/explicit-function-return-type */ | ||||
| import { StoryObj } from '@storybook/vue3'; | ||||
| import MkAnnouncementDialog from './MkAnnouncementDialog.vue'; | ||||
| export const Default = { | ||||
| 	render(args) { | ||||
| 		return { | ||||
| 			components: { | ||||
| 				MkAnnouncementDialog, | ||||
| 			}, | ||||
| 			setup() { | ||||
| 				return { | ||||
| 					args, | ||||
| 				}; | ||||
| 			}, | ||||
| 			computed: { | ||||
| 				props() { | ||||
| 					return { | ||||
| 						...this.args, | ||||
| 					}; | ||||
| 				}, | ||||
| 			}, | ||||
| 			template: '<MkAnnouncementDialog v-bind="props" />', | ||||
| 		}; | ||||
| 	}, | ||||
| 	args: { | ||||
| 		announcement: { | ||||
| 			id: '1', | ||||
| 			title: 'Title', | ||||
| 			text: 'Text', | ||||
| 			createdAt: new Date().toISOString(), | ||||
| 			updatedAt: null, | ||||
| 			icon: 'info', | ||||
| 			imageUrl: null, | ||||
| 			display: 'dialog', | ||||
| 			needConfirmationToRead: false, | ||||
| 			forYou: true, | ||||
| 		}, | ||||
| 	}, | ||||
| 	parameters: { | ||||
| 		layout: 'centered', | ||||
| 	}, | ||||
| } satisfies StoryObj<typeof MkAnnouncementDialog>; | ||||
							
								
								
									
										104
									
								
								packages/frontend/src/components/MkAnnouncementDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								packages/frontend/src/components/MkAnnouncementDialog.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,104 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick"> | ||||
| 	<div ref="rootEl" :class="$style.root"> | ||||
| 		<div :class="$style.header"> | ||||
| 			<span :class="$style.icon"> | ||||
| 				<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 				<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 				<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 				<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 			</span> | ||||
| 			<span :class="$style.title">{{ announcement.title }}</span> | ||||
| 		</div> | ||||
| 		<div :class="$style.text"><Mfm :text="announcement.text"/></div> | ||||
| 		<MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton> | ||||
| 	</div> | ||||
| </MkModal> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, shallowRef } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import * as os from '@/os'; | ||||
| import MkModal from '@/components/MkModal.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { $i, updateAccount } from '@/account'; | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	announcement: misskey.entities.Announcement; | ||||
| }>(), { | ||||
| }); | ||||
|  | ||||
| const rootEl = shallowRef<HTMLDivElement>(); | ||||
| const modal = shallowRef<InstanceType<typeof MkModal>>(); | ||||
|  | ||||
| async function ok() { | ||||
| 	if (props.announcement.needConfirmationToRead) { | ||||
| 		const confirm = await os.confirm({ | ||||
| 			type: 'question', | ||||
| 			title: i18n.ts._announcement.readConfirmTitle, | ||||
| 			text: i18n.t('_announcement.readConfirmText', { title: props.announcement.title }), | ||||
| 		}); | ||||
| 		if (confirm.canceled) return; | ||||
| 	} | ||||
|  | ||||
| 	modal.value.close(); | ||||
| 	os.api('i/read-announcement', { announcementId: props.announcement.id }); | ||||
| 	updateAccount({ | ||||
| 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== props.announcement.id), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function onBgClick() { | ||||
| 	rootEl.value.animate([{ | ||||
| 		offset: 0, | ||||
| 		transform: 'scale(1)', | ||||
| 	}, { | ||||
| 		offset: 0.5, | ||||
| 		transform: 'scale(1.1)', | ||||
| 	}, { | ||||
| 		offset: 1, | ||||
| 		transform: 'scale(1)', | ||||
| 	}], { | ||||
| 		duration: 100, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	margin: auto; | ||||
| 	position: relative; | ||||
| 	padding: 32px; | ||||
| 	min-width: 320px; | ||||
| 	max-width: 480px; | ||||
| 	box-sizing: border-box; | ||||
| 	background: var(--panel); | ||||
| 	border-radius: var(--radius); | ||||
| } | ||||
|  | ||||
| .header { | ||||
| 	font-size: 120%; | ||||
| } | ||||
|  | ||||
| .icon { | ||||
| 	margin-right: 0.5em; | ||||
| } | ||||
|  | ||||
| .title { | ||||
| 	font-weight: bold; | ||||
| } | ||||
|  | ||||
| .text { | ||||
| 	margin: 1em 0; | ||||
| } | ||||
| </style> | ||||
| @@ -172,7 +172,6 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	note: misskey.entities.Note; | ||||
| 	pinned?: boolean; | ||||
| }>(); | ||||
|  | ||||
| const inChannel = inject('inChannel', null); | ||||
|   | ||||
| @@ -37,7 +37,6 @@ import { userPage } from '@/filters/user'; | ||||
|  | ||||
| defineProps<{ | ||||
| 	note: misskey.entities.Note; | ||||
| 	pinned?: boolean; | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -31,7 +31,6 @@ import { $i } from '@/account'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	note: misskey.entities.Note; | ||||
| 	pinned?: boolean; | ||||
| }>(); | ||||
|  | ||||
| const showContent = $ref(false); | ||||
|   | ||||
| @@ -17,7 +17,12 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	</span> | ||||
| 	<span :class="$style.body"> | ||||
| 		<!-- TODO: 無名slotの方は廃止 --> | ||||
| 		<span :class="$style.label" @click="toggle"><slot name="label"></slot><slot></slot></span> | ||||
| 		<span :class="$style.label"> | ||||
| 			<span @click="toggle"> | ||||
| 				<slot name="label"></slot><slot></slot> | ||||
| 			</span> | ||||
| 			<span v-if="helpText" v-tooltip:dialog="helpText" class="_button _help" :class="$style.help"><i class="ti ti-help-circle"></i></span> | ||||
| 		</span> | ||||
| 		<p :class="$style.caption"><slot name="caption"></slot></p> | ||||
| 	</span> | ||||
| </div> | ||||
| @@ -30,6 +35,7 @@ import { i18n } from '@/i18n'; | ||||
| const props = defineProps<{ | ||||
| 	modelValue: boolean | Ref<boolean>; | ||||
| 	disabled?: boolean; | ||||
| 	helpText?: string; | ||||
| }>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| @@ -41,10 +47,6 @@ const checked = toRefs(props).modelValue; | ||||
| const toggle = () => { | ||||
| 	if (props.disabled) return; | ||||
| 	emit('update:modelValue', !checked.value); | ||||
|  | ||||
| 	if (!checked.value) { | ||||
|  | ||||
| 	} | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| @@ -140,4 +142,10 @@ const toggle = () => { | ||||
| 		display: none; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .help { | ||||
| 	margin-left: 0.5em; | ||||
| 	font-size: 85%; | ||||
| 	vertical-align: top; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -0,0 +1,145 @@ | ||||
| <!-- | ||||
| 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="announcement" #header>:{{ announcement.title }}:</template> | ||||
| 	<template v-else #header>New announcement</template> | ||||
|  | ||||
| 	<div> | ||||
| 		<MkSpacer :marginMin="20" :marginMax="28"> | ||||
| 			<div class="_gaps_m"> | ||||
| 				<MkInput v-model="title"> | ||||
| 					<template #label>{{ i18n.ts.title }}</template> | ||||
| 				</MkInput> | ||||
| 				<MkTextarea v-model="text"> | ||||
| 					<template #label>{{ i18n.ts.text }}</template> | ||||
| 				</MkTextarea> | ||||
| 				<MkRadios v-model="icon"> | ||||
| 					<template #label>{{ i18n.ts.icon }}</template> | ||||
| 					<option value="info"><i class="ti ti-info-circle"></i></option> | ||||
| 					<option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> | ||||
| 					<option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> | ||||
| 					<option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> | ||||
| 				</MkRadios> | ||||
| 				<MkRadios v-model="display"> | ||||
| 					<template #label>{{ i18n.ts.display }}</template> | ||||
| 					<option value="normal">{{ i18n.ts.normal }}</option> | ||||
| 					<option value="banner">{{ i18n.ts.banner }}</option> | ||||
| 					<option value="dialog">{{ i18n.ts.dialog }}</option> | ||||
| 				</MkRadios> | ||||
| 				<MkSwitch v-model="needConfirmationToRead"> | ||||
| 					{{ i18n.ts._announcement.needConfirmationToRead }} | ||||
| 					<template #caption>{{ i18n.ts._announcement.needConfirmationToReadDescription }}</template> | ||||
| 				</MkSwitch> | ||||
| 				<MkButton v-if="announcement" 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.announcement ? i18n.ts.update : i18n.ts.create }}</MkButton> | ||||
| 		</div> | ||||
| 	</div> | ||||
| </MkModalWindow> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { } 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 * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import MkRadios from '@/components/MkRadios.vue'; | ||||
|  | ||||
| const props = defineProps<{ | ||||
| 	user: misskey.entities.User, | ||||
| 	announcement?: any, | ||||
| }>(); | ||||
|  | ||||
| let dialog = $ref(null); | ||||
| let title: string = $ref(props.announcement ? props.announcement.title : ''); | ||||
| let text: string = $ref(props.announcement ? props.announcement.text : ''); | ||||
| let icon: string = $ref(props.announcement ? props.announcement.icon : 'info'); | ||||
| let display: string = $ref(props.announcement ? props.announcement.display : 'dialog'); | ||||
| let needConfirmationToRead = $ref(props.announcement ? props.announcement.needConfirmationToRead : false); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| 	(ev: 'done', v: { deleted?: boolean; updated?: any; created?: any }): void, | ||||
| 	(ev: 'closed'): void | ||||
| }>(); | ||||
|  | ||||
| async function done() { | ||||
| 	const params = { | ||||
| 		title: title, | ||||
| 		text: text, | ||||
| 		icon: icon, | ||||
| 		imageUrl: null, | ||||
| 		display: display, | ||||
| 		needConfirmationToRead: needConfirmationToRead, | ||||
| 		userId: props.user.id, | ||||
| 	}; | ||||
|  | ||||
| 	if (props.announcement) { | ||||
| 		await os.apiWithDialog('admin/announcements/update', { | ||||
| 			id: props.announcement.id, | ||||
| 			...params, | ||||
| 		}); | ||||
|  | ||||
| 		emit('done', { | ||||
| 			updated: { | ||||
| 				id: props.announcement.id, | ||||
| 				...params, | ||||
| 			}, | ||||
| 		}); | ||||
|  | ||||
| 		dialog.close(); | ||||
| 	} else { | ||||
| 		const created = await os.apiWithDialog('admin/announcements/create', params); | ||||
|  | ||||
| 		emit('done', { | ||||
| 			created: created, | ||||
| 		}); | ||||
|  | ||||
| 		dialog.close(); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| async function del() { | ||||
| 	const { canceled } = await os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.t('removeAreYouSure', { x: title }), | ||||
| 	}); | ||||
| 	if (canceled) return; | ||||
|  | ||||
| 	os.api('admin/announcements/delete', { | ||||
| 		id: props.announcement.id, | ||||
| 	}).then(() => { | ||||
| 		emit('done', { | ||||
| 			deleted: true, | ||||
| 		}); | ||||
| 		dialog.close(); | ||||
| 	}); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .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> | ||||
| @@ -7,9 +7,20 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| <MkStickyContainer> | ||||
| 	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="900"> | ||||
| 		<div class="_gaps_m"> | ||||
| 			<section v-for="announcement in announcements" class=""> | ||||
| 				<div class="_panel _gaps_m" style="padding: 24px;"> | ||||
| 		<div class="_gaps"> | ||||
| 			<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> | ||||
|  | ||||
| 			<MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null"> | ||||
| 				<template #label>{{ announcement.title }}</template> | ||||
| 				<template #icon> | ||||
| 					<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 					<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 					<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 					<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 				</template> | ||||
| 				<template #caption>{{ announcement.text }}</template> | ||||
|  | ||||
| 				<div class="_gaps_m"> | ||||
| 					<MkInput v-model="announcement.title"> | ||||
| 						<template #label>{{ i18n.ts.title }}</template> | ||||
| 					</MkInput> | ||||
| @@ -19,13 +30,33 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 					<MkInput v-model="announcement.imageUrl"> | ||||
| 						<template #label>{{ i18n.ts.imageUrl }}</template> | ||||
| 					</MkInput> | ||||
| 					<MkRadios v-model="announcement.icon"> | ||||
| 						<template #label>{{ i18n.ts.icon }}</template> | ||||
| 						<option value="info"><i class="ti ti-info-circle"></i></option> | ||||
| 						<option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> | ||||
| 						<option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> | ||||
| 						<option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> | ||||
| 					</MkRadios> | ||||
| 					<MkRadios v-model="announcement.display"> | ||||
| 						<template #label>{{ i18n.ts.display }}</template> | ||||
| 						<option value="normal">{{ i18n.ts.normal }}</option> | ||||
| 						<option value="banner">{{ i18n.ts.banner }}</option> | ||||
| 						<option value="dialog">{{ i18n.ts.dialog }}</option> | ||||
| 					</MkRadios> | ||||
| 					<MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> | ||||
| 						{{ i18n.ts._announcement.forExistingUsers }} | ||||
| 					</MkSwitch> | ||||
| 					<MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> | ||||
| 						{{ i18n.ts._announcement.needConfirmationToRead }} | ||||
| 					</MkSwitch> | ||||
| 					<p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> | ||||
| 					<div class="buttons _buttons"> | ||||
| 						<MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> | ||||
| 						<MkButton class="button" inline danger @click="remove(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton> | ||||
| 						<MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> | ||||
| 						<MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 			</section> | ||||
| 			</MkFolder> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| @@ -37,9 +68,13 @@ import XHeader from './_header_.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInput from '@/components/MkInput.vue'; | ||||
| import MkTextarea from '@/components/MkTextarea.vue'; | ||||
| import MkSwitch from '@/components/MkSwitch.vue'; | ||||
| import MkRadios from '@/components/MkRadios.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import MkFolder from '@/components/MkFolder.vue'; | ||||
|  | ||||
| let announcements: any[] = $ref([]); | ||||
|  | ||||
| @@ -49,17 +84,22 @@ os.api('admin/announcements/list').then(announcementResponse => { | ||||
|  | ||||
| function add() { | ||||
| 	announcements.unshift({ | ||||
| 		_id: Math.random().toString(36), | ||||
| 		id: null, | ||||
| 		title: '', | ||||
| 		title: 'New announcement', | ||||
| 		text: '', | ||||
| 		imageUrl: null, | ||||
| 		icon: 'info', | ||||
| 		display: 'normal', | ||||
| 		forExistingUsers: false, | ||||
| 		needConfirmationToRead: false, | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function remove(announcement) { | ||||
| function del(announcement) { | ||||
| 	os.confirm({ | ||||
| 		type: 'warning', | ||||
| 		text: i18n.t('removeAreYouSure', { x: announcement.title }), | ||||
| 		text: i18n.t('deleteAreYouSure', { x: announcement.title }), | ||||
| 	}).then(({ canceled }) => { | ||||
| 		if (canceled) return; | ||||
| 		announcements = announcements.filter(x => x !== announcement); | ||||
| @@ -67,32 +107,20 @@ function remove(announcement) { | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| function save(announcement) { | ||||
| async function archive(announcement) { | ||||
| 	await os.apiWithDialog('admin/announcements/update', { | ||||
| 		...announcement, | ||||
| 		isActive: false, | ||||
| 	}); | ||||
| 	refresh(); | ||||
| } | ||||
|  | ||||
| async function save(announcement) { | ||||
| 	if (announcement.id == null) { | ||||
| 		os.api('admin/announcements/create', announcement).then(() => { | ||||
| 			os.alert({ | ||||
| 				type: 'success', | ||||
| 				text: i18n.ts.saved, | ||||
| 			}); | ||||
| 			refresh(); | ||||
| 		}).catch(err => { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: err, | ||||
| 			}); | ||||
| 		}); | ||||
| 		await os.apiWithDialog('admin/announcements/create', announcement); | ||||
| 		refresh(); | ||||
| 	} else { | ||||
| 		os.api('admin/announcements/update', announcement).then(() => { | ||||
| 			os.alert({ | ||||
| 				type: 'success', | ||||
| 				text: i18n.ts.saved, | ||||
| 			}); | ||||
| 		}).catch(err => { | ||||
| 			os.alert({ | ||||
| 				type: 'error', | ||||
| 				text: err, | ||||
| 			}); | ||||
| 		}); | ||||
| 		os.apiWithDialog('admin/announcements/update', announcement); | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -5,20 +5,36 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
|  | ||||
| <template> | ||||
| <MkStickyContainer> | ||||
| 	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> | ||||
| 	<MkSpacer :contentMax="800"> | ||||
| 		<MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _gaps_m"> | ||||
| 			<section v-for="(announcement, i) in items" :key="announcement.id" class="announcement _panel"> | ||||
| 				<div class="header"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> | ||||
| 				<div class="content"> | ||||
| 					<Mfm :text="announcement.text"/> | ||||
| 					<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| 				</div> | ||||
| 				<div v-if="$i && !announcement.isRead" class="footer"> | ||||
| 					<MkButton primary @click="read(items, announcement, i)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> | ||||
| 				</div> | ||||
| 			</section> | ||||
| 		</MkPagination> | ||||
| 		<div class="_gaps"> | ||||
| 			<MkInfo v-if="$i && $i.hasUnreadAnnouncement && tab === 'current'" warn>{{ i18n.ts.youHaveUnreadAnnouncements }}</MkInfo> | ||||
| 			<MkPagination ref="paginationEl" :key="tab" v-slot="{items}" :pagination="tab === 'current' ? paginationCurrent : paginationPast" class="_gaps"> | ||||
| 				<section v-for="announcement in items" :key="announcement.id" class="_panel" :class="$style.announcement"> | ||||
| 					<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div> | ||||
| 					<div :class="$style.header"> | ||||
| 						<span v-if="$i && !announcement.isRead" style="margin-right: 0.5em;">🆕</span> | ||||
| 						<span style="margin-right: 0.5em;"> | ||||
| 							<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 							<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 							<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 							<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 						</span> | ||||
| 						<span>{{ announcement.title }}</span> | ||||
| 					</div> | ||||
| 					<div :class="$style.content"> | ||||
| 						<Mfm :text="announcement.text"/> | ||||
| 						<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> | ||||
| 						<div style="opacity: 0.7; font-size: 85%;"> | ||||
| 							<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div v-if="tab !== 'past' && $i && !announcement.isRead" :class="$style.footer"> | ||||
| 						<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton> | ||||
| 					</div> | ||||
| 				</section> | ||||
| 			</MkPagination> | ||||
| 		</div> | ||||
| 	</MkSpacer> | ||||
| </MkStickyContainer> | ||||
| </template> | ||||
| @@ -27,28 +43,64 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| import { } from 'vue'; | ||||
| import MkPagination from '@/components/MkPagination.vue'; | ||||
| import MkButton from '@/components/MkButton.vue'; | ||||
| import MkInfo from '@/components/MkInfo.vue'; | ||||
| import * as os from '@/os'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { $i } from '@/account'; | ||||
| import { $i, updateAccount } from '@/account'; | ||||
|  | ||||
| const pagination = { | ||||
| const paginationCurrent = { | ||||
| 	endpoint: 'announcements' as const, | ||||
| 	limit: 10, | ||||
| 	params: { | ||||
| 		isActive: true, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい | ||||
| function read(items, announcement, i) { | ||||
| 	items[i] = { | ||||
| 		...announcement, | ||||
| 		isRead: true, | ||||
| 	}; | ||||
| const paginationPast = { | ||||
| 	endpoint: 'announcements' as const, | ||||
| 	limit: 10, | ||||
| 	params: { | ||||
| 		isActive: false, | ||||
| 	}, | ||||
| }; | ||||
|  | ||||
| const paginationEl = ref<InstanceType<typeof MkPagination>>(); | ||||
|  | ||||
| const tab = ref('current'); | ||||
|  | ||||
| async function read(announcement) { | ||||
| 	if (announcement.needConfirmationToRead) { | ||||
| 		const confirm = await os.confirm({ | ||||
| 			type: 'question', | ||||
| 			title: i18n.ts._announcement.readConfirmTitle, | ||||
| 			text: i18n.t('_announcement.readConfirmText', { title: announcement.title }), | ||||
| 		}); | ||||
| 		if (confirm.canceled) return; | ||||
| 	} | ||||
|  | ||||
| 	if (!paginationEl.value) return; | ||||
| 	paginationEl.value.updateItem(announcement.id, a => { | ||||
| 		a.isRead = true; | ||||
| 		return a; | ||||
| 	}); | ||||
| 	os.api('i/read-announcement', { announcementId: announcement.id }); | ||||
| 	updateAccount({ | ||||
| 		unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id), | ||||
| 	}); | ||||
| } | ||||
|  | ||||
| const headerActions = $computed(() => []); | ||||
|  | ||||
| const headerTabs = $computed(() => []); | ||||
| const headerTabs = $computed(() => [{ | ||||
| 	key: 'current', | ||||
| 	title: i18n.ts.currentAnnouncements, | ||||
| 	icon: 'ti ti-flare', | ||||
| }, { | ||||
| 	key: 'past', | ||||
| 	title: i18n.ts.pastAnnouncements, | ||||
| 	icon: 'ti ti-point', | ||||
| }]); | ||||
|  | ||||
| definePageMetadata({ | ||||
| 	title: i18n.ts.announcements, | ||||
| @@ -56,27 +108,34 @@ definePageMetadata({ | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .ruryvtyk { | ||||
| 	> .announcement { | ||||
| 		padding: 16px; | ||||
| <style lang="scss" module> | ||||
| .announcement { | ||||
| 	padding: 16px; | ||||
| } | ||||
|  | ||||
| 		> .header { | ||||
| 			margin-bottom: 16px; | ||||
| 			font-weight: bold; | ||||
| 		} | ||||
| .forYou { | ||||
| 	display: flex; | ||||
| 	align-items: center; | ||||
| 	line-height: 24px; | ||||
| 	font-size: 90%; | ||||
| 	white-space: pre; | ||||
| 	color: #d28a3f; | ||||
| } | ||||
|  | ||||
| 		> .content { | ||||
| 			> img { | ||||
| 				display: block; | ||||
| 				max-height: 300px; | ||||
| 				max-width: 100%; | ||||
| 			} | ||||
| 		} | ||||
| .header { | ||||
| 	margin-bottom: 16px; | ||||
| 	font-weight: bold; | ||||
| } | ||||
|  | ||||
| 		> .footer { | ||||
| 			margin-top: 16px; | ||||
| 		} | ||||
| .content { | ||||
| 	> img { | ||||
| 		display: block; | ||||
| 		max-height: 300px; | ||||
| 		max-width: 100%; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .footer { | ||||
| 	margin-top: 16px; | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -80,7 +80,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 				</MkFolder> | ||||
| 				<MkSwitch v-model="isSensitive">isSensitive</MkSwitch> | ||||
| 				<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch> | ||||
| 				<MkButton danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 				<MkButton v-if="emoji" danger @click="del()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> | ||||
| 			</div> | ||||
| 		</MkSpacer> | ||||
| 		<div :class="$style.footer"> | ||||
|   | ||||
| @@ -86,7 +86,7 @@ const tagUsersPagination = $computed(() => ({ | ||||
| 	endpoint: 'hashtags/users' as const, | ||||
| 	limit: 30, | ||||
| 	params: { | ||||
| 		tag: this.tag, | ||||
| 		tag: props.tag, | ||||
| 		origin: 'combined', | ||||
| 		sort: '+follower', | ||||
| 	}, | ||||
|   | ||||
| @@ -133,6 +133,31 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 					</div> | ||||
| 				</MkFolder> | ||||
|  | ||||
| 				<MkFolder v-if="user.host == null && iAmModerator"> | ||||
| 					<template #icon><i class="ti ti-speakerphone"></i></template> | ||||
| 					<template #label>{{ i18n.ts.announcements }}</template> | ||||
| 					<div class="_gaps"> | ||||
| 						<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> | ||||
|  | ||||
| 						<MkPagination :pagination="announcementsPagination"> | ||||
| 							<template #default="{ items }"> | ||||
| 								<div class="_gaps_s"> | ||||
| 									<div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> | ||||
| 										<span style="margin-right: 0.5em;"> | ||||
| 											<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 											<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 											<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 											<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 										</span> | ||||
| 										<span>{{ announcement.title }}</span> | ||||
| 										<span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span> | ||||
| 									</div> | ||||
| 								</div> | ||||
| 							</template> | ||||
| 						</MkPagination> | ||||
| 					</div> | ||||
| 				</MkFolder> | ||||
|  | ||||
| 				<MkFolder> | ||||
| 					<template #icon><i class="ti ti-password"></i></template> | ||||
| 					<template #label>IP</template> | ||||
| @@ -186,7 +211,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { computed, watch } from 'vue'; | ||||
| import { computed, defineAsyncComponent, watch } from 'vue'; | ||||
| import * as misskey from 'misskey-js'; | ||||
| import MkChart from '@/components/MkChart.vue'; | ||||
| import MkObjectView from '@/components/MkObjectView.vue'; | ||||
| @@ -208,6 +233,7 @@ import { definePageMetadata } from '@/scripts/page-metadata'; | ||||
| import { i18n } from '@/i18n'; | ||||
| import { iAmAdmin, iAmModerator, $i } from '@/account'; | ||||
| import MkRolePreview from '@/components/MkRolePreview.vue'; | ||||
| import MkPagination, { Paging } from '@/components/MkPagination.vue'; | ||||
|  | ||||
| const props = withDefaults(defineProps<{ | ||||
| 	userId: string; | ||||
| @@ -235,6 +261,13 @@ const filesPagination = { | ||||
| 		userId: props.userId, | ||||
| 	})), | ||||
| }; | ||||
| const announcementsPagination = { | ||||
| 	endpoint: 'admin/announcements/list' as const, | ||||
| 	limit: 10, | ||||
| 	params: computed(() => ({ | ||||
| 		userId: props.userId, | ||||
| 	})), | ||||
| }; | ||||
| let expandedRoles = $ref([]); | ||||
|  | ||||
| function createFetcher() { | ||||
| @@ -409,6 +442,19 @@ function toggleRoleItem(role) { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| function createAnnouncement() { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { | ||||
| 		user, | ||||
| 	}, {}, 'closed'); | ||||
| } | ||||
|  | ||||
| function editAnnouncement(announcement) { | ||||
| 	os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { | ||||
| 		user, | ||||
| 		announcement, | ||||
| 	}, {}, 'closed'); | ||||
| } | ||||
|  | ||||
| watch(() => props.userId, () => { | ||||
| 	init = createFetcher(); | ||||
| }, { | ||||
| @@ -577,4 +623,11 @@ definePageMetadata(computed(() => ({ | ||||
| 	margin-left: 8px; | ||||
| 	align-self: center; | ||||
| } | ||||
|  | ||||
| .announcementItem { | ||||
| 	display: flex; | ||||
| 	padding: 8px 12px; | ||||
| 	border-radius: 6px; | ||||
| 	cursor: pointer; | ||||
| } | ||||
| </style> | ||||
|   | ||||
							
								
								
									
										80
									
								
								packages/frontend/src/ui/_common_/announcements.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								packages/frontend/src/ui/_common_/announcements.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | ||||
| <!-- | ||||
| SPDX-FileCopyrightText: syuilo and other misskey contributors | ||||
| SPDX-License-Identifier: AGPL-3.0-only | ||||
| --> | ||||
|  | ||||
| <template> | ||||
| <div :class="$style.root"> | ||||
| 	<MkA | ||||
| 		v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')" | ||||
| 		:key="announcement.id" | ||||
| 		:class="$style.item" | ||||
| 		to="/announcements" | ||||
| 	> | ||||
| 		<span :class="$style.icon"> | ||||
| 			<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> | ||||
| 			<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> | ||||
| 			<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> | ||||
| 			<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> | ||||
| 		</span> | ||||
| 		<span :class="$style.title">{{ announcement.title }}</span> | ||||
| 		<span :class="$style.body">{{ announcement.text }}</span> | ||||
| 	</MkA> | ||||
| </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { $i } from '@/account'; | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" module> | ||||
| .root { | ||||
| 	font-size: 15px; | ||||
| 	background: var(--panel); | ||||
| } | ||||
|  | ||||
| .item { | ||||
| 	--height: 24px; | ||||
| 	font-size: 0.85em; | ||||
|  | ||||
| 	display: flex; | ||||
| 	vertical-align: bottom; | ||||
| 	width: 100%; | ||||
| 	line-height: var(--height); | ||||
| 	height: var(--height); | ||||
| 	overflow: clip; | ||||
| 	contain: strict; | ||||
| 	background: var(--accent); | ||||
| 	color: var(--fgOnAccent); | ||||
|  | ||||
| 	@container (max-width: 1000px) { | ||||
| 		display: block; | ||||
| 		text-align: center; | ||||
|  | ||||
| 		> .body { | ||||
| 			display: none; | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .icon { | ||||
| 	margin-left: 10px; | ||||
| } | ||||
|  | ||||
| .title { | ||||
| 	padding: 0 10px; | ||||
| 	font-weight: bold; | ||||
|  | ||||
| 	&:empty { | ||||
| 		display: none; | ||||
| 	} | ||||
| } | ||||
|  | ||||
| .body { | ||||
| 	min-width: 0; | ||||
| 	flex: 1; | ||||
| 	overflow: clip; | ||||
| 	white-space: nowrap; | ||||
| 	text-overflow: ellipsis; | ||||
| } | ||||
| </style> | ||||
| @@ -8,6 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<XSidebar v-if="!isMobile"/> | ||||
|  | ||||
| 	<div :class="$style.main"> | ||||
| 		<XAnnouncements v-if="$i" :class="$style.announcements"/> | ||||
| 		<XStatusBars/> | ||||
| 		<div ref="columnsEl" :class="[$style.sections, { [$style.center]: deckStore.reactiveState.columnAlign.value === 'center', [$style.snapScroll]: snapScroll }]" @contextmenu.self.prevent="onContextmenu" @wheel.self="onWheel"> | ||||
| 			<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> | ||||
| @@ -113,6 +114,7 @@ import XMentionsColumn from '@/ui/deck/mentions-column.vue'; | ||||
| import XDirectColumn from '@/ui/deck/direct-column.vue'; | ||||
| import XRoleTimelineColumn from '@/ui/deck/role-timeline-column.vue'; | ||||
| const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); | ||||
| const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); | ||||
|  | ||||
| const columnComponents = { | ||||
| 	main: XMainColumn, | ||||
|   | ||||
| @@ -8,7 +8,12 @@ SPDX-License-Identifier: AGPL-3.0-only | ||||
| 	<XSidebar v-if="!isMobile" :class="$style.sidebar"/> | ||||
|  | ||||
| 	<MkStickyContainer ref="contents" :class="$style.contents" style="container-type: inline-size;" @contextmenu.stop="onContextmenu"> | ||||
| 		<template #header><XStatusBars :class="$style.statusbars"/></template> | ||||
| 		<template #header> | ||||
| 			<div> | ||||
| 				<XAnnouncements v-if="$i" :class="$style.announcements"/> | ||||
| 				<XStatusBars :class="$style.statusbars"/> | ||||
| 			</div> | ||||
| 		</template> | ||||
| 		<RouterView/> | ||||
| 		<div :class="$style.spacer"></div> | ||||
| 	</MkStickyContainer> | ||||
| @@ -105,6 +110,7 @@ import { useScrollPositionManager } from '@/nirax'; | ||||
| const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); | ||||
| const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/navbar.vue')); | ||||
| const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); | ||||
| const XAnnouncements = defineAsyncComponent(() => import('@/ui/_common_/announcements.vue')); | ||||
|  | ||||
| const DESKTOP_THRESHOLD = 1100; | ||||
| const MOBILE_THRESHOLD = 500; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 まっちゃとーにゅ
					まっちゃとーにゅ