diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-05-09 09:17:34 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-05-09 09:17:34 +0900 |
| commit | 94690c835e3179e3fd616758ad00a8b66d844a0a (patch) | |
| tree | 3171356ca8298aa6caae7c95df7232844163f913 /packages/frontend/src/components | |
| parent | Merge pull request #10608 from misskey-dev/develop (diff) | |
| parent | [ci skip] 13.12.0 (diff) | |
| download | misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.tar.gz misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.tar.bz2 misskey-94690c835e3179e3fd616758ad00a8b66d844a0a.zip | |
Merge pull request #10774 from misskey-dev/develop
Release: 13.12.0
Diffstat (limited to 'packages/frontend/src/components')
68 files changed, 2896 insertions, 725 deletions
diff --git a/packages/frontend/src/components/MkAbuseReport.stories.impl.ts b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts new file mode 100644 index 0000000000..7d27adeb04 --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReport.stories.impl.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { abuseUserReport } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAbuseReport from './MkAbuseReport.vue'; +export const Default = { + render(args) { + return { + components: { + MkAbuseReport, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + resolved: action('resolved'), + }; + }, + }, + template: '<MkAbuseReport v-bind="props" v-on="events" />', + }; + }, + args: { + report: abuseUserReport(), + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/admin/resolve-abuse-user-report', async (req, res, ctx) => { + action('POST /api/admin/resolve-abuse-user-report')(await req.json()); + return res(ctx.json({})); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAbuseReport>; diff --git a/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts new file mode 100644 index 0000000000..d0877ffd3b --- /dev/null +++ b/packages/frontend/src/components/MkAbuseReportWindow.stories.impl.ts @@ -0,0 +1,49 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAbuseReportWindow from './MkAbuseReportWindow.vue'; +export const Default = { + render(args) { + return { + components: { + MkAbuseReportWindow, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + 'closed': action('closed'), + }; + }, + }, + template: '<MkAbuseReportWindow v-bind="props" v-on="events" />', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/report-abuse', async (req, res, ctx) => { + action('POST /api/users/report-abuse')(await req.json()); + return res(ctx.json({})); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAbuseReportWindow>; diff --git a/packages/frontend/src/components/MkAccountMoved.stories.impl.ts b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts new file mode 100644 index 0000000000..bed9d94311 --- /dev/null +++ b/packages/frontend/src/components/MkAccountMoved.stories.impl.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../.storybook/fakes'; +import MkAccountMoved from './MkAccountMoved.vue'; +export const Default = { + render(args) { + return { + components: { + MkAccountMoved, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAccountMoved v-bind="props" />', + }; + }, + args: { + username: userDetailed().username, + host: userDetailed().host, + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkAccountMoved>; diff --git a/packages/frontend/src/components/MkAccountMoved.vue b/packages/frontend/src/components/MkAccountMoved.vue index fd472de6c1..b02bfdc2b8 100644 --- a/packages/frontend/src/components/MkAccountMoved.vue +++ b/packages/frontend/src/components/MkAccountMoved.vue @@ -1,8 +1,8 @@ <template> -<div :class="$style.root"> +<div v-if="user" :class="$style.root"> <i class="ti ti-plane-departure" style="margin-right: 8px;"></i> {{ i18n.ts.accountMoved }} - <MkMention :class="$style.link" :username="acct" :host="host ?? localHost"/> + <MkMention :class="$style.link" :username="user.username" :host="user.host ?? localHost"/> </div> </template> @@ -10,11 +10,17 @@ import MkMention from './MkMention.vue'; import { i18n } from '@/i18n'; import { host as localHost } from '@/config'; +import { ref } from 'vue'; +import { UserLite } from 'misskey-js/built/entities'; +import { api } from '@/os'; -defineProps<{ - acct: string; - host: string; +const user = ref<UserLite>(); + +const props = defineProps<{ + movedTo: string; // user id }>(); + +api('users/show', { userId: props.movedTo }).then(u => user.value = u); </script> <style lang="scss" module> diff --git a/packages/frontend/src/components/MkAchievements.stories.impl.ts b/packages/frontend/src/components/MkAchievements.stories.impl.ts new file mode 100644 index 0000000000..477152a47b --- /dev/null +++ b/packages/frontend/src/components/MkAchievements.stories.impl.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAchievements from './MkAchievements.vue'; +import { ACHIEVEMENT_TYPES } from '@/scripts/achievements'; +export const Empty = { + render(args) { + return { + components: { + MkAchievements, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAchievements v-bind="props" />', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'fullscreen', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/achievements', (req, res, ctx) => { + return res(ctx.json([])); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAchievements>; +export const All = { + ...Empty, + parameters: { + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/achievements', (req, res, ctx) => { + return res(ctx.json(ACHIEVEMENT_TYPES.map((name) => ({ name, unlockedAt: 0 })))); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAchievements>; diff --git a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts index 05190aa268..e7fbb47284 100644 --- a/packages/frontend/src/components/MkAnalogClock.stories.impl.ts +++ b/packages/frontend/src/components/MkAnalogClock.stories.impl.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { StoryObj } from '@storybook/vue3'; import MkAnalogClock from './MkAnalogClock.vue'; +import isChromatic from 'chromatic'; export const Default = { render(args) { return { @@ -22,6 +23,14 @@ export const Default = { template: '<MkAnalogClock v-bind="props" />', }; }, + args: { + now: isChromatic() ? () => new Date('2023-01-01T10:10:30') : undefined, + }, + decorators: [ + () => ({ + template: '<div style="container-type:inline-size;height:100%"><div style="height:100cqmin;margin:auto;width:100cqmin"><story/></div></div>', + }), + ], parameters: { layout: 'fullscreen', }, diff --git a/packages/frontend/src/components/MkAnalogClock.vue b/packages/frontend/src/components/MkAnalogClock.vue index 1218202616..f12020f810 100644 --- a/packages/frontend/src/components/MkAnalogClock.vue +++ b/packages/frontend/src/components/MkAnalogClock.vue @@ -99,6 +99,7 @@ const props = withDefaults(defineProps<{ graduations?: 'none' | 'dots' | 'numbers'; fadeGraduations?: boolean; sAnimation?: 'none' | 'elastic' | 'easeOut'; + now?: () => Date; }>(), { numbers: false, thickness: 0.1, @@ -107,6 +108,7 @@ const props = withDefaults(defineProps<{ graduations: 'dots', fadeGraduations: true, sAnimation: 'elastic', + now: () => new Date(), }); const graduationsMajor = computed(() => { @@ -145,11 +147,17 @@ let disableSAnimate = $ref(false); let sOneRound = false; function tick() { - const now = new Date(); - now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset)); + const now = props.now(); + now.setMinutes(now.getMinutes() + now.getTimezoneOffset() + props.offset); + const previousS = s; + const previousM = m; + const previousH = h; s = now.getSeconds(); m = now.getMinutes(); h = now.getHours(); + if (previousS === s && previousM === m && previousH === h) { + return; + } hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6); mAngle = Math.PI * (m + s / 60) / 30; if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない) diff --git a/packages/frontend/src/components/MkAsUi.stories.impl.ts b/packages/frontend/src/components/MkAsUi.stories.impl.ts new file mode 100644 index 0000000000..b67c0e679d --- /dev/null +++ b/packages/frontend/src/components/MkAsUi.stories.impl.ts @@ -0,0 +1,2 @@ +import MkAsUi from './MkAsUi.vue'; +void MkAsUi; diff --git a/packages/frontend/src/components/MkAutocomplete.stories.impl.ts b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts new file mode 100644 index 0000000000..075904d6a3 --- /dev/null +++ b/packages/frontend/src/components/MkAutocomplete.stories.impl.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAutocomplete from './MkAutocomplete.vue'; +import MkInput from './MkInput.vue'; +import { tick } from '@/scripts/test-utils'; +const common = { + render(args) { + return { + components: { + MkAutocomplete, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + events() { + return { + open: action('open'), + closed: action('closed'), + }; + }, + }, + template: '<MkAutocomplete v-bind="props" v-on="events" :textarea="textarea" />', + }; + }, + args: { + close: action('close'), + x: 0, + y: 0, + }, + decorators: [ + (_, context) => ({ + components: { + MkInput, + }, + data() { + return { + q: context.args.q, + textarea: null, + }; + }, + methods: { + inputMounted() { + this.textarea = this.$refs.input.$refs.inputEl; + }, + }, + template: '<MkInput v-model="q" ref="input" @vue:mounted="inputMounted"/><story v-if="textarea" :q="q" :textarea="textarea"/>', + }), + ], + parameters: { + controls: { + exclude: ['textarea'], + }, + layout: 'centered', + chromatic: { + // FIXME: flaky + disableSnapshot: true, + }, + }, +} satisfies StoryObj<typeof MkAutocomplete>; +export const User = { + ...common, + args: { + ...common.args, + type: 'user', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, 'm')); + await waitFor(async () => { + await userEvent.type(input, ' ', { delay: 256 }); + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { timeout: 16384 }); + }, + parameters: { + ...common.parameters, + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/search-by-username-and-host', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44', 'mizuki', 'misskey-hub.net', 'Mizuki'), + userDetailed('49', 'momoko', 'misskey-hub.net', 'Momoko'), + ])); + }), + ], + }, + }, +}; +export const Hashtag = { + ...common, + args: { + ...common.args, + type: 'hashtag', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, '気象')); + await waitFor(async () => { + await userEvent.type(input, ' ', { delay: 256 }); + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { interval: 256, timeout: 16384 }); + }, + parameters: { + ...common.parameters, + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/hashtags/search', (req, res, ctx) => { + return res(ctx.json([ + '気象警報注意報', + '気象警報', + '気象情報', + ])); + }), + ], + }, + }, +}; +export const Emoji = { + ...common, + args: { + ...common.args, + type: 'emoji', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(() => userEvent.type(input, 'smile')); + await waitFor(async () => { + await userEvent.type(input, ' ', { delay: 256 }); + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { interval: 256, timeout: 16384 }); + }, +} satisfies StoryObj<typeof MkAutocomplete>; +export const MfmTag = { + ...common, + args: { + ...common.args, + type: 'mfmTag', + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox'); + await waitFor(() => userEvent.hover(input)); + await waitFor(() => userEvent.click(input)); + await waitFor(async () => { + await tick(); + return await expect(canvas.getByRole('list')).toBeInTheDocument(); + }, { interval: 256, timeout: 16384 }); + }, +} satisfies StoryObj<typeof MkAutocomplete>; diff --git a/packages/frontend/src/components/MkAvatars.stories.impl.ts b/packages/frontend/src/components/MkAvatars.stories.impl.ts new file mode 100644 index 0000000000..14052c7343 --- /dev/null +++ b/packages/frontend/src/components/MkAvatars.stories.impl.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { userDetailed } from '../../.storybook/fakes'; +import { commonHandlers } from '../../.storybook/mocks'; +import MkAvatars from './MkAvatars.vue'; +export const Default = { + render(args) { + return { + components: { + MkAvatars, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkAvatars v-bind="props" />', + }; + }, + args: { + userIds: ['17', '20', '18'], + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users/show', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('17'), + userDetailed('20'), + userDetailed('18'), + ])); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkAvatars>; diff --git a/packages/frontend/src/components/MkButton.stories.impl.ts b/packages/frontend/src/components/MkButton.stories.impl.ts index e1c1c54d10..982a8b3be1 100644 --- a/packages/frontend/src/components/MkButton.stories.impl.ts +++ b/packages/frontend/src/components/MkButton.stories.impl.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ /* eslint-disable import/no-default-export */ -/* eslint-disable import/no-duplicates */ +import { action } from '@storybook/addon-actions'; import { StoryObj } from '@storybook/vue3'; import MkButton from './MkButton.vue'; export const Default = { @@ -20,11 +20,60 @@ export const Default = { ...this.args, }; }, + events() { + return { + click: action('click'), + }; + }, }, - template: '<MkButton v-bind="props">Text</MkButton>', + template: '<MkButton v-bind="props" v-on="events">Text</MkButton>', }; }, + args: { + }, parameters: { layout: 'centered', }, } satisfies StoryObj<typeof MkButton>; +export const Primary = { + ...Default, + args: { + ...Default.args, + primary: true, + }, +} satisfies StoryObj<typeof MkButton>; +export const Gradate = { + ...Default, + args: { + ...Default.args, + gradate: true, + }, +} satisfies StoryObj<typeof MkButton>; +export const Rounded = { + ...Default, + args: { + ...Default.args, + rounded: true, + }, +} satisfies StoryObj<typeof MkButton>; +export const Danger = { + ...Default, + args: { + ...Default.args, + danger: true, + }, +} satisfies StoryObj<typeof MkButton>; +export const Small = { + ...Default, + args: { + ...Default.args, + small: true, + }, +} satisfies StoryObj<typeof MkButton>; +export const Large = { + ...Default, + args: { + ...Default.args, + large: true, + }, +} satisfies StoryObj<typeof MkButton>; diff --git a/packages/frontend/src/components/MkColorInput.vue b/packages/frontend/src/components/MkColorInput.vue new file mode 100644 index 0000000000..2471aa958d --- /dev/null +++ b/packages/frontend/src/components/MkColorInput.vue @@ -0,0 +1,110 @@ +<template> +<div> + <div :class="$style.label"><slot name="label"></slot></div> + <div :class="[$style.input, { disabled }]"> + <input + ref="inputEl" + v-model="v" + v-adaptive-border + :class="$style.inputCore" + type="color" + :disabled="disabled" + :required="required" + :readonly="readonly" + @input="onInput" + > + </div> + <div :class="$style.caption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + modelValue: string | null; + required?: boolean; + readonly?: boolean; + disabled?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', value: string): void; +}>(); + +const { modelValue } = toRefs(props); +const v = ref(modelValue.value); +const inputEl = shallowRef<HTMLElement>(); + +const onInput = (ev: KeyboardEvent) => { + emit('update:modelValue', v.value); +}; +</script> + +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; + + &:empty { + display: none; + } +} + +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); + + &:empty { + display: none; + } +} + +.input { + position: relative; + + &.focused { + > .inputCore { + border-color: var(--accent) !important; + //box-shadow: 0 0 0 4px var(--focus); + } + } + + &.disabled { + opacity: 0.7; + + &, + > .inputCore { + cursor: not-allowed !important; + } + } +} + +.inputCore { + appearance: none; + -webkit-appearance: none; + display: block; + height: 42px; + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; + + &:hover { + border-color: var(--inputBorderHover) !important; + } +} +</style> diff --git a/packages/frontend/src/components/MkContainer.vue b/packages/frontend/src/components/MkContainer.vue index a6372b7b6f..d03331a6eb 100644 --- a/packages/frontend/src/components/MkContainer.vue +++ b/packages/frontend/src/components/MkContainer.vue @@ -1,6 +1,6 @@ <template> -<div class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.hideHeader]: !showHeader, [$style.scrollable]: scrollable, [$style.closed]: !showBody }]"> - <header v-if="showHeader" ref="header" :class="$style.header"> +<div ref="rootEl" class="_panel" :class="[$style.root, { [$style.naked]: naked, [$style.thin]: thin, [$style.hideHeader]: !showHeader, [$style.scrollable]: scrollable, [$style.closed]: !showBody }]"> + <header v-if="showHeader" ref="headerEl" :class="$style.header"> <div :class="$style.title"> <span :class="$style.titleIcon"><slot name="icon"></slot></span> <slot name="header"></slot> @@ -23,7 +23,7 @@ @leave="leave" @after-leave="afterLeave" > - <div v-show="showBody" ref="content" :class="[$style.content, { [$style.omitted]: omitted }]"> + <div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]"> <slot></slot> <button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }"> <span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span> @@ -33,109 +33,80 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onMounted, ref, shallowRef, watch } from 'vue'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; -export default defineComponent({ - props: { - showHeader: { - type: Boolean, - required: false, - default: true, - }, - thin: { - type: Boolean, - required: false, - default: false, - }, - naked: { - type: Boolean, - required: false, - default: false, - }, - foldable: { - type: Boolean, - required: false, - default: false, - }, - expanded: { - type: Boolean, - required: false, - default: true, - }, - scrollable: { - type: Boolean, - required: false, - default: false, - }, - maxHeight: { - type: Number, - required: false, - default: null, - }, - }, - data() { - return { - showBody: this.expanded, - omitted: null, - ignoreOmit: false, - defaultStore, - i18n, - }; - }, - mounted() { - this.$watch('showBody', showBody => { - const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; - this.$el.style.minHeight = `${headerHeight}px`; - if (showBody) { - this.$el.style.flexBasis = 'auto'; - } else { - this.$el.style.flexBasis = `${headerHeight}px`; - } - }, { - immediate: true, - }); +const props = withDefaults(defineProps<{ + showHeader?: boolean; + thin?: boolean; + naked?: boolean; + foldable?: boolean; + scrollable?: boolean; + expanded?: boolean; + maxHeight?: number | null; +}>(), { + expanded: true, + showHeader: true, + maxHeight: null, +}); - this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); +const rootEl = shallowRef<HTMLElement>(); +const contentEl = shallowRef<HTMLElement>(); +const headerEl = shallowRef<HTMLElement>(); +const showBody = ref(props.expanded); +const ignoreOmit = ref(false); +const omitted = ref(false); - const calcOmit = () => { - if (this.omitted || this.ignoreOmit || this.maxHeight == null) return; - const height = this.$refs.content.offsetHeight; - this.omitted = height > this.maxHeight; - }; +function enter(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = 0; + el.offsetHeight; // reflow + el.style.height = Math.min(elementHeight, props.maxHeight ?? Infinity) + 'px'; +} - calcOmit(); - new ResizeObserver((entries, observer) => { - calcOmit(); - }).observe(this.$refs.content); - }, - methods: { - toggleContent(show: boolean) { - if (!this.foldable) return; - this.showBody = show; - }, +function afterEnter(el) { + el.style.height = null; +} + +function leave(el) { + const elementHeight = el.getBoundingClientRect().height; + el.style.height = elementHeight + 'px'; + el.offsetHeight; // reflow + el.style.height = 0; +} - enter(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = 0; - el.offsetHeight; // reflow - el.style.height = elementHeight + 'px'; - }, - afterEnter(el) { - el.style.height = null; - }, - leave(el) { - const elementHeight = el.getBoundingClientRect().height; - el.style.height = elementHeight + 'px'; - el.offsetHeight; // reflow - el.style.height = 0; - }, - afterLeave(el) { - el.style.height = null; - }, - }, +function afterLeave(el) { + el.style.height = null; +} + +const calcOmit = () => { + if (omitted.value || ignoreOmit.value || props.maxHeight == null) return; + const height = contentEl.value.offsetHeight; + omitted.value = height > props.maxHeight; +}; + +onMounted(() => { + watch(showBody, v => { + const headerHeight = props.showHeader ? headerEl.value.offsetHeight : 0; + rootEl.value.style.minHeight = `${headerHeight}px`; + if (v) { + rootEl.value.style.flexBasis = 'auto'; + } else { + rootEl.value.style.flexBasis = `${headerHeight}px`; + } + }, { + immediate: true, + }); + + rootEl.value.style.setProperty('--maxHeight', props.maxHeight + 'px'); + + calcOmit(); + + new ResizeObserver((entries, observer) => { + calcOmit(); + }).observe(contentEl.value); }); </script> diff --git a/packages/frontend/src/components/MkDialog.vue b/packages/frontend/src/components/MkDialog.vue index 93c1f89199..9f5404ce15 100644 --- a/packages/frontend/src/components/MkDialog.vue +++ b/packages/frontend/src/components/MkDialog.vue @@ -9,7 +9,7 @@ <i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i> <i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i> <i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i> - <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-question-circle"></i> + <i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i> <MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/> </div> <header v-if="title" :class="$style.title"><Mfm :text="title"/></header> @@ -32,8 +32,8 @@ </template> </MkSelect> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> - <MkButton v-if="showOkButton" inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> - <MkButton v-if="showCancelButton || input || select" inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> + <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabled" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> + <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> </div> <div v-if="actions" :class="$style.buttons"> <MkButton v-for="action in actions" :key="action.text" inline rounded :primary="action.primary" :danger="action.danger" @click="() => { action.callback(); modal?.close(); }">{{ action.text }}</MkButton> @@ -183,7 +183,7 @@ onBeforeUnmount(() => { box-sizing: border-box; text-align: center; background: var(--panel); - border-radius: var(--radius); + border-radius: 16px; } .icon { diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue index 58cc0de5c8..10eee6aab1 100644 --- a/packages/frontend/src/components/MkFolder.vue +++ b/packages/frontend/src/components/MkFolder.vue @@ -1,8 +1,8 @@ <template> -<div ref="rootEl" :class="$style.root"> +<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened"> <MkStickyContainer> <template #header> - <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle"> + <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle"> <div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerText"> <div :class="$style.headerTextMain"> @@ -20,7 +20,7 @@ </div> </template> - <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }"> + <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened"> <Transition :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" @@ -65,7 +65,7 @@ const getBgColor = (el: HTMLElement) => { } }; -let rootEl = $ref<HTMLElement>(); +let rootEl = $shallowRef<HTMLElement>(); let bgSame = $ref(false); let opened = $ref(props.defaultOpen); let openedAtLeastOnce = $ref(props.defaultOpen); @@ -196,7 +196,7 @@ onMounted(() => { .headerRight { margin-left: auto; - opacity: 0.7; + color: var(--fgTransparentWeak); white-space: nowrap; } diff --git a/packages/frontend/src/components/MkFollowButton.vue b/packages/frontend/src/components/MkFollowButton.vue index de8db54bfa..beee21c647 100644 --- a/packages/frontend/src/components/MkFollowButton.vue +++ b/packages/frontend/src/components/MkFollowButton.vue @@ -178,7 +178,7 @@ onBeforeUnmount(() => { } &.active { - color: #fff; + color: var(--fgOnAccent); background: var(--accent); &:hover { diff --git a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts index e46a708192..57b3e75513 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts +++ b/packages/frontend/src/components/MkGalleryPostPreview.stories.impl.ts @@ -28,9 +28,11 @@ export const Default = { async play({ canvasElement }) { const canvas = within(canvasElement); const links = canvas.getAllByRole('link'); - await expect(links).toHaveLength(2); - await expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`); - await expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`); + expect(links).toHaveLength(2); + expect(links[0]).toHaveAttribute('href', `/gallery/${galleryPost().id}`); + expect(links[1]).toHaveAttribute('href', `/@${galleryPost().user.username}@${galleryPost().user.host}`); + const images = canvas.getAllByRole<HTMLImageElement>('img'); + await waitFor(() => expect(Promise.all(images.map((image) => image.decode()))).resolves.toBeDefined()); }, args: { post: galleryPost(), diff --git a/packages/frontend/src/components/MkGalleryPostPreview.vue b/packages/frontend/src/components/MkGalleryPostPreview.vue index 944f5ad97b..4f8f7b945a 100644 --- a/packages/frontend/src/components/MkGalleryPostPreview.vue +++ b/packages/frontend/src/components/MkGalleryPostPreview.vue @@ -1,9 +1,21 @@ <template> <MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1" @pointerenter="enterHover" @pointerleave="leaveHover"> <div class="thumbnail"> - <ImgWithBlurhash class="img" :hash="post.files[0].blurhash"/> <Transition> - <ImgWithBlurhash v-if="show" class="img layered" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/> + <ImgWithBlurhash + class="img layered" + :transition="safe ? null : { + enterActiveClass: $style.transition_toggle_enterActive, + leaveActiveClass: $style.transition_toggle_leaveActive, + enterFromClass: $style.transition_toggle_enterFrom, + leaveToClass: $style.transition_toggle_leaveTo, + enterToClass: $style.transition_toggle_enterTo, + leaveFromClass: $style.transition_toggle_leaveFrom, + }" + :src="post.files[0].thumbnailUrl" + :hash="post.files[0].blurhash" + :force-blurhash="!show" + /> </Transition> </div> <article> @@ -28,7 +40,8 @@ const props = defineProps<{ }>(); const hover = ref(false); -const show = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive || hover.value); +const safe = computed(() => defaultStore.state.nsfw === 'ignore' || defaultStore.state.nsfw === 'respect' && !props.post.isSensitive); +const show = computed(() => safe.value || hover.value); function enterHover(): void { hover.value = true; @@ -39,6 +52,27 @@ function leaveHover(): void { } </script> +<style lang="scss" module> +.transition_toggle_enterActive, +.transition_toggle_leaveActive { + transition: opacity 0.5s; + position: absolute; + top: 0; + left: 0; +} + +.transition_toggle_enterFrom, +.transition_toggle_leaveTo { + opacity: 0; +} + +.transition_toggle_enterTo, +.transition_toggle_leaveFrom { + transition: none; + opacity: 1; +} +</style> + <style lang="scss" scoped> .ttasepnz { display: block; @@ -66,7 +100,7 @@ function leaveHover(): void { width: 100%; height: 100%; position: absolute; - transition: all 0.5s ease; + transition: transform 0.5s ease; > .img { width: 100%; @@ -76,16 +110,6 @@ function leaveHover(): void { &.layered { position: absolute; top: 0; - - &.v-enter-active, - &.v-leave-active { - transition: opacity 0.5s ease; - } - - &.v-enter-from, - &.v-leave-to { - opacity: 0; - } } } } diff --git a/packages/frontend/src/components/MkImgWithBlurhash.vue b/packages/frontend/src/components/MkImgWithBlurhash.vue index 944c76d7dc..6406a35060 100644 --- a/packages/frontend/src/components/MkImgWithBlurhash.vue +++ b/packages/frontend/src/components/MkImgWithBlurhash.vue @@ -1,44 +1,90 @@ <template> -<div :class="[$style.root, { [$style.cover]: cover }]" :title="title"> - <canvas v-if="!loaded" ref="canvas" :class="$style.canvas" :width="size" :height="size" :title="title"/> - <img v-if="src" :class="$style.img" :src="src" :title="title" :alt="alt" @load="onLoad"/> +<div :class="[$style.root, { [$style.cover]: cover }]" :title="title ?? ''"> + <img v-if="!loaded && src && !forceBlurhash" :class="$style.loader" :src="src" @load="onLoad"/> + <Transition + mode="in-out" + :enter-active-class="defaultStore.state.animation && (props.transition?.enterActiveClass ?? $style['transition_toggle_enterActive']) || undefined" + :leave-active-class="defaultStore.state.animation && (props.transition?.leaveActiveClass ?? $style['transition_toggle_leaveActive']) || undefined" + :enter-from-class="defaultStore.state.animation && props.transition?.enterFromClass || undefined" + :leave-to-class="defaultStore.state.animation && props.transition?.leaveToClass || undefined" + :enter-to-class="defaultStore.state.animation && (props.transition?.enterToClass ?? $style['transition_toggle_enterTo']) || undefined" + :leave-from-class="defaultStore.state.animation && (props.transition?.leaveFromClass ?? $style['transition_toggle_leaveFrom']) || undefined" + > + <canvas v-if="!loaded || forceBlurhash" ref="canvas" :class="$style.canvas" :width="width" :height="height" :title="title ?? undefined"/> + <img v-else :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined"/> + </Transition> </div> </template> <script lang="ts" setup> -import { onMounted } from 'vue'; +import { onMounted, shallowRef, useCssModule, watch } from 'vue'; import { decode } from 'blurhash'; +import { defaultStore } from '@/store'; + +const $style = useCssModule(); const props = withDefaults(defineProps<{ + transition?: { + enterActiveClass?: string; + leaveActiveClass?: string; + enterFromClass?: string; + leaveToClass?: string; + enterToClass?: string; + leaveFromClass?: string; + } | null; src?: string | null; hash?: string; - alt?: string; + alt?: string | null; title?: string | null; - size?: number; + height?: number; + width?: number; cover?: boolean; + forceBlurhash?: boolean; }>(), { + transition: null, src: null, alt: '', title: null, - size: 64, + height: 64, + width: 64, cover: true, + forceBlurhash: false, }); -const canvas = $shallowRef<HTMLCanvasElement>(); +const canvas = shallowRef<HTMLCanvasElement>(); let loaded = $ref(false); +let width = $ref(props.width); +let height = $ref(props.height); + +function onLoad() { + loaded = true; +} + +watch([() => props.width, () => props.height], () => { + const ratio = props.width / props.height; + if (ratio > 1) { + width = Math.round(64 * ratio); + height = 64; + } else { + width = 64; + height = Math.round(64 / ratio); + } +}, { + immediate: true, +}); function draw() { - if (props.hash == null) return; - const pixels = decode(props.hash, props.size, props.size); - const ctx = canvas.getContext('2d'); - const imageData = ctx!.createImageData(props.size, props.size); + if (props.hash == null || !canvas.value) return; + const pixels = decode(props.hash, width, height); + const ctx = canvas.value.getContext('2d'); + const imageData = ctx!.createImageData(width, height); imageData.data.set(pixels); ctx!.putImageData(imageData, 0, 0); } -function onLoad() { - loaded = true; -} +watch([() => props.hash, canvas], () => { + draw(); +}); onMounted(() => { draw(); @@ -46,12 +92,33 @@ onMounted(() => { </script> <style lang="scss" module> +.transition_toggle_enterActive, +.transition_toggle_leaveActive { + position: absolute; + top: 0; + left: 0; +} + +.transition_toggle_enterTo, +.transition_toggle_leaveFrom { + opacity: 0; +} + +.loader { + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; +} + .root { position: relative; width: 100%; height: 100%; &.cover { + > .canvas, > .img { object-fit: cover; } @@ -66,8 +133,7 @@ onMounted(() => { } .canvas { - position: absolute; - object-fit: cover; + object-fit: contain; } .img { diff --git a/packages/frontend/src/components/MkInfo.vue b/packages/frontend/src/components/MkInfo.vue index dc7344d707..cda428a77c 100644 --- a/packages/frontend/src/components/MkInfo.vue +++ b/packages/frontend/src/components/MkInfo.vue @@ -21,6 +21,7 @@ const props = defineProps<{ background: var(--infoBg); color: var(--infoFg); border-radius: var(--radius); + white-space: pre-wrap; &.warn { background: var(--infoWarnBg); diff --git a/packages/frontend/src/components/MkInput.vue b/packages/frontend/src/components/MkInput.vue index 3e3d7354c1..e48032d599 100644 --- a/packages/frontend/src/components/MkInput.vue +++ b/packages/frontend/src/components/MkInput.vue @@ -1,12 +1,13 @@ <template> -<div class="matxzzsk"> - <div class="label" @click="focus"><slot name="label"></slot></div> - <div class="input" :class="{ inline, disabled, focused }"> - <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> +<div> + <div :class="$style.label" @click="focus"><slot name="label"></slot></div> + <div :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]"> + <div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div> <input ref="inputEl" v-model="v" v-adaptive-border + :class="$style.inputCore" :type="type" :disabled="disabled" :required="required" @@ -25,11 +26,11 @@ <datalist v-if="datalist" :id="id"> <option v-for="data in datalist" :key="data" :value="data"/> </datalist> - <div ref="suffixEl" class="suffix"><slot name="suffix"></slot></div> + <div ref="suffixEl" :class="$style.suffix"><slot name="suffix"></slot></div> </div> - <div class="caption"><slot name="caption"></slot></div> + <div :class="$style.caption"><slot name="caption"></slot></div> - <MkButton v-if="manualSave && changed" primary class="save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="manualSave && changed" primary :class="$style.save" @click="updated"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> </template> @@ -151,115 +152,110 @@ onMounted(() => { }); </script> -<style lang="scss" scoped> -.matxzzsk { - > .label { - font-size: 0.85em; - padding: 0 0 8px 0; - user-select: none; +<style lang="scss" module> +.label { + font-size: 0.85em; + padding: 0 0 8px 0; + user-select: none; - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .caption { - font-size: 0.85em; - padding: 8px 0 0 0; - color: var(--fgTransparentWeak); +.caption { + font-size: 0.85em; + padding: 8px 0 0 0; + color: var(--fgTransparentWeak); - &:empty { - display: none; - } + &:empty { + display: none; } +} - > .input { - position: relative; - - > input { - appearance: none; - -webkit-appearance: none; - display: block; - height: v-bind("height + 'px'"); - width: 100%; - margin: 0; - padding: 0 12px; - font: inherit; - font-weight: normal; - font-size: 1em; - color: var(--fg); - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 6px; - outline: none; - box-shadow: none; - box-sizing: border-box; - transition: border-color 0.1s ease-out; - - &:hover { - border-color: var(--inputBorderHover) !important; - } - } - - > .prefix, - > .suffix { - display: flex; - align-items: center; - position: absolute; - z-index: 1; - top: 0; - padding: 0 12px; - font-size: 1em; - height: v-bind("height + 'px'"); - pointer-events: none; +.input { + position: relative; - &:empty { - display: none; - } + &.inline { + display: inline-block; + margin: 0; + } - > * { - display: inline-block; - min-width: 16px; - max-width: 150px; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } + &.focused { + > .inputCore { + border-color: var(--accent) !important; + //box-shadow: 0 0 0 4px var(--focus); } + } - > .prefix { - left: 0; - padding-right: 6px; - } + &.disabled { + opacity: 0.7; - > .suffix { - right: 0; - padding-left: 6px; + &, + > .inputCore { + cursor: not-allowed !important; } + } +} - &.inline { - display: inline-block; - margin: 0; - } +.inputCore { + appearance: none; + -webkit-appearance: none; + display: block; + height: v-bind("height + 'px'"); + width: 100%; + margin: 0; + padding: 0 12px; + font: inherit; + font-weight: normal; + font-size: 1em; + color: var(--fg); + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 6px; + outline: none; + box-shadow: none; + box-sizing: border-box; + transition: border-color 0.1s ease-out; - &.focused { - > input { - border-color: var(--accent) !important; - //box-shadow: 0 0 0 4px var(--focus); - } - } + &:hover { + border-color: var(--inputBorderHover) !important; + } +} - &.disabled { - opacity: 0.7; +.prefix, +.suffix { + display: flex; + align-items: center; + position: absolute; + z-index: 1; + top: 0; + padding: 0 12px; + font-size: 1em; + height: v-bind("height + 'px'"); + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + box-sizing: border-box; + pointer-events: none; - &, * { - cursor: not-allowed !important; - } - } + &:empty { + display: none; } +} - > .save { - margin: 8px 0 0 0; - } +.prefix { + left: 0; + padding-right: 6px; +} + +.suffix { + right: 0; + padding-left: 6px; +} +.save { + margin: 8px 0 0 0; } </style> diff --git a/packages/frontend/src/components/MkMediaImage.vue b/packages/frontend/src/components/MkMediaImage.vue index a4065dcd07..42dc9e79ff 100644 --- a/packages/frontend/src/components/MkMediaImage.vue +++ b/packages/frontend/src/components/MkMediaImage.vue @@ -1,9 +1,10 @@ <template> <div v-if="hide" :class="$style.hidden" @click="hide = false"> - <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> + <ImgWithBlurhash style="filter: brightness(0.5);" :hash="image.blurhash" :title="image.comment" :alt="image.comment" :width="image.properties.width" :height="image.properties.height" :force-blurhash="defaultStore.state.enableDataSaverMode"/> <div :class="$style.hiddenText"> <div :class="$style.hiddenTextWrapper"> - <b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b> + <b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b> + <b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b> <span style="display: block;">{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -14,13 +15,15 @@ :href="image.url" :title="image.name" > - <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :cover="false"/> + <ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment || image.name" :title="image.comment || image.name" :width="image.properties.width" :height="image.properties.height" :cover="false"/> </a> <div :class="$style.indicators"> <div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div> <div v-if="image.comment" :class="$style.indicator">ALT</div> + <div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div> </div> <button v-tooltip="i18n.ts.hide" :class="$style.hide" class="_button" @click="hide = true"><i class="ti ti-eye-off"></i></button> + <button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots"></i></button> </div> </template> @@ -28,9 +31,12 @@ import { watch } from 'vue'; import * as misskey from 'misskey-js'; import { getStaticImageUrl } from '@/scripts/media-proxy'; +import bytes from '@/filters/bytes'; import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; +import * as os from '@/os'; +import { iAmModerator } from '@/account'; const props = defineProps<{ image: misskey.entities.DriveFile; @@ -38,21 +44,33 @@ const props = defineProps<{ }>(); let hide = $ref(true); -let darkMode = $ref(defaultStore.state.darkMode); +let darkMode: boolean = $ref(defaultStore.state.darkMode); -const url = (props.raw || defaultStore.state.loadRawImages) +const url = $computed(() => (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(props.image.url) - : props.image.thumbnailUrl; + : props.image.thumbnailUrl, +); // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { - hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore'); + hide = (defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.image.isSensitive && defaultStore.state.nsfw !== 'ignore'); }, { deep: true, immediate: true, }); + +function showMenu(ev: MouseEvent) { + os.popupMenu([...(iAmModerator ? [{ + text: i18n.ts.markAsSensitive, + icon: 'ti ti-eye-off', + action: () => { + os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true }); + }, + }] : [])], ev.currentTarget ?? ev.target); +} + </script> <style lang="scss" module> @@ -102,6 +120,21 @@ watch(() => props.image, () => { right: 12px; } +.menu { + display: block; + position: absolute; + border-radius: 6px; + background-color: rgba(0, 0, 0, 0.3); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + color: #fff; + font-size: 0.8em; + padding: 6px 8px; + text-align: center; + bottom: 12px; + right: 12px; +} + .imageContainer { display: block; cursor: zoom-in; @@ -132,6 +165,7 @@ watch(() => props.image, () => { color: var(--accentLighten); display: inline-block; font-weight: bold; - padding: 0 6px; + font-size: 12px; + padding: 2px 6px; } </style> diff --git a/packages/frontend/src/components/MkMediaList.vue b/packages/frontend/src/components/MkMediaList.vue index d36cc2d26b..e456ff3eec 100644 --- a/packages/frontend/src/components/MkMediaList.vue +++ b/packages/frontend/src/components/MkMediaList.vue @@ -2,10 +2,16 @@ <div> <XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/> <div v-if="mediaList.filter(media => previewable(media)).length > 0" :class="$style.container"> - <div ref="gallery" :class="[$style.medias, count <= 4 ? $style['n' + count] : $style.nMany]"> + <div + ref="gallery" + :class="[ + $style.medias, + count <= 4 ? $style['n' + count] : $style.nMany, + ]" + > <template v-for="media in mediaList.filter(media => previewable(media))"> - <XVideo v-if="media.type.startsWith('video')" :key="media.id" :class="$style.media" :video="media"/> - <XImage v-else-if="media.type.startsWith('image')" :key="media.id" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/> + <XVideo v-if="media.type.startsWith('video')" :key="`video:${media.id}`" :class="$style.media" :video="media"/> + <XImage v-else-if="media.type.startsWith('image')" :key="`image:${media.id}`" :class="$style.media" class="image" :data-id="media.id" :image="media" :raw="raw"/> </template> </div> </div> @@ -13,7 +19,7 @@ </template> <script lang="ts" setup> -import { onMounted, ref, useCssModule } from 'vue'; +import { onMounted, ref, useCssModule, watch } from 'vue'; import * as misskey from 'misskey-js'; import PhotoSwipeLightbox from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; @@ -23,6 +29,7 @@ import XImage from '@/components/MkMediaImage.vue'; import XVideo from '@/components/MkMediaVideo.vue'; import * as os from '@/os'; import { FILE_TYPE_BROWSERSAFE } from '@/const'; +import { defaultStore } from '@/store'; const props = defineProps<{ mediaList: misskey.entities.DriveFile[]; @@ -31,7 +38,7 @@ const props = defineProps<{ const $style = useCssModule(); -const gallery = ref(null); +const gallery = ref<HTMLDivElement>(); const pswpZIndex = os.claimZIndex('middle'); document.documentElement.style.setProperty('--mk-pswp-root-z-index', pswpZIndex.toString()); const count = $computed(() => props.mediaList.filter(media => previewable(media)).length); diff --git a/packages/frontend/src/components/MkMediaVideo.vue b/packages/frontend/src/components/MkMediaVideo.vue index e02a7af09e..a4b76300e6 100644 --- a/packages/frontend/src/components/MkMediaVideo.vue +++ b/packages/frontend/src/components/MkMediaVideo.vue @@ -1,7 +1,9 @@ <template> <div v-if="hide" class="icozogqfvdetwohsdglrbswgrejoxbdj" @click="hide = false"> + <!-- 【注意】dataSaverMode が有効になっている際には、hide が false になるまでサムネイルや動画を読み込まないようにすること --> <div> - <b><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}</b> + <b v-if="video.isSensitive"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b> + <b v-else><i class="ti ti-movie"></i> {{ defaultStore.state.enableDataSaverMode && video.size ? bytes(video.size) : i18n.ts.video }}</b> <span>{{ i18n.ts.clickToShow }}</span> </div> </div> @@ -25,6 +27,7 @@ <script lang="ts" setup> import { ref } from 'vue'; import * as misskey from 'misskey-js'; +import bytes from '@/filters/bytes'; import VuePlyr from 'vue-plyr'; import { defaultStore } from '@/store'; import 'vue-plyr/dist/vue-plyr.css'; @@ -34,7 +37,7 @@ const props = defineProps<{ video: misskey.entities.DriveFile; }>(); -const hide = ref((defaultStore.state.nsfw === 'force') ? true : props.video.isSensitive && (defaultStore.state.nsfw !== 'ignore')); +const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.enableDataSaverMode) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore')); </script> <style lang="scss" scoped> diff --git a/packages/frontend/src/components/MkModal.vue b/packages/frontend/src/components/MkModal.vue index 852c72f6ff..99df9e8150 100644 --- a/packages/frontend/src/components/MkModal.vue +++ b/packages/frontend/src/components/MkModal.vue @@ -404,16 +404,10 @@ defineExpose({ right: 0; margin: auto; padding: 32px; - // TODO: mask-imageはiOSだとやたら重い。なんとかしたい - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%); - overflow: auto; display: flex; @media (max-width: 500px) { padding: 16px; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%); } } } diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index dd115246ff..1c942cfd0d 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -1,12 +1,12 @@ <template> <MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> + <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: height ? `${height}px` : null }" @keydown="onKeydown"> <div ref="headerEl" class="header"> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> <span class="title"> <slot name="header"></slot> </span> - <button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> + <button v-if="!withOkButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button> <button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button> </div> <div class="body"> @@ -25,13 +25,11 @@ const props = withDefaults(defineProps<{ okButtonDisabled: boolean; width: number; height: number | null; - scroll: boolean; }>(), { withOkButton: false, okButtonDisabled: false, width: 400, height: null, - scroll: true, }); const emit = defineEmits<{ @@ -86,11 +84,11 @@ defineExpose({ <style lang="scss" scoped> .ebkgoccj { margin: auto; + max-height: 100%; overflow: hidden; display: flex; flex-direction: column; contain: content; - container-type: inline-size; border-radius: var(--radius); --root-margin: 24px; @@ -143,6 +141,7 @@ defineExpose({ flex: 1; overflow: auto; background: var(--panel); + container-type: size; } } </style> diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index 36ec778a14..d95f8de311 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -12,6 +12,7 @@ <!--<div v-if="appearNote._prId_" class="tip"><i class="ti ti-speakerphone"></i> {{ i18n.ts.promotion }}<button class="_textButton hide" @click="readPromo()">{{ i18n.ts.hideThisNote }} <i class="ti ti-x"></i></button></div>--> <!--<div v-if="appearNote._featuredId_" class="tip"><i class="ti ti-bolt"></i> {{ i18n.ts.featured }}</div>--> <div v-if="isRenote" :class="$style.renote"> + <div v-if="note.channel" :class="$style.colorBar" :style="{ background: note.channel.color }"></div> <MkAvatar :class="$style.renoteAvatar" :user="note.user" link preview/> <i class="ti ti-repeat" style="margin-right: 4px;"></i> <I18n :src="i18n.ts.renotedBy" tag="span" :class="$style.renoteText"> @@ -40,6 +41,7 @@ <Mfm :text="getNoteSummary(appearNote)" :plain="true" :nowrap="true" :author="appearNote.user" :class="$style.collapsedRenoteTargetText" @click="renoteCollapsed = false"/> </div> <article v-else :class="$style.article" @contextmenu.stop="onContextmenu"> + <div v-if="appearNote.channel" :class="$style.colorBar" :style="{ background: appearNote.channel.color }"></div> <MkAvatar :class="$style.avatar" :user="appearNote.user" link preview/> <div :class="$style.main"> <MkNoteHeader :class="$style.header" :note="appearNote" :mini="true"/> @@ -162,6 +164,7 @@ import { claimAchievement } from '@/scripts/achievements'; import { getNoteSummary } from '@/scripts/get-note-summary'; import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { showMovedDialog } from '@/scripts/show-moved-dialog'; const props = defineProps<{ note: misskey.entities.Note; @@ -255,6 +258,7 @@ useTooltip(renoteButton, async (showing) => { function renote(viaKeyboard = false) { pleaseLogin(); + showMovedDialog(); let items = [] as MenuItem[]; @@ -335,6 +339,7 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); + showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { os.api('notes/reactions/create', { noteId: appearNote.id, @@ -401,6 +406,7 @@ async function clip() { function showRenoteMenu(viaKeyboard = false): void { if (!isMyRenote) return; + pleaseLogin(); os.popupMenu([{ text: i18n.ts.unrenote, icon: 'ti ti-trash', @@ -484,6 +490,11 @@ function showReactions(): void { } } + .footer { + position: relative; + z-index: 1; + } + &:hover > .article > .main > .footer > .footerButton { opacity: 1; } @@ -537,6 +548,7 @@ function showReactions(): void { } .renote { + position: relative; display: flex; align-items: center; padding: 16px 32px 8px 32px; @@ -547,6 +559,10 @@ function showReactions(): void { & + .article { padding-top: 8px; } + + > .colorBar { + height: calc(100% - 6px); + } } .renoteAvatar { @@ -618,6 +634,16 @@ function showReactions(): void { padding: 28px 32px; } +.colorBar { + position: absolute; + top: 8px; + left: 8px; + width: 5px; + height: calc(100% - 16px); + border-radius: 999px; + pointer-events: none; +} + .avatar { flex-shrink: 0; display: block !important; @@ -669,6 +695,7 @@ function showReactions(): void { position: absolute; bottom: 0; left: 0; + z-index: 2; width: 100%; height: 64px; background: linear-gradient(0deg, var(--panel), var(--X15)); @@ -833,6 +860,13 @@ function showReactions(): void { } } } + + .colorBar { + top: 6px; + left: 6px; + width: 4px; + height: calc(100% - 12px); + } } @container (max-width: 300px) { diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index b9ab366850..0d6d329d98 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -166,6 +166,7 @@ import { useTooltip } from '@/scripts/use-tooltip'; import { claimAchievement } from '@/scripts/achievements'; import { MenuItem } from '@/types/menu'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; +import { showMovedDialog } from '@/scripts/show-moved-dialog'; const props = defineProps<{ note: misskey.entities.Note; @@ -248,6 +249,7 @@ useTooltip(renoteButton, async (showing) => { function renote(viaKeyboard = false) { pleaseLogin(); + showMovedDialog(); let items = [] as MenuItem[]; @@ -318,6 +320,7 @@ function renote(viaKeyboard = false) { function reply(viaKeyboard = false): void { pleaseLogin(); + showMovedDialog(); os.post({ reply: appearNote, animation: !viaKeyboard, @@ -328,6 +331,7 @@ function reply(viaKeyboard = false): void { function react(viaKeyboard = false): void { pleaseLogin(); + showMovedDialog(); if (appearNote.reactionAcceptance === 'likeOnly') { os.api('notes/reactions/create', { noteId: appearNote.id, @@ -394,6 +398,7 @@ async function clip() { function showRenoteMenu(viaKeyboard = false): void { if (!isMyRenote) return; + pleaseLogin(); os.popupMenu([{ text: i18n.ts.unrenote, icon: 'ti ti-trash', diff --git a/packages/frontend/src/components/MkNumberDiff.vue b/packages/frontend/src/components/MkNumberDiff.vue index e7d4a5472a..303417dae8 100644 --- a/packages/frontend/src/components/MkNumberDiff.vue +++ b/packages/frontend/src/components/MkNumberDiff.vue @@ -1,47 +1,32 @@ <template> -<span class="ceaaebcd" :class="{ isPlus, isMinus, isZero }"> +<span class="ceaaebcd" :class="{ [$style.isPlus]: isPlus, [$style.isMinus]: isMinus, [$style.isZero]: isZero }"> <slot name="before"></slot>{{ isPlus ? '+' : '' }}{{ number(value) }}<slot name="after"></slot> </span> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed } from 'vue'; import number from '@/filters/number'; -export default defineComponent({ - props: { - value: { - type: Number, - required: true, - }, - }, +const props = defineProps<{ + value: number; +}>(); - setup(props) { - const isPlus = computed(() => props.value > 0); - const isMinus = computed(() => props.value < 0); - const isZero = computed(() => props.value === 0); - return { - isPlus, - isMinus, - isZero, - number, - }; - }, -}); +const isPlus = computed(() => props.value > 0); +const isMinus = computed(() => props.value < 0); +const isZero = computed(() => props.value === 0); </script> -<style lang="scss" scoped> -.ceaaebcd { - &.isPlus { - color: var(--success); - } +<style lang="scss" module> +.isPlus { + color: var(--success); +} - &.isMinus { - color: var(--error); - } +.isMinus { + color: var(--error); +} - &.isZero { - opacity: 0.5; - } +.isZero { + opacity: 0.5; } </style> diff --git a/packages/frontend/src/components/MkOmit.vue b/packages/frontend/src/components/MkOmit.vue index 0f148022bf..e2d68d12c3 100644 --- a/packages/frontend/src/components/MkOmit.vue +++ b/packages/frontend/src/components/MkOmit.vue @@ -17,7 +17,7 @@ const props = withDefaults(defineProps<{ maxHeight: 200, }); -let content = $ref<HTMLElement>(); +let content = $shallowRef<HTMLElement>(); let omitted = $ref(false); let ignoreOmit = $ref(false); diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index 42a3748d9a..c65cb7d6e5 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -247,6 +247,10 @@ watch($$(text), () => { checkMissingMention(); }, { immediate: true }); +watch($$(visibility), () => { + checkMissingMention(); +}, { immediate: true }); + watch($$(visibleUsers), () => { checkMissingMention(); }, { @@ -900,27 +904,28 @@ defineExpose({ } .headerLeft { - display: grid; - grid-template-columns: repeat(2, minmax(36px, 50px)); - grid-template-rows: minmax(40px, 100%); + display: flex; + flex: 0 1 100px; } .cancel { padding: 0; font-size: 1em; height: 100%; + flex: 0 1 50px; } .account { height: 100%; display: inline-flex; vertical-align: bottom; + flex: 0 1 50px; } .avatar { width: 28px; height: 28px; - margin: auto 0; + margin: auto; } .headerRight { diff --git a/packages/frontend/src/components/MkRadio.vue b/packages/frontend/src/components/MkRadio.vue index fcf454c77a..5db2f5ee6d 100644 --- a/packages/frontend/src/components/MkRadio.vue +++ b/packages/frontend/src/components/MkRadio.vue @@ -24,7 +24,7 @@ import { } from 'vue'; const props = defineProps<{ modelValue: any; value: any; - disabled: boolean; + disabled?: boolean; }>(); const emit = defineEmits<{ diff --git a/packages/frontend/src/components/MkRadios.vue b/packages/frontend/src/components/MkRadios.vue index 8590ccf9ae..e2240fb4e1 100644 --- a/packages/frontend/src/components/MkRadios.vue +++ b/packages/frontend/src/components/MkRadios.vue @@ -1,5 +1,5 @@ <script lang="ts"> -import { defineComponent, h } from 'vue'; +import { VNode, defineComponent, h } from 'vue'; import MkRadio from './MkRadio.vue'; export default defineComponent({ @@ -22,31 +22,33 @@ export default defineComponent({ }, }, render() { + console.log(this.$slots, this.$slots.label && this.$slots.label()); + if (!this.$slots.default) return null; let options = this.$slots.default(); const label = this.$slots.label && this.$slots.label(); const caption = this.$slots.caption && this.$slots.caption(); // なぜかFragmentになることがあるため - if (options.length === 1 && options[0].props == null) options = options[0].children; + if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[]; return h('div', { class: 'novjtcto', }, [ ...(label ? [h('div', { class: 'label', - }, [label])] : []), + }, label)] : []), h('div', { class: 'body', }, options.map(option => h(MkRadio, { key: option.key, - value: option.props.value, + value: option.props?.value, modelValue: this.value, 'onUpdate:modelValue': value => this.value = value, - }, option.children)), + }, () => option.children)), ), ...(caption ? [h('div', { class: 'caption', - }, [caption])] : []), + }, caption)] : []), ]); }, }); diff --git a/packages/frontend/src/components/MkRange.vue b/packages/frontend/src/components/MkRange.vue index a1ee6367a0..eaa134df25 100644 --- a/packages/frontend/src/components/MkRange.vue +++ b/packages/frontend/src/components/MkRange.vue @@ -17,7 +17,7 @@ </template> <script lang="ts" setup> -import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch, shallowRef } from 'vue'; import * as os from '@/os'; const props = withDefaults(defineProps<{ @@ -39,8 +39,8 @@ const emit = defineEmits<{ (ev: 'update:modelValue', value: number): void; }>(); -const containerEl = ref<HTMLElement>(); -const thumbEl = ref<HTMLElement>(); +const containerEl = shallowRef<HTMLElement>(); +const thumbEl = shallowRef<HTMLElement>(); const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); const steppedRawValue = computed(() => { diff --git a/packages/frontend/src/components/MkReactedUsersDialog.vue b/packages/frontend/src/components/MkReactedUsersDialog.vue index 1506e24ce8..0c0cc36692 100644 --- a/packages/frontend/src/components/MkReactedUsersDialog.vue +++ b/packages/frontend/src/components/MkReactedUsersDialog.vue @@ -6,7 +6,7 @@ @close="dialog.close()" @closed="emit('closed')" > - <template #header>{{ i18n.ts.reactions }}</template> + <template #header>{{ i18n.ts.reactionsList }}</template> <MkSpacer :margin-min="20" :margin-max="28"> <div v-if="note" class="_gaps"> @@ -21,7 +21,7 @@ <span style="margin-left: 4px;">{{ note.reactions[reaction] }}</span> </button> </div> - <MkA v-for="user in users" :key="user.id" :to="userPage(user)"> + <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()"> <MkUserCardMini :user="user" :with-chart="false"/> </MkA> </template> diff --git a/packages/frontend/src/components/MkReactionsViewer.details.vue b/packages/frontend/src/components/MkReactionsViewer.details.vue index b4210be911..f5e611c62a 100644 --- a/packages/frontend/src/components/MkReactionsViewer.details.vue +++ b/packages/frontend/src/components/MkReactionsViewer.details.vue @@ -10,7 +10,7 @@ <MkAvatar :class="$style.avatar" :user="u"/> <MkUserName :user="u" :nowrap="true"/> </div> - <div v-if="users.length > 10">+{{ count - 10 }}</div> + <div v-if="users.length > 10" :class="$style.more">+{{ count - 10 }}</div> </div> </div> </MkTooltip> @@ -50,7 +50,9 @@ function getReactionName(reaction: string): string { .reaction { max-width: 100px; + padding-right: 10px; text-align: center; + border-right: solid 0.5px var(--divider); } .reactionIcon { @@ -66,25 +68,20 @@ function getReactionName(reaction: string): string { } .users { + contain: content; flex: 1; min-width: 0; + margin: -4px 14px 0 10px; font-size: 0.95em; - border-left: solid 0.5px var(--divider); - padding-left: 10px; - margin-left: 10px; - margin-right: 14px; text-align: left; } .user { line-height: 24px; + padding-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - - &:not(:last-child) { - margin-bottom: 3px; - } } .avatar { @@ -92,4 +89,8 @@ function getReactionName(reaction: string): string { height: 24px; margin-right: 3px; } + +.more { + padding-top: 4px; +} </style> diff --git a/packages/frontend/src/components/MkRenotedUsersDialog.vue b/packages/frontend/src/components/MkRenotedUsersDialog.vue new file mode 100644 index 0000000000..56025535f1 --- /dev/null +++ b/packages/frontend/src/components/MkRenotedUsersDialog.vue @@ -0,0 +1,65 @@ +<template> +<MkModalWindow + ref="dialog" + :width="400" + :height="450" + @close="dialog.close()" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.renotesList }}</template> + + <MkSpacer :margin-min="20" :margin-max="28"> + <div v-if="renotes" class="_gaps"> + <div v-if="renotes.length === 0" class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.nothing }}</div> + </div> + <template v-else> + <MkA v-for="user in users" :key="user.id" :to="userPage(user)" @click="dialog.close()"> + <MkUserCardMini :user="user" :with-chart="false"/> + </MkA> + </template> + </div> + <div v-else> + <MkLoading/> + </div> + </MkSpacer> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import * as misskey from 'misskey-js'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; +import { userPage } from '@/filters/user'; +import { i18n } from '@/i18n'; +import * as os from '@/os'; + +const emit = defineEmits<{ + (ev: 'closed'): void, +}>(); + +const props = defineProps<{ + noteId: misskey.entities.Note['id']; +}>(); + +const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); + +let note = $ref<misskey.entities.Note>(); +let renotes = $ref(); +let users = $ref(); + +onMounted(async () => { + const res = await os.api('notes/renotes', { + noteId: props.noteId, + limit: 30, + }); + + renotes = res; + users = res.map(x => x.user); +}); +</script> + +<style lang="scss" module> +</style> diff --git a/packages/frontend/src/components/MkRetentionHeatmap.vue b/packages/frontend/src/components/MkRetentionHeatmap.vue index 85c009f746..f33f68cab7 100644 --- a/packages/frontend/src/components/MkRetentionHeatmap.vue +++ b/packages/frontend/src/components/MkRetentionHeatmap.vue @@ -44,7 +44,13 @@ async function renderChart() { const data = []; for (const record of raw) { - let i = 0; + data.push({ + x: 0, + y: record.createdAt, + v: record.users, + }); + + let i = 1; for (const date of Object.keys(record.data).sort((a, b) => new Date(a).getTime() - new Date(b).getTime())) { data.push({ x: i, @@ -61,8 +67,14 @@ async function renderChart() { const color = defaultStore.state.darkMode ? '#b4e900' : '#86b300'; - // 視覚上の分かりやすさのため上から最も大きい3つの値の平均を最大値とする - const max = raw.map(x => x.users).slice().sort((a, b) => b - a).slice(0, 3).reduce((a, b) => a + b, 0) / 3; + const getYYYYMMDD = (date: Date) => { + const y = date.getFullYear().toString().padStart(2, '0'); + const m = (date.getMonth() + 1).toString().padStart(2, '0'); + const d = date.getDate().toString().padStart(2, '0'); + return `${y}/${m}/${d}`; + }; + + const max = (createdAt: string) => raw.find(x => x.createdAt === createdAt)!.users; const marginEachCell = 12; @@ -78,7 +90,7 @@ async function renderChart() { borderRadius: 3, backgroundColor(c) { const value = c.dataset.data[c.dataIndex].v; - const a = value / max; + const a = value / max(c.dataset.data[c.dataIndex].y); return alpha(color, a); }, fill: true, @@ -115,7 +127,7 @@ async function renderChart() { maxRotation: 0, autoSkipPadding: 0, autoSkip: false, - callback: (value, index, values) => value + 1, + callback: (value, index, values) => value, }, }, y: { @@ -150,11 +162,11 @@ async function renderChart() { callbacks: { title(context) { const v = context[0].dataset.data[context[0].dataIndex]; - return v.d; + return getYYYYMMDD(new Date(new Date(v.y).getTime() + (v.x * 86400000))); }, label(context) { const v = context.dataset.data[context.dataIndex]; - return ['Active: ' + v.v]; + return [`Active: ${v.v} (${Math.round((v.v / max(v.y)) * 100)}%)`]; }, }, //mode: 'index', diff --git a/packages/frontend/src/components/MkSample.vue b/packages/frontend/src/components/MkSample.vue index 7a3bc20888..922b862b47 100644 --- a/packages/frontend/src/components/MkSample.vue +++ b/packages/frontend/src/components/MkSample.vue @@ -87,7 +87,7 @@ export default defineComponent({ }, async openDrive() { - os.selectDriveFile(); + os.selectDriveFile(false); }, async selectUser() { diff --git a/packages/frontend/src/components/MkSignup.vue b/packages/frontend/src/components/MkSignup.vue deleted file mode 100644 index 30279148f8..0000000000 --- a/packages/frontend/src/components/MkSignup.vue +++ /dev/null @@ -1,263 +0,0 @@ -<template> -<form class="qlvuhzng _gaps_m" autocomplete="new-password" @submit.prevent="onSubmit"> - <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required> - <template #label>{{ i18n.ts.invitationCode }}</template> - <template #prefix><i class="ti ti-key"></i></template> - </MkInput> - <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername"> - <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template> - <template #prefix>@</template> - <template #suffix>@{{ host }}</template> - <template #caption> - <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div> - <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> - <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> - <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> - <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> - <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span> - <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span> - <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span> - </template> - </MkInput> - <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail"> - <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template> - <template #prefix><i class="ti ti-mail"></i></template> - <template #caption> - <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> - <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> - <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span> - <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span> - <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span> - <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span> - <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span> - <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> - <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> - </template> - </MkInput> - <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword"> - <template #label>{{ i18n.ts.password }}</template> - <template #prefix><i class="ti ti-lock"></i></template> - <template #caption> - <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span> - <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span> - <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span> - </template> - </MkInput> - <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype"> - <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template> - <template #prefix><i class="ti ti-lock"></i></template> - <template #caption> - <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span> - <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span> - </template> - </MkInput> - <MkSwitch v-model="ToSAgreement" class="tou"> - <template #label>{{ i18n.ts.agreeBelow }}</template> - </MkSwitch> - <ul style="margin: 0; padding-left: 2em;"> - <li v-if="instance.tosUrl"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a></li> - <li><a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }}</a></li> - </ul> - <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> - <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> - <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> - <MkButton type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton> -</form> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import getPasswordStrength from 'syuilo-password-strength'; -import { toUnicode } from 'punycode/'; -import MkButton from './MkButton.vue'; -import MkInput from './MkInput.vue'; -import MkSwitch from './MkSwitch.vue'; -import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; -import * as config from '@/config'; -import * as os from '@/os'; -import { login } from '@/account'; -import { instance } from '@/instance'; -import { i18n } from '@/i18n'; - -const props = withDefaults(defineProps<{ - autoSet?: boolean; -}>(), { - autoSet: false, -}); - -const emit = defineEmits<{ - (ev: 'signup', user: Record<string, any>): void; - (ev: 'signupEmailPending'): void; -}>(); - -const host = toUnicode(config.host); - -let hcaptcha = $ref<Captcha | undefined>(); -let recaptcha = $ref<Captcha | undefined>(); -let turnstile = $ref<Captcha | undefined>(); - -let username: string = $ref(''); -let password: string = $ref(''); -let retypedPassword: string = $ref(''); -let invitationCode: string = $ref(''); -let email = $ref(''); -let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null); -let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null); -let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref(''); -let passwordRetypeState: null | 'match' | 'not-match' = $ref(null); -let submitting: boolean = $ref(false); -let ToSAgreement: boolean = $ref(false); -let hCaptchaResponse = $ref(null); -let reCaptchaResponse = $ref(null); -let turnstileResponse = $ref(null); -let usernameAbortController: null | AbortController = $ref(null); -let emailAbortController: null | AbortController = $ref(null); - -const shouldDisableSubmitting = $computed((): boolean => { - return submitting || - instance.tosUrl && !ToSAgreement || - instance.enableHcaptcha && !hCaptchaResponse || - instance.enableRecaptcha && !reCaptchaResponse || - instance.enableTurnstile && !turnstileResponse || - instance.emailRequiredForSignup && emailState !== 'ok' || - usernameState !== 'ok' || - passwordRetypeState !== 'match'; -}); - -function onChangeUsername(): void { - if (username === '') { - usernameState = null; - return; - } - - { - const err = - !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : - username.length < 1 ? 'min-range' : - username.length > 20 ? 'max-range' : - null; - - if (err) { - usernameState = err; - return; - } - } - - if (usernameAbortController != null) { - usernameAbortController.abort(); - } - usernameState = 'wait'; - usernameAbortController = new AbortController(); - - os.api('username/available', { - username, - }, undefined, usernameAbortController.signal).then(result => { - usernameState = result.available ? 'ok' : 'unavailable'; - }).catch((err) => { - if (err.name !== 'AbortError') { - usernameState = 'error'; - } - }); -} - -function onChangeEmail(): void { - if (email === '') { - emailState = null; - return; - } - - if (emailAbortController != null) { - emailAbortController.abort(); - } - emailState = 'wait'; - emailAbortController = new AbortController(); - - os.api('email-address/available', { - emailAddress: email, - }, undefined, emailAbortController.signal).then(result => { - emailState = result.available ? 'ok' : - result.reason === 'used' ? 'unavailable:used' : - result.reason === 'format' ? 'unavailable:format' : - result.reason === 'disposable' ? 'unavailable:disposable' : - result.reason === 'mx' ? 'unavailable:mx' : - result.reason === 'smtp' ? 'unavailable:smtp' : - 'unavailable'; - }).catch((err) => { - if (err.name !== 'AbortError') { - emailState = 'error'; - } - }); -} - -function onChangePassword(): void { - if (password === '') { - passwordStrength = ''; - return; - } - - const strength = getPasswordStrength(password); - passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; -} - -function onChangePasswordRetype(): void { - if (retypedPassword === '') { - passwordRetypeState = null; - return; - } - - passwordRetypeState = password === retypedPassword ? 'match' : 'not-match'; -} - -async function onSubmit(): Promise<void> { - if (submitting) return; - submitting = true; - - try { - await os.api('signup', { - username, - password, - emailAddress: email, - invitationCode, - 'hcaptcha-response': hCaptchaResponse, - 'g-recaptcha-response': reCaptchaResponse, - 'turnstile-response': turnstileResponse, - }); - if (instance.emailRequiredForSignup) { - os.alert({ - type: 'success', - title: i18n.ts._signup.almostThere, - text: i18n.t('_signup.emailSent', { email }), - }); - emit('signupEmailPending'); - } else { - const res = await os.api('signin', { - username, - password, - }); - emit('signup', res); - - if (props.autoSet) { - return login(res.i); - } - } - } catch { - submitting = false; - hcaptcha?.reset?.(); - recaptcha?.reset?.(); - turnstile?.reset?.(); - - os.alert({ - type: 'error', - text: i18n.ts.somethingHappened, - }); - } -} -</script> - -<style lang="scss" scoped> -.qlvuhzng { - .captcha { - margin: 16px 0; - } -} -</style> diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue new file mode 100644 index 0000000000..0e8bdb321e --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.form.vue @@ -0,0 +1,272 @@ +<template> +<div> + <div :class="$style.banner"> + <i class="ti ti-user-edit"></i> + </div> + <MkSpacer :margin-min="20" :margin-max="32"> + <form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit"> + <MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required> + <template #label>{{ i18n.ts.invitationCode }}</template> + <template #prefix><i class="ti ti-key"></i></template> + </MkInput> + <MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername"> + <template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template> + <template #prefix>@</template> + <template #suffix>@{{ host }}</template> + <template #caption> + <div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div> + <span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> + <span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> + <span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> + <span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> + <span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span> + <span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span> + <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span> + </template> + </MkInput> + <MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail"> + <template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template> + <template #prefix><i class="ti ti-mail"></i></template> + <template #caption> + <span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span> + <span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span> + <span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span> + <span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span> + <span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span> + <span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span> + <span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span> + <span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span> + <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> + </template> + </MkInput> + <MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword"> + <template #label>{{ i18n.ts.password }}</template> + <template #prefix><i class="ti ti-lock"></i></template> + <template #caption> + <span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span> + <span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span> + <span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span> + </template> + </MkInput> + <MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype"> + <template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template> + <template #prefix><i class="ti ti-lock"></i></template> + <template #caption> + <span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span> + <span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span> + </template> + </MkInput> + <MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/> + <MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/> + <MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;"> + <template v-if="submitting"> + <MkLoading :em="true" :colored="false"/> + </template> + <template v-else>{{ i18n.ts.start }}</template> + </MkButton> + </form> + </MkSpacer> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import getPasswordStrength from 'syuilo-password-strength'; +import { toUnicode } from 'punycode/'; +import MkButton from './MkButton.vue'; +import MkInput from './MkInput.vue'; +import MkSwitch from './MkSwitch.vue'; +import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue'; +import * as config from '@/config'; +import * as os from '@/os'; +import { login } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + autoSet?: boolean; +}>(), { + autoSet: false, +}); + +const emit = defineEmits<{ + (ev: 'signup', user: Record<string, any>): void; + (ev: 'signupEmailPending'): void; +}>(); + +const host = toUnicode(config.host); + +let hcaptcha = $ref<Captcha | undefined>(); +let recaptcha = $ref<Captcha | undefined>(); +let turnstile = $ref<Captcha | undefined>(); + +let username: string = $ref(''); +let password: string = $ref(''); +let retypedPassword: string = $ref(''); +let invitationCode: string = $ref(''); +let email = $ref(''); +let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null); +let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null); +let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref(''); +let passwordRetypeState: null | 'match' | 'not-match' = $ref(null); +let submitting: boolean = $ref(false); +let hCaptchaResponse = $ref(null); +let reCaptchaResponse = $ref(null); +let turnstileResponse = $ref(null); +let usernameAbortController: null | AbortController = $ref(null); +let emailAbortController: null | AbortController = $ref(null); + +const shouldDisableSubmitting = $computed((): boolean => { + return submitting || + instance.enableHcaptcha && !hCaptchaResponse || + instance.enableRecaptcha && !reCaptchaResponse || + instance.enableTurnstile && !turnstileResponse || + instance.emailRequiredForSignup && emailState !== 'ok' || + usernameState !== 'ok' || + passwordRetypeState !== 'match'; +}); + +function onChangeUsername(): void { + if (username === '') { + usernameState = null; + return; + } + + { + const err = + !username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' : + username.length < 1 ? 'min-range' : + username.length > 20 ? 'max-range' : + null; + + if (err) { + usernameState = err; + return; + } + } + + if (usernameAbortController != null) { + usernameAbortController.abort(); + } + usernameState = 'wait'; + usernameAbortController = new AbortController(); + + os.api('username/available', { + username, + }, undefined, usernameAbortController.signal).then(result => { + usernameState = result.available ? 'ok' : 'unavailable'; + }).catch((err) => { + if (err.name !== 'AbortError') { + usernameState = 'error'; + } + }); +} + +function onChangeEmail(): void { + if (email === '') { + emailState = null; + return; + } + + if (emailAbortController != null) { + emailAbortController.abort(); + } + emailState = 'wait'; + emailAbortController = new AbortController(); + + os.api('email-address/available', { + emailAddress: email, + }, undefined, emailAbortController.signal).then(result => { + emailState = result.available ? 'ok' : + result.reason === 'used' ? 'unavailable:used' : + result.reason === 'format' ? 'unavailable:format' : + result.reason === 'disposable' ? 'unavailable:disposable' : + result.reason === 'mx' ? 'unavailable:mx' : + result.reason === 'smtp' ? 'unavailable:smtp' : + 'unavailable'; + }).catch((err) => { + if (err.name !== 'AbortError') { + emailState = 'error'; + } + }); +} + +function onChangePassword(): void { + if (password === '') { + passwordStrength = ''; + return; + } + + const strength = getPasswordStrength(password); + passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low'; +} + +function onChangePasswordRetype(): void { + if (retypedPassword === '') { + passwordRetypeState = null; + return; + } + + passwordRetypeState = password === retypedPassword ? 'match' : 'not-match'; +} + +async function onSubmit(): Promise<void> { + if (submitting) return; + submitting = true; + + try { + await os.api('signup', { + username, + password, + emailAddress: email, + invitationCode, + 'hcaptcha-response': hCaptchaResponse, + 'g-recaptcha-response': reCaptchaResponse, + 'turnstile-response': turnstileResponse, + }); + if (instance.emailRequiredForSignup) { + os.alert({ + type: 'success', + title: i18n.ts._signup.almostThere, + text: i18n.t('_signup.emailSent', { email }), + }); + emit('signupEmailPending'); + } else { + const res = await os.api('signin', { + username, + password, + }); + emit('signup', res); + + if (props.autoSet) { + return login(res.i); + } + } + } catch { + submitting = false; + hcaptcha?.reset?.(); + recaptcha?.reset?.(); + turnstile?.reset?.(); + + os.alert({ + type: 'error', + text: i18n.ts.somethingHappened, + }); + } +} +</script> + +<style lang="scss" module> +.banner { + padding: 16px; + text-align: center; + font-size: 26px; + background-color: var(--accentedBg); + color: var(--accent); +} + +.captcha { + margin: 16px 0; +} +</style> diff --git a/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts new file mode 100644 index 0000000000..2d95455730 --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.rules.stories.impl.ts @@ -0,0 +1,94 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { expect } from '@storybook/jest'; +import { userEvent, waitFor, within } from '@storybook/testing-library'; +import { StoryObj } from '@storybook/vue3'; +import { onBeforeUnmount } from 'vue'; +import MkSignupServerRules from './MkSignupDialog.rules.vue'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +export const Empty = { + render(args) { + return { + components: { + MkSignupServerRules, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkSignupServerRules v-bind="props" />', + }; + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const groups = await canvas.findAllByRole('group'); + const buttons = await canvas.findAllByRole('button'); + for (const group of groups) { + if (group.ariaExpanded === 'true') { + continue; + } + const button = await within(group).findByRole('button'); + userEvent.click(button); + await waitFor(() => expect(group).toHaveAttribute('aria-expanded', 'true')); + } + const labels = await canvas.findAllByText(i18n.ts.agree); + for (const label of labels) { + expect(buttons.at(-1)).toBeDisabled(); + await waitFor(() => userEvent.click(label)); + } + expect(buttons.at(-1)).toBeEnabled(); + }, + args: { + serverRules: [], + tosUrl: null, + }, + decorators: [ + (_, context) => ({ + setup() { + instance.serverRules = context.args.serverRules; + instance.tosUrl = context.args.tosUrl; + onBeforeUnmount(() => { + // FIXME: 呼び出されない + instance.serverRules = []; + instance.tosUrl = null; + }); + }, + template: '<story/>', + }), + ], + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkSignupServerRules>; +export const ServerRulesOnly = { + ...Empty, + args: { + ...Empty.args, + serverRules: [ + 'ルール', + ], + }, +} satisfies StoryObj<typeof MkSignupServerRules>; +export const TOSOnly = { + ...Empty, + args: { + ...Empty.args, + tosUrl: 'https://example.com/tos', + }, +} satisfies StoryObj<typeof MkSignupServerRules>; +export const ServerRulesAndTOS = { + ...Empty, + args: { + ...Empty.args, + serverRules: ServerRulesOnly.args.serverRules, + tosUrl: TOSOnly.args.tosUrl, + }, +} satisfies StoryObj<typeof MkSignupServerRules>; diff --git a/packages/frontend/src/components/MkSignupDialog.rules.vue b/packages/frontend/src/components/MkSignupDialog.rules.vue new file mode 100644 index 0000000000..6da81c3bcb --- /dev/null +++ b/packages/frontend/src/components/MkSignupDialog.rules.vue @@ -0,0 +1,124 @@ +<template> +<div> + <div :class="$style.banner"> + <i class="ti ti-checklist"></i> + </div> + <MkSpacer :margin-min="20" :margin-max="28"> + <div class="_gaps_m"> + <div v-if="instance.disableRegistration"> + <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> + </div> + + <div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div> + + <MkFolder v-if="availableServerRules" :default-open="true"> + <template #label>{{ i18n.ts.serverRules }}</template> + <template #suffix><i v-if="agreeServerRules" class="ti ti-check" style="color: var(--success)"></i></template> + + <ol class="_gaps_s" :class="$style.rules"> + <li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li> + </ol> + + <MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> + </MkFolder> + + <MkFolder v-if="availableTos" :default-open="true"> + <template #label>{{ i18n.ts.termsOfService }}</template> + <template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template> + + <a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a> + + <MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch> + </MkFolder> + + <MkFolder :default-open="true"> + <template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template> + <template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--success)"></i></template> + + <a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a> + + <MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch> + </MkFolder> + + <div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div> + + <div class="_buttonsCenter"> + <MkButton inline rounded @click="emit('cancel')">{{ i18n.ts.cancel }}</MkButton> + <MkButton inline primary rounded gradate :disabled="!agreed" data-cy-signup-rules-continue @click="emit('done')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </div> + </MkSpacer> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref } from 'vue'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkInfo from '@/components/MkInfo.vue'; + +const availableServerRules = instance.serverRules.length > 0; +const availableTos = instance.tosUrl != null; + +const agreeServerRules = ref(false); +const agreeTos = ref(false); +const agreeNote = ref(false); + +const agreed = computed(() => { + return (!availableServerRules || agreeServerRules.value) && (!availableTos || agreeTos.value) && agreeNote.value; +}); + +const emit = defineEmits<{ + (ev: 'cancel'): void; + (ev: 'done'): void; +}>(); +</script> + +<style lang="scss" module> +.banner { + padding: 16px; + text-align: center; + font-size: 26px; + background-color: var(--accentedBg); + color: var(--accent); +} + +.rules { + counter-reset: item; + list-style: none; + padding: 0; + margin: 0; +} + +.rule { + display: flex; + gap: 8px; + word-break: break-word; + + &::before { + flex-shrink: 0; + display: flex; + position: sticky; + top: calc(var(--stickyTop, 0px) + 8px); + counter-increment: item; + content: counter(item); + width: 32px; + height: 32px; + line-height: 32px; + background-color: var(--accentedBg); + color: var(--accent); + font-size: 13px; + font-weight: bold; + align-items: center; + justify-content: center; + border-radius: 999px; + } +} + +.ruleText { + padding-top: 6px; +} +</style> diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index 790c1e94df..17f8b86425 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -1,24 +1,40 @@ <template> <MkModalWindow ref="dialog" - :width="366" - :height="500" + :width="500" + :height="600" @close="dialog.close()" @closed="$emit('closed')" > <template #header>{{ i18n.ts.signup }}</template> - <MkSpacer :margin-min="20" :margin-max="28"> - <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/> - </MkSpacer> + <div style="overflow-x: clip;"> + <Transition + mode="out-in" + :enter-active-class="$style.transition_x_enterActive" + :leave-active-class="$style.transition_x_leaveActive" + :enter-from-class="$style.transition_x_enterFrom" + :leave-to-class="$style.transition_x_leaveTo" + > + <template v-if="!isAcceptedServerRule"> + <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/> + </template> + <template v-else> + <XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/> + </template> + </Transition> + </div> </MkModalWindow> </template> <script lang="ts" setup> import { } from 'vue'; -import XSignup from '@/components/MkSignup.vue'; +import { $ref } from 'vue/macros'; +import XSignup from '@/components/MkSignupDialog.form.vue'; +import XServerRules from '@/components/MkSignupDialog.rules.vue'; import MkModalWindow from '@/components/MkModalWindow.vue'; import { i18n } from '@/i18n'; +import { instance } from '@/instance'; const props = withDefaults(defineProps<{ autoSet?: boolean; @@ -33,6 +49,8 @@ const emit = defineEmits<{ const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); +const isAcceptedServerRule = $ref(false); + function onSignup(res) { emit('done', res); dialog.close(); @@ -42,3 +60,18 @@ function onSignupEmailPending() { dialog.close(); } </script> + +<style lang="scss" module> +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_x_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_x_leaveTo { + opacity: 0; + transform: translateX(-50px); +} +</style> diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue index 8bb8637dda..d9f6716f92 100644 --- a/packages/frontend/src/components/MkSwitch.vue +++ b/packages/frontend/src/components/MkSwitch.vue @@ -9,7 +9,7 @@ :disabled="disabled" @keydown.enter="toggle" > - <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle"> + <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" data-cy-switch-toggle @click.prevent="toggle"> <div class="knob"></div> </span> <span class="label"> diff --git a/packages/frontend/src/components/MkUserInfo.vue b/packages/frontend/src/components/MkUserInfo.vue index 5086c1b319..6349ada65a 100644 --- a/packages/frontend/src/components/MkUserInfo.vue +++ b/packages/frontend/src/components/MkUserInfo.vue @@ -1,30 +1,30 @@ <template> -<div class="_panel vjnjpkug"> - <div class="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> - <MkAvatar class="avatar" :user="user" indicator/> - <div class="title"> - <MkA class="name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> - <p class="username"><MkAcct :user="user"/></p> +<div class="_panel" :class="$style.root"> + <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar :class="$style.avatar" :user="user" indicator/> + <div :class="$style.title"> + <MkA :class="$style.name" :to="userPage(user)"><MkUserName :user="user" :nowrap="false"/></MkA> + <p :class="$style.username"><MkAcct :user="user"/></p> </div> - <span v-if="$i && $i.id !== user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> - <div class="description"> + <span v-if="$i && $i.id !== user.id && user.isFollowed" :class="$style.followed">{{ i18n.ts.followsYou }}</span> + <div :class="$style.description"> <div v-if="user.description" class="mfm"> <Mfm :text="user.description" :author="user" :i="$i"/> </div> <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span> </div> - <div class="status"> - <div> - <p>{{ i18n.ts.notes }}</p><span>{{ user.notesCount }}</span> + <div :class="$style.status"> + <div :class="$style.statusItem"> + <p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ user.notesCount }}</span> </div> - <div> - <p>{{ i18n.ts.following }}</p><span>{{ user.followingCount }}</span> + <div :class="$style.statusItem"> + <p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ user.followingCount }}</span> </div> - <div> - <p>{{ i18n.ts.followers }}</p><span>{{ user.followersCount }}</span> + <div :class="$style.statusItem"> + <p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ user.followersCount }}</span> </div> </div> - <MkFollowButton v-if="$i && user.id != $i.id" class="koudoku-button" :user="user" mini/> + <MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/> </div> </template> @@ -40,99 +40,99 @@ defineProps<{ }>(); </script> -<style lang="scss" scoped> -.vjnjpkug { +<style lang="scss" module> +.root { position: relative; +} - > .banner { - height: 84px; - background-color: rgba(0, 0, 0, 0.1); - background-size: cover; - background-position: center; - } +.banner { + height: 84px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; +} - > .avatar { - display: block; - position: absolute; - top: 62px; - left: 13px; - z-index: 2; - width: 58px; - height: 58px; - border: solid 4px var(--panel); - } +.avatar { + display: block; + position: absolute; + top: 62px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 4px var(--panel); +} - > .title { - display: block; - padding: 10px 0 10px 88px; +.title { + display: block; + padding: 10px 0 10px 88px; +} - > .name { - display: inline-block; - margin: 0; - font-weight: bold; - line-height: 16px; - word-break: break-all; - } +.name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; +} - > .username { - display: block; - margin: 0; - line-height: 16px; - font-size: 0.8em; - color: var(--fg); - opacity: 0.7; - } - } - - > .followed { - position: absolute; - top: 12px; - left: 12px; - padding: 4px 8px; - color: #fff; - background: rgba(0, 0, 0, 0.7); - font-size: 0.7em; - border-radius: 6px; - } - - > .description { - padding: 16px; - font-size: 0.8em; - border-top: solid 0.5px var(--divider); +.username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; +} - > .mfm { - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - } - } +.followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; +} - > .status { - padding: 10px 16px; - border-top: solid 0.5px var(--divider); +.description { + padding: 16px; + font-size: 0.8em; + border-top: solid 0.5px var(--divider); +} - > div { - display: inline-block; - width: 33%; +.mfm { + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.status { + padding: 10px 16px; + border-top: solid 0.5px var(--divider); +} - > p { - margin: 0; - font-size: 0.7em; - color: var(--fg); - } +.statusItem { + display: inline-block; + width: 33%; +} - > span { - font-size: 1em; - color: var(--accent); - } - } - } +.statusItemLabel { + margin: 0; + font-size: 0.7em; + color: var(--fg); +} + +.statusItemValue { + font-size: 1em; + color: var(--accent); +} - > .koudoku-button { - position: absolute; - top: 8px; - right: 8px; - } +.follow { + position: absolute; + top: 8px; + right: 8px; } </style> diff --git a/packages/frontend/src/components/MkUserList.vue b/packages/frontend/src/components/MkUserList.vue index 51eb426e97..3571ca84d9 100644 --- a/packages/frontend/src/components/MkUserList.vue +++ b/packages/frontend/src/components/MkUserList.vue @@ -8,7 +8,7 @@ </template> <template #default="{ items }"> - <div class="efvhhmdq"> + <div :class="$style.root"> <MkUserInfo v-for="item in items" :key="item.id" class="user" :user="extractor(item)"/> </div> </template> @@ -29,8 +29,8 @@ const props = withDefaults(defineProps<{ }); </script> -<style lang="scss" scoped> -.efvhhmdq { +<style lang="scss" module> +.root { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-gap: var(--margin); diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts new file mode 100644 index 0000000000..7d5a65f41a --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.stories.impl.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog_Follow from './MkUserSetupDialog.Follow.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_Follow, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUserSetupDialog_Follow v-bind="props" />', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + rest.post('/api/pinned-users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkUserSetupDialog_Follow>; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Follow.vue b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue new file mode 100644 index 0000000000..b89e3e4c9d --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Follow.vue @@ -0,0 +1,63 @@ +<template> +<div class="_gaps"> + <div style="text-align: center;">{{ i18n.ts._initialAccountSetting.followUsers }}</div> + + <MkFolder :default-open="true"> + <template #label>{{ i18n.ts.recommended }}</template> + + <MkPagination :pagination="pinnedUsers"> + <template #default="{ items }"> + <div :class="$style.users"> + <XUser v-for="item in items" :key="item.id" :user="item"/> + </div> + </template> + </MkPagination> + </MkFolder> + + <MkFolder :default-open="true"> + <template #label>{{ i18n.ts.popularUsers }}</template> + + <MkPagination :pagination="popularUsers"> + <template #default="{ items }"> + <div :class="$style.users"> + <XUser v-for="item in items" :key="item.id" :user="item"/> + </div> + </template> + </MkPagination> + </MkFolder> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import XUser from '@/components/MkUserSetupDialog.User.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import MkPagination from '@/components/MkPagination.vue'; + +const emit = defineEmits<{ + (ev: 'done'): void; +}>(); + +const pinnedUsers = { endpoint: 'pinned-users', noPaging: true }; + +const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', +} }; +</script> + +<style lang="scss" module> +.users { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + grid-gap: var(--margin); + justify-content: center; +} +</style> diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts new file mode 100644 index 0000000000..f4930aa26b --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.stories.impl.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkUserSetupDialog_Profile from './MkUserSetupDialog.Profile.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_Profile, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUserSetupDialog_Profile v-bind="props" />', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkUserSetupDialog_Profile>; diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue new file mode 100644 index 0000000000..adb8d43349 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue @@ -0,0 +1,101 @@ +<template> +<div class="_gaps"> + <MkInfo>{{ i18n.ts._initialAccountSetting.theseSettingsCanEditLater }}</MkInfo> + + <FormSlot> + <template #label>{{ i18n.ts.avatar }}</template> + <div v-adaptive-bg :class="$style.avatarSection" class="_panel"> + <MkAvatar :class="$style.avatar" :user="$i" @click="setAvatar"/> + <div style="margin-top: 16px;"> + <MkButton primary rounded inline @click="setAvatar">{{ i18n.ts._profile.changeAvatar }}</MkButton> + </div> + </div> + </FormSlot> + + <MkInput v-model="name" :max="30" manual-save data-cy-user-setup-user-name> + <template #label>{{ i18n.ts._profile.name }}</template> + </MkInput> + + <MkTextarea v-model="description" :max="500" tall manual-save data-cy-user-setup-user-description> + <template #label>{{ i18n.ts._profile.description }}</template> + </MkTextarea> + + <MkInfo>{{ i18n.ts._initialAccountSetting.youCanEditMoreSettingsInSettingsPageLater }}</MkInfo> +</div> +</template> + +<script lang="ts" setup> +import { computed, ref, watch } from 'vue'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import FormSlot from '@/components/form/slot.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { chooseFileFromPc } from '@/scripts/select-file'; +import * as os from '@/os'; +import { $i } from '@/account'; + +const emit = defineEmits<{ + (ev: 'done'): void; +}>(); + +const name = ref(''); +const description = ref(''); + +watch(name, () => { + os.apiWithDialog('i/update', { + // 空文字列をnullにしたいので??は使うな + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: name.value || null, + }); +}); + +watch(description, () => { + os.apiWithDialog('i/update', { + // 空文字列をnullにしたいので??は使うな + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + description: description.value || null, + }); +}); + +function setAvatar(ev) { + chooseFileFromPc(false).then(async (files) => { + const file = files[0]; + + let originalOrCropped = file; + + const { canceled } = await os.confirm({ + type: 'question', + text: i18n.t('cropImageAsk'), + okText: i18n.ts.cropYes, + cancelText: i18n.ts.cropNo, + }); + + if (!canceled) { + originalOrCropped = await os.cropImage(file, { + aspectRatio: 1, + }); + } + + const i = await os.apiWithDialog('i/update', { + avatarId: originalOrCropped.id, + }); + $i.avatarId = i.avatarId; + $i.avatarUrl = i.avatarUrl; + }); +} +</script> + +<style lang="scss" module> +.avatarSection { + text-align: center; + padding: 20px; +} + +.avatar { + width: 100px; + height: 100px; +} +</style> diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts new file mode 100644 index 0000000000..7413f4884b --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.User.stories.impl.ts @@ -0,0 +1,32 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog_User from './MkUserSetupDialog.User.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog_User, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUserSetupDialog_User v-bind="props" />', + }; + }, + args: { + user: userDetailed(), + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkUserSetupDialog_User>; diff --git a/packages/frontend/src/components/MkUserSetupDialog.User.vue b/packages/frontend/src/components/MkUserSetupDialog.User.vue new file mode 100644 index 0000000000..d66f34f165 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.User.vue @@ -0,0 +1,101 @@ +<template> +<div v-adaptive-bg class="_panel" style="position: relative;"> + <div :class="$style.banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl})` : ''"></div> + <MkAvatar :class="$style.avatar" :user="user" indicator/> + <div :class="$style.title"> + <div :class="$style.name"><MkUserName :user="user" :nowrap="false"/></div> + <p :class="$style.username"><MkAcct :user="user"/></p> + </div> + <div :class="$style.description"> + <div v-if="user.description" :class="$style.mfm"> + <Mfm :text="user.description" :author="user" :i="$i"/> + </div> + <span v-else style="opacity: 0.7;">{{ i18n.ts.noAccountDescription }}</span> + </div> + <div :class="$style.footer"> + <MkButton v-if="!isFollowing" primary gradate rounded full @click="follow"><i class="ti ti-plus"></i> {{ i18n.ts.follow }}</MkButton> + <div v-else style="opacity: 0.7; text-align: center;">{{ i18n.ts.youFollowing }} <i class="ti ti-check"></i></div> + </div> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import { ref } from 'vue'; +import MkButton from '@/components/MkButton.vue'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import * as os from '@/os'; + +const props = defineProps<{ + user: misskey.entities.UserDetailed; +}>(); + +const isFollowing = ref(false); + +async function follow() { + isFollowing.value = true; + os.api('following/create', { + userId: props.user.id, + }); +} +</script> + +<style lang="scss" module> +.banner { + height: 60px; + background-color: rgba(0, 0, 0, 0.1); + background-size: cover; + background-position: center; +} + +.avatar { + display: block; + position: absolute; + top: 30px; + left: 13px; + z-index: 2; + width: 58px; + height: 58px; + border: solid 4px var(--panel); +} + +.title { + display: block; + padding: 10px 0 10px 88px; +} + +.name { + display: inline-block; + margin: 0; + font-weight: bold; + line-height: 16px; + word-break: break-all; +} + +.username { + display: block; + margin: 0; + line-height: 16px; + font-size: 0.8em; + color: var(--fg); + opacity: 0.7; +} + +.description { + padding: 0 16px 16px 88px; + font-size: 0.9em; +} + +.mfm { + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.footer { + border-top: solid 0.5px var(--divider); + padding: 16px; +} +</style> diff --git a/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts new file mode 100644 index 0000000000..55790602d5 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.stories.impl.ts @@ -0,0 +1,51 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import { rest } from 'msw'; +import { commonHandlers } from '../../.storybook/mocks'; +import { userDetailed } from '../../.storybook/fakes'; +import MkUserSetupDialog from './MkUserSetupDialog.vue'; +export const Default = { + render(args) { + return { + components: { + MkUserSetupDialog, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkUserSetupDialog v-bind="props" />', + }; + }, + args: { + + }, + parameters: { + layout: 'centered', + msw: { + handlers: [ + ...commonHandlers, + rest.post('/api/users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + rest.post('/api/pinned-users', (req, res, ctx) => { + return res(ctx.json([ + userDetailed('44'), + userDetailed('49'), + ])); + }), + ], + }, + }, +} satisfies StoryObj<typeof MkUserSetupDialog>; diff --git a/packages/frontend/src/components/MkUserSetupDialog.vue b/packages/frontend/src/components/MkUserSetupDialog.vue new file mode 100644 index 0000000000..096b88c309 --- /dev/null +++ b/packages/frontend/src/components/MkUserSetupDialog.vue @@ -0,0 +1,145 @@ +<template> +<MkModalWindow + ref="dialog" + :width="500" + :height="550" + data-cy-user-setup + @close="close(true)" + @closed="emit('closed')" +> + <template #header>{{ i18n.ts.initialAccountSetting }}</template> + + <div style="overflow-x: clip;"> + <Transition + mode="out-in" + :enter-active-class="$style.transition_x_enterActive" + :leave-active-class="$style.transition_x_leaveActive" + :enter-from-class="$style.transition_x_enterFrom" + :leave-to-class="$style.transition_x_leaveTo" + > + <template v-if="page === 0"> + <div :class="$style.centerPage"> + <MkSpacer :margin-min="20" :margin-max="28"> + <div class="_gaps" style="text-align: center;"> + <i class="ti ti-confetti" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> + <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.accountCreated }}</div> + <div>{{ i18n.ts._initialAccountSetting.letsStartAccountSetup }}</div> + <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts._initialAccountSetting.profileSetting }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </MkSpacer> + </div> + </template> + <template v-else-if="page === 1"> + <div style="height: 100cqh; overflow: auto;"> + <MkSpacer :margin-min="20" :margin-max="28"> + <XProfile/> + <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </MkSpacer> + </div> + </template> + <template v-else-if="page === 2"> + <div style="height: 100cqh; overflow: auto;"> + <MkSpacer :margin-min="20" :margin-max="28"> + <XFollow/> + <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </MkSpacer> + </div> + </template> + <template v-else-if="page === 3"> + <div :class="$style.centerPage"> + <MkSpacer :margin-min="20" :margin-max="28"> + <div class="_gaps" style="text-align: center;"> + <i class="ti ti-bell-ringing-2" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> + <div style="font-size: 120%;">{{ i18n.ts.pushNotification }}</div> + <div style="padding: 0 16px;">{{ i18n.t('_initialAccountSetting.pushNotificationDescription', { name: instance.name ?? host }) }}</div> + <MkPushNotificationAllowButton primary show-only-to-register style="margin: 0 auto;"/> + <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="page++">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </MkSpacer> + </div> + </template> + <template v-else-if="page === 4"> + <div :class="$style.centerPage"> + <MkSpacer :margin-min="20" :margin-max="28"> + <div class="_gaps" style="text-align: center;"> + <i class="ti ti-check" style="display: block; margin: auto; font-size: 3em; color: var(--accent);"></i> + <div style="font-size: 120%;">{{ i18n.ts._initialAccountSetting.initialAccountSettingCompleted }}</div> + <I18n :src="i18n.ts._initialAccountSetting.ifYouNeedLearnMore" tag="div" style="padding: 0 16px;"> + <template #name>{{ instance.name ?? host }}</template> + <template #link> + <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> + </template> + </I18n> + <div>{{ i18n.t('_initialAccountSetting.haveFun', { name: instance.name ?? host }) }}</div> + <MkButton primary rounded gradate style="margin: 16px auto 0 auto;" data-cy-user-setup-continue @click="close(false)">{{ i18n.ts.close }}</MkButton> + </div> + </MkSpacer> + </div> + </template> + </Transition> + </div> +</MkModalWindow> +</template> + +<script lang="ts" setup> +import { ref, shallowRef, watch } from 'vue'; +import MkModalWindow from '@/components/MkModalWindow.vue'; +import MkButton from '@/components/MkButton.vue'; +import XProfile from '@/components/MkUserSetupDialog.Profile.vue'; +import XFollow from '@/components/MkUserSetupDialog.Follow.vue'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { host } from '@/config'; +import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; +import { defaultStore } from '@/store'; +import * as os from '@/os'; + +const emit = defineEmits<{ + (ev: 'closed'): void; +}>(); + +const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); + +const page = ref(defaultStore.state.accountSetupWizard); + +watch(page, () => { + defaultStore.set('accountSetupWizard', page.value); +}); + +async function close(skip: boolean) { + if (skip) { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts._initialAccountSetting.skipAreYouSure, + }); + if (canceled) return; + } + + dialog.value.close(); + defaultStore.set('accountSetupWizard', -1); +} +</script> + +<style lang="scss" module> +.transition_x_enterActive, +.transition_x_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_x_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_x_leaveTo { + opacity: 0; + transform: translateX(-50px); +} + +.centerPage { + display: flex; + justify-content: center; + align-items: center; + height: 100cqh; + padding-bottom: 30px; + box-sizing: border-box; +} +</style> diff --git a/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue new file mode 100644 index 0000000000..fb705786cf --- /dev/null +++ b/packages/frontend/src/components/MkVisitorDashboard.ActiveUsersChart.vue @@ -0,0 +1,157 @@ +<template> +<div> + <MkLoading v-if="fetching"/> + <div v-show="!fetching" :class="$style.root"> + <canvas ref="chartEl"></canvas> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import { Chart } from 'chart.js'; +import gradient from 'chartjs-plugin-gradient'; +import tinycolor from 'tinycolor2'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import { chartVLine } from '@/scripts/chart-vline'; +import { initChart } from '@/scripts/init-chart'; + +initChart(); + +const chartEl = $shallowRef<HTMLCanvasElement>(null); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 30; +let fetching = $ref(true); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + const computedStyle = getComputedStyle(document.documentElement); + const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(); + + const colorRead = accent; + const colorWrite = '#2ecc71'; + + const max = Math.max(...raw.read); + + chartInstance = new Chart(chartEl, { + type: 'bar', + data: { + datasets: [{ + parsing: false, + label: 'Read', + data: format(raw.read).slice().reverse(), + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 4, + backgroundColor: colorRead, + barPercentage: 0.5, + categoryPercentage: 1, + fill: true, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 8, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + offset: true, + time: { + stepSize: 1, + unit: 'day', + displayFormats: { + day: 'M/d', + month: 'Y/M', + }, + }, + grid: { + display: false, + }, + ticks: { + display: true, + maxRotation: 0, + autoSkipPadding: 8, + }, + }, + y: { + position: 'left', + suggestedMax: 10, + grid: { + display: true, + }, + ticks: { + display: true, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + gradient, + }, + }, + plugins: [chartVLine(vLineColor)], + }); + + fetching = false; +} + +onMounted(async () => { + renderChart(); +}); +</script> + +<style lang="scss" module> +.root { + padding: 20px; +} +</style> diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue new file mode 100644 index 0000000000..6226768127 --- /dev/null +++ b/packages/frontend/src/components/MkVisitorDashboard.vue @@ -0,0 +1,227 @@ +<template> +<div v-if="meta" :class="$style.root"> + <div :class="[$style.main, $style.panel]"> + <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" :class="$style.mainIcon"/> + <button class="_button _acrylic" :class="$style.mainMenu" @click="showMenu"><i class="ti ti-dots"></i></button> + <div :class="$style.mainFg"> + <h1 :class="$style.mainTitle"> + <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> + <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> + <span>{{ instanceName }}</span> + </h1> + <div :class="$style.mainAbout"> + <!-- eslint-disable-next-line vue/no-v-html --> + <div v-html="meta.description || i18n.ts.headlineMisskey"></div> + </div> + <div v-if="instance.disableRegistration" :class="$style.mainWarn"> + <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> + </div> + <div class="_gaps_s" :class="$style.mainActions"> + <MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton> + <MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton> + <MkButton :class="$style.mainAction" full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> + </div> + </div> + </div> + <div v-if="stats" :class="$style.stats"> + <div :class="[$style.statsItem, $style.panel]"> + <div :class="$style.statsItemLabel">{{ i18n.ts.users }}</div> + <div :class="$style.statsItemCount"><MkNumber :value="stats.originalUsersCount"/></div> + </div> + <div :class="[$style.statsItem, $style.panel]"> + <div :class="$style.statsItemLabel">{{ i18n.ts.notes }}</div> + <div :class="$style.statsItemCount"><MkNumber :value="stats.originalNotesCount"/></div> + </div> + </div> + <div v-if="instance.policies.ltlAvailable" :class="[$style.tl, $style.panel]"> + <div :class="$style.tlHeader">{{ i18n.ts.letsLookAtTimeline }}</div> + <div :class="$style.tlBody"> + <MkTimeline src="local"/> + </div> + </div> + <div :class="[$style.activeUsersChart, $style.panel]"> + <XActiveUsersChart/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { Instance } from 'misskey-js/built/entities'; +import XTimeline from './welcome.timeline.vue'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkTimeline from '@/components/MkTimeline.vue'; +import MkInfo from '@/components/MkInfo.vue'; +import { instanceName } from '@/config'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import number from '@/filters/number'; +import MkNumber from '@/components/MkNumber.vue'; +import XActiveUsersChart from '@/components/MkVisitorDashboard.ActiveUsersChart.vue'; + +let meta = $ref<Instance>(); +let stats = $ref(null); + +os.api('meta', { detail: true }).then(_meta => { + meta = _meta; +}); + +os.api('stats', { +}).then((res) => { + stats = res; +}); + +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function showMenu(ev) { + os.popupMenu([{ + text: i18n.ts.instanceInfo, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about'); + }, + }, { + text: i18n.ts.aboutMisskey, + icon: 'ti ti-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: i18n.ts.help, + icon: 'ti ti-help-circle', + action: () => { + window.open('https://misskey-hub.net/help.md', '_blank'); + }, + }], ev.currentTarget ?? ev.target); +} + +function exploreOtherServers() { + // TODO: 言語をよしなに + window.open('https://join.misskey.page/ja-JP/instances', '_blank'); +} +</script> + +<style lang="scss" module> +.root { + position: relative; + display: flex; + flex-direction: column; + gap: 16px; + padding: 32px 0 0 0; +} + +.panel { + position: relative; + background: var(--panel); + border-radius: var(--radius); + box-shadow: 0 12px 32px rgb(0 0 0 / 25%); +} + +.main { + text-align: center; +} + +.mainIcon { + width: 85px; + margin-top: -47px; + vertical-align: bottom; + filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.5)); +} + +.mainMenu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 18px; +} + +.mainFg { + position: relative; + z-index: 1; +} + +.mainTitle { + display: block; + margin: 0; + padding: 16px 32px 24px 32px; + font-size: 1.4em; +} + +.mainLogo { + vertical-align: bottom; + max-height: 120px; + max-width: min(100%, 300px); +} + +.mainAbout { + padding: 0 32px; +} + +.mainWarn { + padding: 32px 32px 0 32px; +} + +.mainActions { + padding: 32px; +} + +.mainAction { + line-height: 28px; +} + +.stats { + display: grid; + grid-template-columns: 1fr 1fr; + grid-gap: 16px; +} + +.statsItem { + overflow: clip; + padding: 16px 20px; +} + +.statsItemLabel { + color: var(--fgTransparentWeak); + font-size: 0.9em; +} + +.statsItemCount { + font-weight: bold; + font-size: 1.2em; + color: var(--accent); +} + +.tl { + overflow: clip; +} + +.tlHeader { + padding: 12px 16px; + border-bottom: solid 1px var(--divider); +} + +.tlBody { + height: 350px; + overflow: auto; +} + +.activeUsersChart { + +} +</style> diff --git a/packages/frontend/src/components/MkWidgets.vue b/packages/frontend/src/components/MkWidgets.vue index d074fdd150..33e594acd8 100644 --- a/packages/frontend/src/components/MkWidgets.vue +++ b/packages/frontend/src/components/MkWidgets.vue @@ -2,11 +2,11 @@ <div :class="$style.root"> <template v-if="edit"> <header :class="$style['edit-header']"> - <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" class="mk-widget-select"> + <MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--margin)" data-cy-widget-select> <template #label>{{ i18n.ts.selectWidget }}</template> <option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.t(`_widgets.${widget}`) }}</option> </MkSelect> - <MkButton inline primary class="mk-widget-add" @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline @click="$emit('exit')">{{ i18n.ts.close }}</MkButton> </header> <Sortable diff --git a/packages/frontend/src/components/MkWindow.vue b/packages/frontend/src/components/MkWindow.vue index 687abed632..b662479b2a 100644 --- a/packages/frontend/src/components/MkWindow.vue +++ b/packages/frontend/src/components/MkWindow.vue @@ -29,7 +29,7 @@ <button v-if="closeButton" v-tooltip="i18n.ts.close" class="_button" :class="$style.headerButton" @click="close()"><i class="ti ti-x"></i></button> </span> </div> - <div v-container :class="$style.content"> + <div :class="$style.content"> <slot></slot> </div> </div> @@ -541,7 +541,7 @@ defineExpose({ flex: 1; overflow: auto; background: var(--panel); - container-type: inline-size; + container-type: size; } $handleSize: 8px; diff --git a/packages/frontend/src/components/global/MkAcct.stories.impl.ts b/packages/frontend/src/components/global/MkAcct.stories.impl.ts index d5e3fc3568..9d5fd3947d 100644 --- a/packages/frontend/src/components/global/MkAcct.stories.impl.ts +++ b/packages/frontend/src/components/global/MkAcct.stories.impl.ts @@ -41,3 +41,35 @@ export const Detail = { detail: true, }, } satisfies StoryObj<typeof MkAcct>; +export const Long = { + ...Default, + args: { + ...Default.args, + user: { + ...userDetailed(), + username: 'the_quick_brown_fox_jumped_over_the_lazy_dog', + host: 'misskey.example', + }, + }, + decorators: [ + () => ({ + template: '<div style="width: 360px;"><story/></div>', + }), + ], +} satisfies StoryObj<typeof MkAcct>; +export const VeryLong = { + ...Default, + args: { + ...Default.args, + user: { + ...userDetailed(), + username: '2c7cc62a697ea3a7826521f3fd34f0cb273693cbe5e9310f35449f43622a5cdc', + host: 'the.quick.brown.fox.jumped.over.the.lazy.dog.very.long.hostname.nostr.example', + }, + }, + decorators: [ + () => ({ + template: '<div style="width: 360px;"><story/></div>', + }), + ], +} satisfies StoryObj<typeof MkAcct>; diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 2b9f892fc6..59358aef70 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -1,5 +1,9 @@ <template> -<span> +<MkCondensedLine v-if="defaultStore.state.enableCondensedLineForAcct" :min-scale="2 / 3"> + <span>@{{ user.username }}</span> + <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> +</MkCondensedLine> +<span v-else> <span>@{{ user.username }}</span> <span v-if="user.host || detail || defaultStore.state.showFullAcct" style="opacity: 0.5;">@{{ user.host || host }}</span> </span> @@ -8,6 +12,7 @@ <script lang="ts" setup> import * as misskey from 'misskey-js'; import { toUnicode } from 'punycode/'; +import MkCondensedLine from './MkCondensedLine.vue'; import { host as hostRaw } from '@/config'; import { defaultStore } from '@/store'; diff --git a/packages/frontend/src/components/global/MkAvatar.vue b/packages/frontend/src/components/global/MkAvatar.vue index 8497b8443b..ad36dcabe4 100644 --- a/packages/frontend/src/components/global/MkAvatar.vue +++ b/packages/frontend/src/components/global/MkAvatar.vue @@ -222,7 +222,7 @@ watch(() => props.user.avatarBlurhash, () => { transform: rotate(37.5deg) skew(30deg); &, &::after { - border-radius: 0 75% 75%; + border-radius: 25% 75% 75%; } > .layer { @@ -251,7 +251,7 @@ watch(() => props.user.avatarBlurhash, () => { transform: rotate(-37.5deg) skew(-30deg); &, &::after { - border-radius: 75% 0 75% 75%; + border-radius: 75% 25% 75% 75%; } > .layer { diff --git a/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts new file mode 100644 index 0000000000..ce985bc59f --- /dev/null +++ b/packages/frontend/src/components/global/MkCondensedLine.stories.impl.ts @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { StoryObj } from '@storybook/vue3'; +import MkCondensedLine from './MkCondensedLine.vue'; +export const Default = { + render(args) { + return { + components: { + MkCondensedLine, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkCondensedLine>{{ props.text }}</MkCondensedLine>', + }; + }, + args: { + text: 'This is a condensed line.', + }, + parameters: { + layout: 'centered', + }, +} satisfies StoryObj<typeof MkCondensedLine>; +export const ContainerIs100px = { + ...Default, + decorators: [ + () => ({ + template: '<div style="width: 100px;"><story/></div>', + }), + ], +} satisfies StoryObj<typeof MkCondensedLine>; diff --git a/packages/frontend/src/components/global/MkCondensedLine.vue b/packages/frontend/src/components/global/MkCondensedLine.vue new file mode 100644 index 0000000000..1d46ff1ec9 --- /dev/null +++ b/packages/frontend/src/components/global/MkCondensedLine.vue @@ -0,0 +1,65 @@ +<template> +<span :class="$style.container"> + <span ref="content" :class="$style.content"> + <slot/> + </span> +</span> +</template> + +<script lang="ts"> +interface Props { + readonly minScale?: number; +} + +const contentSymbol = Symbol(); +const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const content = (entry.target[contentSymbol] ? entry.target : entry.target.firstElementChild) as HTMLSpanElement; + const props: Required<Props> = content[contentSymbol]; + const container = content.parentElement as HTMLSpanElement; + const contentWidth = content.getBoundingClientRect().width; + const containerWidth = container.getBoundingClientRect().width; + container.style.transform = `scaleX(${Math.max(props.minScale, Math.min(1, containerWidth / contentWidth))})`; + } +}); +</script> + +<script setup lang="ts"> +import { ref, watch } from 'vue'; + +const props = withDefaults(defineProps<Props>(), { + minScale: 0, +}); + +const content = ref<HTMLSpanElement>(); + +watch(content, (value, oldValue) => { + if (oldValue) { + delete oldValue[contentSymbol]; + observer.unobserve(oldValue); + if (oldValue.parentElement) { + observer.unobserve(oldValue.parentElement); + } + } + if (value) { + value[contentSymbol] = props; + observer.observe(value); + if (value.parentElement) { + observer.observe(value.parentElement); + } + } +}); +</script> + +<style module lang="scss"> +.container { + display: inline-block; + max-width: 100%; + transform-origin: 0; +} + +.content { + display: inline-block; + white-space: nowrap; +} +</style> diff --git a/packages/frontend/src/components/global/MkError.stories.impl.ts b/packages/frontend/src/components/global/MkError.stories.impl.ts index 60ac5c91ad..8252a4d76e 100644 --- a/packages/frontend/src/components/global/MkError.stories.impl.ts +++ b/packages/frontend/src/components/global/MkError.stories.impl.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { action } from '@storybook/addon-actions'; import { expect } from '@storybook/jest'; import { waitFor } from '@storybook/testing-library'; import { StoryObj } from '@storybook/vue3'; @@ -20,14 +21,21 @@ export const Default = { ...this.args, }; }, + events() { + return { + retry: action('retry'), + }; + }, }, - template: '<MkError v-bind="props" />', + template: '<MkError v-bind="props" v-on="events" />', }; }, async play({ canvasElement }) { await expect(canvasElement.firstElementChild).not.toBeNull(); await waitFor(async () => expect(canvasElement.firstElementChild?.classList).not.toContain('_transition_zoom-enter-active')); }, + args: { + }, parameters: { layout: 'centered', }, diff --git a/packages/frontend/src/components/global/MkPageHeader.vue b/packages/frontend/src/components/global/MkPageHeader.vue index 710edd797a..b91d378b17 100644 --- a/packages/frontend/src/components/global/MkPageHeader.vue +++ b/packages/frontend/src/components/global/MkPageHeader.vue @@ -156,7 +156,7 @@ onUnmounted(() => { } &.thin { - --height: 42px; + --height: 40px; > .buttons { > .button { diff --git a/packages/frontend/src/components/global/MkTime.vue b/packages/frontend/src/components/global/MkTime.vue index 99169512db..261cc0ee18 100644 --- a/packages/frontend/src/components/global/MkTime.vue +++ b/packages/frontend/src/components/global/MkTime.vue @@ -8,6 +8,7 @@ </template> <script lang="ts" setup> +import isChromatic from 'chromatic/isChromatic'; import { onUnmounted } from 'vue'; import { i18n } from '@/i18n'; import { dateTimeFormat } from '@/scripts/intl-const'; @@ -17,7 +18,7 @@ const props = withDefaults(defineProps<{ origin?: Date | null; mode?: 'relative' | 'absolute' | 'detail'; }>(), { - origin: null, + origin: isChromatic() ? new Date('2023-04-01T00:00:00Z') : null, mode: 'relative', }); diff --git a/packages/frontend/src/components/index.ts b/packages/frontend/src/components/index.ts index 63e8fc225c..4ef8111da9 100644 --- a/packages/frontend/src/components/index.ts +++ b/packages/frontend/src/components/index.ts @@ -5,6 +5,7 @@ import MkA from './global/MkA.vue'; import MkAcct from './global/MkAcct.vue'; import MkAvatar from './global/MkAvatar.vue'; import MkEmoji from './global/MkEmoji.vue'; +import MkCondensedLine from './global/MkCondensedLine.vue'; import MkCustomEmoji from './global/MkCustomEmoji.vue'; import MkUserName from './global/MkUserName.vue'; import MkEllipsis from './global/MkEllipsis.vue'; @@ -33,6 +34,7 @@ export const components = { MkAcct: MkAcct, MkAvatar: MkAvatar, MkEmoji: MkEmoji, + MkCondensedLine: MkCondensedLine, MkCustomEmoji: MkCustomEmoji, MkUserName: MkUserName, MkEllipsis: MkEllipsis, @@ -55,6 +57,7 @@ declare module '@vue/runtime-core' { MkAcct: typeof MkAcct; MkAvatar: typeof MkAvatar; MkEmoji: typeof MkEmoji; + MkCondensedLine: typeof MkCondensedLine; MkCustomEmoji: typeof MkCustomEmoji; MkUserName: typeof MkUserName; MkEllipsis: typeof MkEllipsis; |