diff options
Diffstat (limited to 'packages/frontend')
| -rw-r--r-- | packages/frontend/.storybook/fakes.ts | 24 | ||||
| -rw-r--r-- | packages/frontend/.storybook/generate.tsx | 1 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkInviteCode.stories.impl.ts | 60 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkInviteCode.vue | 123 | ||||
| -rw-r--r-- | packages/frontend/src/const.ts | 3 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/index.vue | 11 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/invites.vue | 126 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.editor.vue | 59 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.vue | 23 | ||||
| -rw-r--r-- | packages/frontend/src/pages/invite.vue | 114 | ||||
| -rw-r--r-- | packages/frontend/src/router.ts | 8 | ||||
| -rw-r--r-- | packages/frontend/src/ui/_common_/common.ts | 25 |
12 files changed, 556 insertions, 21 deletions
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index 5fd21cdf0a..a4289cff7d 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi url: null, }; } + +export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) { + const date = new Date(); + const createdAt = new Date(); + createdAt.setDate(date.getDate() - 1) + const expiresAt = new Date(); + + if (isExpired) { + expiresAt.setHours(date.getHours() - 1) + } else { + expiresAt.setHours(date.getHours() + 1) + } + + return { + id: "9gyqzizw77", + code: "SLF3JKF7UV2H9", + expiresAt: hasExpiration ? expiresAt.toISOString() : null, + createdAt: createdAt.toISOString(), + createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'), + usedBy: isUsed ? userDetailed('3i3r2znx1v') : null, + usedAt: isUsed ? date.toISOString() : null, + used: isUsed, + } +} diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index b3d7bd8f5e..d47d8672c7 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -403,6 +403,7 @@ function toStories(component: string): Promise<string> { glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), glob('src/components/MkUserSetupDialog.*.vue'), + glob('src/components/MkInviteCode.vue'), glob('src/pages/user/home.vue'), ]); const components = globs.flat(); diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts new file mode 100644 index 0000000000..def0a96e6a --- /dev/null +++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts @@ -0,0 +1,60 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed, inviteCode } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkInviteCode from './MkInviteCode.vue'; + +export const Default = { + render(args) { + return { + components: { + MkInviteCode, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkInviteCode v-bind="props" />', + }; + }, + args: { + invite: inviteCode() as any, + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/show', (req, res, ctx) => { + return res(ctx.json(userDetailed(req.params.userId as string))); + }), + ], + }, + }, + decorators: [() => ({ + template: '<div style="width:100cqmin"><story/></div>', + })], +} satisfies StoryObj<typeof MkInviteCode>; + +export const Used = { + ...Default, + args: { + invite: inviteCode(true) as any + }, +} satisfies StoryObj<typeof MkInviteCode>; + +export const Expired = { + ...Default, + args: { + invite: inviteCode(false, true, true) as any + }, +} satisfies StoryObj<typeof MkInviteCode>; diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue new file mode 100644 index 0000000000..fdde79b178 --- /dev/null +++ b/packages/frontend/src/components/MkInviteCode.vue @@ -0,0 +1,123 @@ +<template> +<MkFolder> + <template #label>{{ invite.code }}</template> + <template #suffix> + <span v-if="invite.used">{{ i18n.ts.used }}</span> + <span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span> + <span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span> + </template> + + <div class="_gaps_s" :class="$style.root"> + <div :class="$style.items"> + <div> + <div :class="$style.label">{{ i18n.ts.invitationCode }}</div> + <div>{{ invite.code }}</div> + </div> + <div v-if="moderator"> + <div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div> + <div v-if="invite.createdBy" :class="$style.user"> + <MkAvatar :user="invite.createdBy" :class="$style.avatar" link preview/> + <MkUserName :user="invite.createdBy" :nowrap="false"/> + <div v-if="moderator">({{ invite.createdBy.id }})</div> + </div> + <div v-else>system</div> + </div> + <div v-if="invite.used"> + <div :class="$style.label">{{ i18n.ts.registeredUserUsingInviteCode }}</div> + <div v-if="invite.usedBy" :class="$style.user"> + <MkAvatar :user="invite.usedBy" :class="$style.avatar" link preview/> + <MkUserName :user="invite.usedBy" :nowrap="false"/> + <div v-if="moderator">({{ invite.usedBy.id }})</div> + </div> + <div v-else>{{ i18n.ts.unknown }} ({{ i18n.ts.waitingForMailAuth }})</div> + </div> + <div v-if="invite.expiresAt && !invite.used"> + <div :class="$style.label">{{ i18n.ts.expirationDate }}</div> + <div><MkTime :time="invite.expiresAt" mode="absolute"/></div> + </div> + <div v-if="invite.usedAt"> + <div :class="$style.label">{{ i18n.ts.inviteCodeUsedAt }}</div> + <div><MkTime :time="invite.usedAt" mode="absolute"/></div> + </div> + <div v-if="moderator"> + <div :class="$style.label">{{ i18n.ts.createdAt }}</div> + <div><MkTime :time="invite.createdAt" mode="absolute"/></div> + </div> + </div> + <div :class="$style.buttons"> + <MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()">{{ i18n.ts.copy }}</MkButton> + <MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()">{{ i18n.ts.delete }}</MkButton> + </div> + </div> +</MkFolder> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; +import MkFolder from '@/components/MkFolder.vue'; +import MkButton from '@/components/MkButton.vue'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +const props = defineProps<{ + invite: misskey.entities.Invite; + moderator?: boolean; +}>(); + +const emits = defineEmits<{ + (event: 'deleted', value: string): void; +}>(); + +const isExpired = computed(() => { + return props.invite.expiresAt && new Date(props.invite.expiresAt) < new Date(); +}); + +function deleteCode() { + os.apiWithDialog('invite/delete', { + inviteId: props.invite.id, + }); + emits('deleted', props.invite.id); +} + +function copyInviteCode() { + copyToClipboard(props.invite.code); + os.success(); +} +</script> + +<style lang="scss" module> +.root { + text-align: left; +} + +.items { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + grid-gap: 12px; +} + +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; +} + +.user { + display: flex; + align-items: center; + gap: 8px; +} + +.avatar { + --height: 24px; + width: var(--height); + height: var(--height); +} + +.buttons { + display: flex; + gap: 8px; +} +</style> diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index ad7fa372e9..1d883c038e 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -57,6 +57,9 @@ export const ROLE_POLICIES = [ 'ltlAvailable', 'canPublicNote', 'canInvite', + 'inviteLimit', + 'inviteLimitCycle', + 'inviteExpirationTime', 'canManageCustomEmojis', 'canSearchNotes', 'canHideAds', diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 226eb8d026..e91f65b5d5 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -80,7 +80,7 @@ const menuDef = $computed(() => [{ }, ...(instance.disableRegistration ? [{ type: 'button', icon: 'ti ti-user-plus', - text: i18n.ts.invite, + text: i18n.ts.createInviteCode, action: invite, }] : [])], }, { @@ -96,6 +96,11 @@ const menuDef = $computed(() => [{ to: '/admin/users', active: currentPage?.route.name === 'users', }, { + icon: 'ti ti-user-plus', + text: i18n.ts.invite, + to: '/admin/invites', + active: currentPage?.route.name === 'invites', + }, { icon: 'ti ti-badges', text: i18n.ts.roles, to: '/admin/roles', @@ -240,10 +245,10 @@ provideMetadataReceiver((info) => { }); const invite = () => { - os.api('invite').then(x => { + os.api('admin/invite/create').then(x => { os.alert({ type: 'info', - text: x.code, + text: x?.[0].code, }); }).catch(err => { os.alert({ diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue new file mode 100644 index 0000000000..70a9c93713 --- /dev/null +++ b/packages/frontend/src/pages/admin/invites.vue @@ -0,0 +1,126 @@ +<template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :contentMax="800"> + <div class="_gaps_m"> + <MkFolder :expanded="false"> + <template #icon><i class="ti ti-plus"></i></template> + <template #label>{{ i18n.ts.createInviteCode }}</template> + + <div class="_gaps_m"> + <MkSwitch v-model="noExpirationDate"> + <template #label>{{ i18n.ts.noExpirationDate }}</template> + </MkSwitch> + <MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local"> + <template #label>{{ i18n.ts.expirationDate }}</template> + </MkInput> + <MkInput v-model="createCount" type="number"> + <template #label>{{ i18n.ts.createCount }}</template> + </MkInput> + <MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton> + </div> + </MkFolder> + + <div :class="$style.inputs"> + <MkSelect v-model="type" :class="$style.input"> + <template #label>{{ i18n.ts.state }}</template> + <option value="all">{{ i18n.ts.all }}</option> + <option value="unused">{{ i18n.ts.unused }}</option> + <option value="used">{{ i18n.ts.used }}</option> + <option value="expired">{{ i18n.ts.expired }}</option> + </MkSelect> + <MkSelect v-model="sort" :class="$style.input"> + <template #label>{{ i18n.ts.sort }}</template> + <option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option> + <option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option> + <option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option> + </MkSelect> + </div> + <MkPagination ref="pagingComponent" :pagination="pagination"> + <template #default="{ items }"> + <div class="_gaps_s"> + <MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/> + </div> + </template> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, ref, shallowRef } from 'vue'; +import XHeader from './_header_.vue'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkInviteCode from '@/components/MkInviteCode.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); + +let type = ref('all'); +let sort = ref('+createdAt'); + +const pagination: Paging = { + endpoint: 'admin/invite/list' as const, + limit: 10, + params: computed(() => ({ + type: type.value, + sort: sort.value, + })), + offsetMode: true, +}; + +const expiresAt = ref(''); +const noExpirationDate = ref(true); +const createCount = ref(1); + +async function createWithOptions() { + const options = { + expiresAt: noExpirationDate.value ? null : expiresAt.value, + count: createCount.value, + }; + + const tickets = await os.api('admin/invite/create', options); + os.alert({ + type: 'success', + title: i18n.ts.inviteCodeCreated, + text: tickets?.map(x => x.code).join('\n'), + }); + + tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket)); +} + +function deleted(id: string) { + if (pagingComponent.value) { + pagingComponent.value.items.delete(id); + } +} + +const headerActions = $computed(() => []); +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.invite, + icon: 'ti ti-user-plus', +}); +</script> + +<style lang="scss" module> +.inputs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.input { + flex: 1; +} +</style> diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 02a2d4366a..7fe5624fb5 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -171,6 +171,65 @@ </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])"> + <template #label>{{ i18n.ts._role._options.inviteLimit }}</template> + <template #suffix> + <span v-if="role.policies.inviteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.inviteLimit.value }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimit)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.inviteLimit.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.inviteLimit.value" :disabled="role.policies.inviteLimit.useDefault" type="number" :readonly="readonly"> + </MkInput> + <MkRange v-model="role.policies.inviteLimit.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.inviteLimitCycle, 'inviteLimitCycle'])"> + <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template> + <template #suffix> + <span v-if="role.policies.inviteLimitCycle.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.inviteLimitCycle.value + i18n.ts._time.minute }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimitCycle)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.inviteLimitCycle.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.inviteLimitCycle.value" :disabled="role.policies.inviteLimitCycle.useDefault" type="number" :readonly="readonly"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + <MkRange v-model="role.policies.inviteLimitCycle.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.inviteExpirationTime, 'inviteExpirationTime'])"> + <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template> + <template #suffix> + <span v-if="role.policies.inviteExpirationTime.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.inviteExpirationTime.value + i18n.ts._time.minute }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteExpirationTime)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.inviteExpirationTime.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="role.policies.inviteExpirationTime.value" :disabled="role.policies.inviteExpirationTime.useDefault" type="number" :readonly="readonly"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + <MkRange v-model="role.policies.inviteExpirationTime.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.canManageCustomEmojis, 'canManageCustomEmojis'])"> <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index 6634d9cba9..cdb6e90505 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -51,6 +51,29 @@ </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])"> + <template #label>{{ i18n.ts._role._options.inviteLimit }}</template> + <template #suffix>{{ policies.inviteLimit }}</template> + <MkInput v-model="policies.inviteLimit" type="number"> + </MkInput> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])"> + <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template> + <template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template> + <MkInput v-model="policies.inviteLimitCycle" type="number"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </MkFolder> + + <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])"> + <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template> + <template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template> + <MkInput v-model="policies.inviteExpirationTime" type="number"> + <template #suffix>{{ i18n.ts._time.minute }}</template> + </MkInput> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])"> <template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template> <template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template> diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue new file mode 100644 index 0000000000..c893ad51e8 --- /dev/null +++ b/packages/frontend/src/pages/invite.vue @@ -0,0 +1,114 @@ +<template> +<MkStickyContainer> + <template #header> + <MkPageHeader/> + </template> + <MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200"> + <div :class="$style.root"> + <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/> + <div :class="$style.text"> + <i class="ti ti-alert-triangle"></i> + {{ i18n.ts.nothing }} + </div> + </div> + </MKSpacer> + <MkSpacer v-else :contentMax="800"> + <div class="_gaps_m" style="text-align: center;"> + <div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div> + <MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton> + <div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div> + + <MkPagination ref="pagingComponent" :pagination="pagination"> + <template #default="{ items }"> + <div class="_gaps_s"> + <MkInviteCode v-for="item in (items as Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/> + </div> + </template> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed, ref, shallowRef } from 'vue'; +import type { Invite } from 'misskey-js/built/entities'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; +import MkButton from '@/components/MkButton.vue'; +import MkPagination, { Paging } from '@/components/MkPagination.vue'; +import MkInviteCode from '@/components/MkInviteCode.vue'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { serverErrorImageUrl, instance } from '@/instance'; +import { $i } from '@/account'; + +const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); +const currentInviteLimit = ref<null | number>(null); +const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number; +const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number; + +const pagination: Paging = { + endpoint: 'invite/list' as const, + limit: 10, +}; + +const resetCycle = computed<null | string>(() => { + if (!inviteLimitCycle) return null; + + const minutes = inviteLimitCycle; + if (minutes < 60) return minutes + i18n.ts._time.minute; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + i18n.ts._time.hour; + return Math.floor(hours / 24) + i18n.ts._time.day; +}); + +async function create() { + const ticket = await os.api('invite/create'); + os.alert({ + type: 'success', + title: i18n.ts.inviteCodeCreated, + text: ticket.code, + }); + + pagingComponent.value?.prepend(ticket); + update(); +} + +function deleted(id: string) { + if (pagingComponent.value) { + pagingComponent.value.items.delete(id); + } + update(); +} + +async function update() { + currentInviteLimit.value = (await os.api('invite/limit')).remaining; +} + +update(); + +definePageMetadata({ + title: i18n.ts.invite, + icon: 'ti ti-user-plus', +}); +</script> + +<style lang="scss" module> +.root { + padding: 32px; + text-align: center; + align-items: center; +} + +.text { + margin: 0 0 8px 0; +} + +.img { + vertical-align: bottom; + width: 128px; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} +</style> diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 873b372e0a..a4276ff4c0 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -202,6 +202,10 @@ export const routes = [{ path: '/about-misskey', component: page(() => import('./pages/about-misskey.vue')), }, { + path: '/invite', + name: 'invite', + component: page(() => import('./pages/invite.vue')), +}, { path: '/ads', component: page(() => import('./pages/ads.vue')), }, { @@ -429,6 +433,10 @@ export const routes = [{ name: 'server-rules', component: page(() => import('./pages/admin/server-rules.vue')), }, { + path: '/invites', + name: 'invites', + component: page(() => import('./pages/admin/invites.vue')), + }, { path: '/', component: page(() => import('./pages/_empty_.vue')), }], diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index 53042a4ce7..aa2f7b9c55 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -33,7 +33,12 @@ export function openInstanceMenu(ev: MouseEvent) { text: i18n.ts.ads, icon: 'ti ti-ad', to: '/ads', - }, { + }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? { + type: 'link', + to: '/invite', + text: i18n.ts.invite, + icon: 'ti ti-user-plus', + } : undefined, { type: 'parent', text: i18n.ts.tools, icon: 'ti ti-tool', @@ -52,23 +57,7 @@ export function openInstanceMenu(ev: MouseEvent) { to: '/clicker', text: '🍪👈', icon: 'ti ti-cookie', - }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? { - text: i18n.ts.invite, - icon: 'ti ti-user-plus', - action: () => { - os.api('invite').then(x => { - os.alert({ - type: 'info', - text: x.code, - }); - }).catch(err => { - os.alert({ - type: 'error', - text: err, - }); - }); - }, - } : undefined, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { + }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? { type: 'link', to: '/custom-emojis-manager', text: i18n.ts.manageCustomEmojis, |