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 | |
| 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')
139 files changed, 4530 insertions, 2945 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; diff --git a/packages/frontend/src/config.ts b/packages/frontend/src/config.ts index 073b21a0ae..24f60910d1 100644 --- a/packages/frontend/src/config.ts +++ b/packages/frontend/src/config.ts @@ -1,21 +1,22 @@ -import { miLocalStorage } from "./local-storage"; +import { miLocalStorage } from './local-storage'; const address = new URL(location.href); -const siteName = (document.querySelector('meta[property="og:site_name"]') as HTMLMetaElement)?.content; +const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content; export const host = address.host; export const hostname = address.hostname; export const url = address.origin; export const apiUrl = url + '/api'; export const wsUrl = url.replace('http://', 'ws://').replace('https://', 'wss://') + '/streaming'; -export const lang = miLocalStorage.getItem('lang'); +export const lang = miLocalStorage.getItem('lang') ?? 'en-US'; export const langs = _LANGS_; -export let locale = JSON.parse(miLocalStorage.getItem('locale')); +const preParseLocale = miLocalStorage.getItem('locale'); +export let locale = preParseLocale ? JSON.parse(preParseLocale) : null; export const version = _VERSION_; export const instanceName = siteName === 'Misskey' ? host : siteName; export const ui = miLocalStorage.getItem('ui'); export const debug = miLocalStorage.getItem('debug') === 'true'; -export function updateLocale(newLocale) { +export function updateLocale(newLocale): void { locale = newLocale; } diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts index 1d1b8fcea4..aaa3d10302 100644 --- a/packages/frontend/src/const.ts +++ b/packages/frontend/src/const.ts @@ -35,6 +35,11 @@ export const FILE_TYPE_BROWSERSAFE = [ 'audio/webm', 'audio/aac', + + // see https://github.com/misskey-dev/misskey/pull/10686 + 'audio/flac', + 'audio/wav', + // backward compatibility 'audio/x-flac', 'audio/vnd.wave', ]; @@ -56,6 +61,7 @@ export const ROLE_POLICIES = [ 'canSearchNotes', 'canHideAds', 'driveCapacityMb', + 'alwaysMarkNsfw', 'pinLimit', 'antennaLimit', 'wordMuteLimit', diff --git a/packages/frontend/src/directives/container.ts b/packages/frontend/src/directives/container.ts deleted file mode 100644 index a8a93eb9be..0000000000 --- a/packages/frontend/src/directives/container.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Directive } from 'vue'; - -const map = new WeakMap<HTMLElement, ResizeObserver>(); - -export default { - mounted(el: HTMLElement, binding, vn) { - const ro = new ResizeObserver((entries, observer) => { - el.style.setProperty('--containerHeight', el.offsetHeight + 'px'); - }); - ro.observe(el); - map.set(el, ro); - }, - - unmounted(el, binding, vn) { - const ro = map.get(el); - if (ro) { - ro.disconnect(); - map.delete(el); - } - }, -} as Directive; diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts index 064ee4f64b..7847d661d4 100644 --- a/packages/frontend/src/directives/index.ts +++ b/packages/frontend/src/directives/index.ts @@ -11,7 +11,6 @@ import clickAnime from './click-anime'; import panel from './panel'; import adaptiveBorder from './adaptive-border'; import adaptiveBg from './adaptive-bg'; -import container from './container'; export default function(app: App) { for (const [key, value] of Object.entries(directives)) { @@ -32,5 +31,4 @@ export const directives = { 'panel': panel, 'adaptive-border': adaptiveBorder, 'adaptive-bg': adaptiveBg, - 'container': container, }; diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index 5b3e7ec932..49e7bb4008 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -6,18 +6,6 @@ import 'vite/modulepreload-polyfill'; import '@/style.scss'; -//#region account indexedDB migration -import { set } from '@/scripts/idb-proxy'; - -{ - const accounts = miLocalStorage.getItem('accounts'); - if (accounts) { - set('accounts', JSON.parse(accounts)); - miLocalStorage.removeItem('accounts'); - } -} -//#endregion - import { computed, createApp, watch, markRaw, version as vueVersion, defineAsyncComponent } from 'vue'; import { compareVersions } from 'compare-versions'; import JSON5 from 'json5'; @@ -42,11 +30,11 @@ import { reloadChannel } from '@/scripts/unison-reload'; import { reactionPicker } from '@/scripts/reaction-picker'; import { getUrlWithoutLoginId } from '@/scripts/login-id'; import { getAccountFromId } from '@/scripts/get-account-from-id'; -import { deckStore } from './ui/deck/deck-store'; -import { miLocalStorage } from './local-storage'; -import { claimAchievement, claimedAchievements } from './scripts/achievements'; -import { fetchCustomEmojis } from './custom-emojis'; -import { mainRouter } from './router'; +import { deckStore } from '@/ui/deck/deck-store'; +import { miLocalStorage } from '@/local-storage'; +import { claimAchievement, claimedAchievements } from '@/scripts/achievements'; +import { fetchCustomEmojis } from '@/custom-emojis'; +import { mainRouter } from '@/router'; console.info(`Misskey v${version}`); @@ -55,7 +43,9 @@ if (_DEV_) { console.info(`vue ${vueVersion}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).$i = $i; + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as any).$store = defaultStore; window.addEventListener('error', event => { @@ -184,7 +174,7 @@ fetchInstanceMetaPromise.then(() => { try { await fetchCustomEmojis(); -} catch (err) {} +} catch (err) { /* empty */ } const app = createApp( new URLSearchParams(window.location.search).has('zen') ? defineAsyncComponent(() => import('@/ui/zen.vue')) : @@ -212,20 +202,20 @@ await deckStore.ready; // https://github.com/misskey-dev/misskey/pull/8575#issuecomment-1114239210 // なぜかinit.tsの内容が2回実行されることがあるため、mountするdivを1つに制限する -const rootEl = (() => { +const rootEl = ((): HTMLElement => { const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; - const currentEl = document.getElementById(MISSKEY_MOUNT_DIV_ID); + const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); - if (currentEl) { + if (currentRoot) { console.warn('multiple import detected'); - return currentEl; + return currentRoot; } - const rootEl = document.createElement('div'); - rootEl.id = MISSKEY_MOUNT_DIV_ID; - document.body.appendChild(rootEl); - return rootEl; + const root = document.createElement('div'); + root.id = MISSKEY_MOUNT_DIV_ID; + document.body.appendChild(root); + return root; })(); app.mount(rootEl); @@ -256,8 +246,7 @@ if (lastVersion !== version) { popup(defineAsyncComponent(() => import('@/components/MkUpdated.vue')), {}, {}, 'closed'); } } - } catch (err) { - } + } catch (err) { /* empty */ } } await defaultStore.ready; @@ -354,6 +343,16 @@ if ($i) { // only add post shortcuts if logged in hotkeys['p|n'] = post; + if (defaultStore.state.accountSetupWizard !== -1) { + // このウィザードが実装される前に登録したユーザーには表示させないため + // TODO: そのうち消す + if (Date.now() - new Date($i.createdAt).getTime() < 1000 * 60 * 60 * 24) { + popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {}, 'closed'); + } else { + defaultStore.set('accountSetupWizard', -1); + } + } + if ($i.isDeleted) { alert({ type: 'warning', @@ -442,6 +441,10 @@ if ($i) { claimAchievement('client30min'); }, 1000 * 60 * 30); + window.setTimeout(() => { + claimAchievement('client60min'); + }, 1000 * 60 * 60); + const lastUsed = miLocalStorage.getItem('lastUsed'); if (lastUsed) { const lastUsedDate = parseInt(lastUsed, 10); @@ -456,7 +459,7 @@ if ($i) { const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); - if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3)))) { + if (neverShowDonationInfo !== 'true' && (new Date($i.createdAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 3))) && !location.pathname.startsWith('/miauth')) { if (latestDonationInfoShownAt == null || (new Date(latestDonationInfoShownAt).getTime() < (Date.now() - (1000 * 60 * 60 * 24 * 30)))) { popup(defineAsyncComponent(() => import('@/components/MkDonation.vue')), {}, {}, 'closed'); } diff --git a/packages/frontend/src/local-storage.ts b/packages/frontend/src/local-storage.ts index 9a288f264c..441a35747a 100644 --- a/packages/frontend/src/local-storage.ts +++ b/packages/frontend/src/local-storage.ts @@ -24,6 +24,7 @@ type Keys = 'customCss' | 'message_drafts' | 'scratchpad' | + 'debug' | `miux:${string}` | `ui:folder:${string}` | `themes:${string}` | @@ -32,7 +33,7 @@ type Keys = 'emojis' // DEPRECATED, stored in indexeddb (13.9.0~); export const miLocalStorage = { - getItem: (key: Keys) => window.localStorage.getItem(key), - setItem: (key: Keys, value: string) => window.localStorage.setItem(key, value), - removeItem: (key: Keys) => window.localStorage.removeItem(key), + getItem: (key: Keys): string | null => window.localStorage.getItem(key), + setItem: (key: Keys, value: string): void => window.localStorage.setItem(key, value), + removeItem: (key: Keys): void => window.localStorage.removeItem(key), }; diff --git a/packages/frontend/src/os.ts b/packages/frontend/src/os.ts index 962f9cdd98..c4f9d47d7d 100644 --- a/packages/frontend/src/os.ts +++ b/packages/frontend/src/os.ts @@ -18,6 +18,8 @@ import MkPopupMenu from '@/components/MkPopupMenu.vue'; import MkContextMenu from '@/components/MkContextMenu.vue'; import { MenuItem } from '@/types/menu'; import copyToClipboard from './scripts/copy-to-clipboard'; +import { showMovedDialog } from './scripts/show-moved-dialog'; +import { DriveFile } from 'misskey-js/built/entities'; export const openingWindowsCount = ref(0); @@ -55,6 +57,12 @@ export const apiWithDialog = (( } else if (err.code === 'RATE_LIMIT_EXCEEDED') { title = i18n.ts.cannotPerformTemporary; text = i18n.ts.cannotPerformTemporaryDescription; + } else if (err.code === 'INVALID_PARAM') { + title = i18n.ts.invalidParamError; + text = i18n.ts.invalidParamErrorDescription; + } else if (err.code === 'ROLE_PERMISSION_DENIED') { + title = i18n.ts.permissionDeniedError; + text = i18n.ts.permissionDeniedErrorDescription; } else if (err.code.startsWith('TOO_MANY')) { title = i18n.ts.youCannotCreateAnymore; text = `${i18n.ts.error}: ${err.id}`; @@ -413,7 +421,7 @@ export async function selectUser(opts: { includeSelf?: boolean } = {}) { }); } -export async function selectDriveFile(multiple: boolean) { +export async function selectDriveFile(multiple: boolean): Promise<DriveFile[]> { return new Promise((resolve, reject) => { popup(defineAsyncComponent(() => import('@/components/MkDriveSelectDialog.vue')), { type: 'file', @@ -421,7 +429,7 @@ export async function selectDriveFile(multiple: boolean) { }, { done: files => { if (files) { - resolve(multiple ? files : files[0]); + resolve(files); } }, }, 'closed'); @@ -572,6 +580,8 @@ export function contextMenu(items: MenuItem[] | Ref<MenuItem[]>, ev: MouseEvent) } export function post(props: Record<string, any> = {}): Promise<void> { + showMovedDialog(); + return new Promise((resolve, reject) => { // NOTE: MkPostFormDialogをdynamic importするとiOSでテキストエリアに自動フォーカスできない // NOTE: ただ、dynamic importしない場合、MkPostFormDialogインスタンスが使いまわされ、 diff --git a/packages/frontend/src/pages/about-misskey.vue b/packages/frontend/src/pages/about-misskey.vue index bca4d17784..e592c629ce 100644 --- a/packages/frontend/src/pages/about-misskey.vue +++ b/packages/frontend/src/pages/about-misskey.vue @@ -132,6 +132,18 @@ const patronsWithIcon = [{ }, { name: 'mollinaca', icon: 'https://misskey-hub.net/patrons/ceb36b8f66e549bdadb3b90d5da62314.jpg', +}, { + name: '坂本龍', + icon: 'https://misskey-hub.net/patrons/a631cf8b490145cf8dbbe4e7508cfbc2.jpg', +}, { + name: 'takke', + icon: 'https://misskey-hub.net/patrons/6c3327e626c046f2914fbcd9f7557935.jpg', +}, { + name: 'ぺんぎん', + icon: 'https://misskey-hub.net/patrons/6a652e0534ff4cb1836e7ce4968d76a7.jpg', +}, { + name: 'かみらえっと', + icon: 'https://misskey-hub.net/patrons/be1326bda7d940a482f3758ffd9ffaf6.jpg', }]; const patrons = [ @@ -219,6 +231,13 @@ const patrons = [ '巣黒るい@リスケモ男の娘VTuber!', 'ふぇいぽむ', '依古田イコ', + '戸塚こだま', + 'すー。', + '秋雨/Slime-hatena.jp', + 'けそ', + 'ずも', + 'binvinyl', + '渡志郎', ]; let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure')); diff --git a/packages/frontend/src/pages/about.emojis.vue b/packages/frontend/src/pages/about.emojis.vue index d461430234..2d82fcf277 100644 --- a/packages/frontend/src/pages/about.emojis.vue +++ b/packages/frontend/src/pages/about.emojis.vue @@ -53,7 +53,15 @@ function search() { } if (selectedTags.size === 0) { - searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q)); + const queryarry = q.match(/\:([a-z0-9_]*)\:/g); + + if (queryarry) { + searchEmojis = customEmojis.value.filter(emoji => + queryarry.includes(`:${emoji.name}:`) + ); + } else { + searchEmojis = customEmojis.value.filter(emoji => emoji.name.includes(q) || emoji.aliases.includes(q)); + } } else { searchEmojis = customEmojis.value.filter(emoji => (emoji.name.includes(q) || emoji.aliases.includes(q)) && [...selectedTags].every(t => emoji.aliases.includes(t))); } diff --git a/packages/frontend/src/pages/about.vue b/packages/frontend/src/pages/about.vue index d54d93eaee..8e29990426 100644 --- a/packages/frontend/src/pages/about.vue +++ b/packages/frontend/src/pages/about.vue @@ -3,10 +3,10 @@ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> <div class="_gaps_m"> - <div class="fwhjspax" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> - <div class="content"> - <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" class="icon"/> - <div class="name"> + <div :class="$style.banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> + <div style="overflow: clip;"> + <img :src="instance.iconUrl ?? instance.faviconUrl ?? '/favicon.ico'" alt="" :class="$style.bannerIcon"/> + <div :class="$style.bannerName"> <b>{{ instance.name ?? host }}</b> </div> </div> @@ -41,7 +41,14 @@ <template #value>{{ instance.maintainerEmail }}</template> </MkKeyValue> </FormSplit> - <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.tos }}</FormLink> + <MkFolder v-if="instance.serverRules.length > 0"> + <template #label>{{ i18n.ts.serverRules }}</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> + </MkFolder> + <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink> </div> </FormSection> @@ -94,6 +101,7 @@ import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; +import MkFolder from '@/components/MkFolder.vue'; import MkKeyValue from '@/components/MkKeyValue.vue'; import MkInstanceStats from '@/components/MkInstanceStats.vue'; import * as os from '@/os'; @@ -148,31 +156,63 @@ definePageMetadata(computed(() => ({ }))); </script> -<style lang="scss" scoped> -.fwhjspax { +<style lang="scss" module> +.banner { text-align: center; border-radius: 10px; overflow: clip; background-size: cover; background-position: center center; +} - > .content { - overflow: hidden; +.bannerIcon { + display: block; + margin: 16px auto 0 auto; + height: 64px; + border-radius: 8px; +} - > .icon { - display: block; - margin: 16px auto 0 auto; - height: 64px; - border-radius: 8px; - } +.bannerName { + display: block; + padding: 16px; + color: #fff; + text-shadow: 0 0 8px #000; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); +} - > .name { - display: block; - padding: 16px; - color: #fff; - text-shadow: 0 0 8px #000; - background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); - } +.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/pages/admin/email-settings.vue b/packages/frontend/src/pages/admin/email-settings.vue index b742132af6..d51bf6230a 100644 --- a/packages/frontend/src/pages/admin/email-settings.vue +++ b/packages/frontend/src/pages/admin/email-settings.vue @@ -96,7 +96,9 @@ async function testEmail() { const { canceled, result: destination } = await os.inputText({ title: i18n.ts.destination, type: 'email', - placeholder: instance.maintainerEmail, + default: instance.maintainerEmail ?? '', + placeholder: 'test@example.com', + minLength: 1, }); if (canceled) return; os.apiWithDialog('admin/send-email', { diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue index ebe1a8ade0..ffd3b6e233 100644 --- a/packages/frontend/src/pages/admin/moderation.vue +++ b/packages/frontend/src/pages/admin/moderation.vue @@ -5,14 +5,30 @@ <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> <FormSuspense :p="init"> <div class="_gaps_m"> - <FormSection first> - <div class="_gaps_m"> - <MkTextarea v-model="sensitiveWords"> - <template #label>{{ i18n.ts.sensitiveWords }}</template> - <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> - </MkTextarea> - </div> - </FormSection> + <MkSwitch v-model="enableRegistration"> + <template #label>{{ i18n.ts.enableRegistration }}</template> + </MkSwitch> + + <MkSwitch v-model="emailRequiredForSignup"> + <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + </MkSwitch> + + <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> + + <MkInput v-model="tosUrl"> + <template #prefix><i class="ti ti-link"></i></template> + <template #label>{{ i18n.ts.tosUrl }}</template> + </MkInput> + + <MkTextarea v-model="preservedUsernames"> + <template #label>{{ i18n.ts.preservedUsernames }}</template> + <template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template> + </MkTextarea> + + <MkTextarea v-model="sensitiveWords"> + <template #label>{{ i18n.ts.sensitiveWords }}</template> + <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> + </MkTextarea> </div> </FormSuspense> </MkSpacer> @@ -41,17 +57,30 @@ import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import MkButton from '@/components/MkButton.vue'; +import FormLink from '@/components/form/link.vue'; +let enableRegistration: boolean = $ref(false); +let emailRequiredForSignup: boolean = $ref(false); let sensitiveWords: string = $ref(''); +let preservedUsernames: string = $ref(''); +let tosUrl: string | null = $ref(null); async function init() { const meta = await os.api('admin/meta'); + enableRegistration = !meta.disableRegistration; + emailRequiredForSignup = meta.emailRequiredForSignup; sensitiveWords = meta.sensitiveWords.join('\n'); + preservedUsernames = meta.preservedUsernames.join('\n'); + tosUrl = meta.tosUrl; } function save() { os.apiWithDialog('admin/update-meta', { + disableRegistration: !enableRegistration, + emailRequiredForSignup, + tosUrl, sensitiveWords: sensitiveWords.split('\n'), + preservedUsernames: preservedUsernames.split('\n'), }).then(() => { fetchInstance(); }); diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue index b1aa03f1f7..c211ef2f05 100644 --- a/packages/frontend/src/pages/admin/roles.edit.vue +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -54,6 +54,7 @@ if (props.id) { target: 'manual', condFormula: { id: uuid(), type: 'isRemote' }, isPublic: false, + isExplorable: false, asBadge: false, canEditMembersByModerator: false, displayOrder: 0, diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 873ff02feb..49942c87ce 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -8,10 +8,9 @@ <template #label>{{ i18n.ts._role.description }}</template> </MkTextarea> - <MkInput v-model="role.color"> + <MkColorInput v-model="role.color"> <template #label>{{ i18n.ts.color }}</template> - <template #caption>#RRGGBB</template> - </MkInput> + </MkColorInput> <MkInput v-model="role.iconUrl"> <template #label>{{ i18n.ts._role.iconUrl }}</template> @@ -59,6 +58,11 @@ <template #caption>{{ i18n.ts._role.descriptionOfAsBadge }}</template> </MkSwitch> + <MkSwitch v-model="role.isExplorable" :readonly="readonly"> + <template #label>{{ i18n.ts._role.isExplorable }}</template> + <template #caption>{{ i18n.ts._role.descriptionOfIsExplorable }}</template> + </MkSwitch> + <FormSlot> <template #label><i class="ti ti-license"></i> {{ i18n.ts._role.policies }}</template> <div class="_gaps_s"> @@ -206,7 +210,7 @@ </MkRange> </div> </MkFolder> - + <MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])"> <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> <template #suffix> @@ -227,6 +231,26 @@ </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])"> + <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template> + <template #suffix> + <span v-if="role.policies.alwaysMarkNsfw.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.alwaysMarkNsfw.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.alwaysMarkNsfw)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.alwaysMarkNsfw.value" :disabled="role.policies.alwaysMarkNsfw.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.alwaysMarkNsfw.priority" :min="0" :max="2" :step="1" easing :text-converter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''"> + <template #label>{{ i18n.ts._role.priority }}</template> + </MkRange> + </div> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix> @@ -409,6 +433,7 @@ import { watch } from 'vue'; import { throttle } from 'throttle-debounce'; import RolesEditorFormula from './RolesEditorFormula.vue'; import MkInput from '@/components/MkInput.vue'; +import MkColorInput from '@/components/MkColorInput.vue'; import MkSelect from '@/components/MkSelect.vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkFolder from '@/components/MkFolder.vue'; @@ -475,6 +500,7 @@ const save = throttle(100, () => { isAdministrator: role.isAdministrator, isModerator: role.isModerator, isPublic: role.isPublic, + isExplorable: role.isExplorable, asBadge: role.asBadge, canEditMembersByModerator: role.canEditMembersByModerator, policies: role.policies, diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index a1e467edbd..e8dbe1c5f0 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -75,6 +75,14 @@ </MkInput> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.alwaysMarkNsfw, 'alwaysMarkNsfw'])"> + <template #label>{{ i18n.ts._role._options.alwaysMarkNsfw }}</template> + <template #suffix>{{ policies.alwaysMarkNsfw ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.alwaysMarkNsfw"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix>{{ policies.pinLimit }}</template> diff --git a/packages/frontend/src/pages/admin/server-rules.vue b/packages/frontend/src/pages/admin/server-rules.vue new file mode 100644 index 0000000000..85781c0bd0 --- /dev/null +++ b/packages/frontend/src/pages/admin/server-rules.vue @@ -0,0 +1,128 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <div class="_gaps_m"> + <div>{{ i18n.ts._serverRules.description }}</div> + <Sortable + v-model="serverRules" + class="_gaps_m" + :item-key="(_, i) => i" + :animation="150" + :handle="'.' + $style.itemHandle" + @start="e => e.item.classList.add('active')" + @end="e => e.item.classList.remove('active')" + > + <template #item="{element,index}"> + <div :class="$style.item"> + <div :class="$style.itemHeader"> + <div :class="$style.itemNumber" v-text="String(index + 1)"/> + <span :class="$style.itemHandle"><i class="ti ti-menu"/></span> + <button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button> + </div> + <MkInput v-model="serverRules[index]"/> + </div> + </template> + </Sortable> + <div :class="$style.commands"> + <MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton primary rounded :class="$style.buttonSave" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import XHeader from './_header_.vue'; +import * as os from '@/os'; +import { fetchInstance, instance } from '@/instance'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; + +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); + +let serverRules: string[] = $ref(instance.serverRules); + +const save = async () => { + await os.apiWithDialog('admin/update-meta', { + serverRules, + }); + fetchInstance(); +}; + +const remove = (index: number): void => { + serverRules.splice(index, 1); +}; + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.serverRules, + icon: 'ti ti-checkbox', +}); +</script> + +<style lang="scss" module> +.item { + display: block; + color: var(--navFg); +} + +.itemHeader { + display: flex; + margin-bottom: 8px; + align-items: center; +} + +.itemHandle { + display: flex; + width: 40px; + height: 40px; + align-items: center; + justify-content: center; + cursor: move; +} + +.itemNumber { + display: flex; + background-color: var(--accentedBg); + color: var(--accent); + font-size: 14px; + font-weight: bold; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + border-radius: 999px; + margin-right: 8px; +} + +.itemEdit { + width: 100%; + max-width: 100%; + min-width: 100%; +} + +.itemRemove { + width: 40px; + height: 40px; + color: var(--error); + margin-left: auto; + border-radius: 6px; + + &:hover { + background: var(--X5); + } +} + +.commands { + display: flex; + gap: 16px; +} +</style> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 65e64930d5..7ec3c381f3 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -13,11 +13,6 @@ <template #label>{{ i18n.ts.instanceDescription }}</template> </MkTextarea> - <MkInput v-model="tosUrl"> - <template #prefix><i class="ti ti-link"></i></template> - <template #label>{{ i18n.ts.tosUrl }}</template> - </MkInput> - <FormSplit :min-width="300"> <MkInput v-model="maintainerName"> <template #label>{{ i18n.ts.maintainerName }}</template> @@ -36,14 +31,6 @@ <FormSection> <div class="_gaps_s"> - <MkSwitch v-model="enableRegistration"> - <template #label>{{ i18n.ts.enableRegistration }}</template> - </MkSwitch> - - <MkSwitch v-model="emailRequiredForSignup"> - <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> - </MkSwitch> - <MkSwitch v-model="enableChartsForRemoteUser"> <template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template> </MkSwitch> @@ -73,11 +60,9 @@ <template #label>{{ i18n.ts.backgroundImageUrl }}</template> </MkInput> - <MkInput v-model="themeColor"> - <template #prefix><i class="ti ti-palette"></i></template> + <MkColorInput v-model="themeColor"> <template #label>{{ i18n.ts.themeColor }}</template> - <template #caption>#RRGGBB</template> - </MkInput> + </MkColorInput> <MkTextarea v-model="defaultLightTheme"> <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> @@ -166,10 +151,10 @@ import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; import MkButton from '@/components/MkButton.vue'; +import MkColorInput from '@/components/MkColorInput.vue'; let name: string | null = $ref(null); let description: string | null = $ref(null); -let tosUrl: string | null = $ref(null); let maintainerName: string | null = $ref(null); let maintainerEmail: string | null = $ref(null); let iconUrl: string | null = $ref(null); @@ -180,8 +165,6 @@ let defaultLightTheme: any = $ref(null); let defaultDarkTheme: any = $ref(null); let pinnedUsers: string = $ref(''); let cacheRemoteFiles: boolean = $ref(false); -let enableRegistration: boolean = $ref(false); -let emailRequiredForSignup: boolean = $ref(false); let enableServiceWorker: boolean = $ref(false); let enableChartsForRemoteUser: boolean = $ref(false); let enableChartsForFederatedInstances: boolean = $ref(false); @@ -194,7 +177,6 @@ async function init() { const meta = await os.api('admin/meta'); name = meta.name; description = meta.description; - tosUrl = meta.tosUrl; iconUrl = meta.iconUrl; bannerUrl = meta.bannerUrl; backgroundImageUrl = meta.backgroundImageUrl; @@ -205,8 +187,6 @@ async function init() { maintainerEmail = meta.maintainerEmail; pinnedUsers = meta.pinnedUsers.join('\n'); cacheRemoteFiles = meta.cacheRemoteFiles; - enableRegistration = !meta.disableRegistration; - emailRequiredForSignup = meta.emailRequiredForSignup; enableServiceWorker = meta.enableServiceWorker; enableChartsForRemoteUser = meta.enableChartsForRemoteUser; enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances; @@ -220,7 +200,6 @@ function save() { os.apiWithDialog('admin/update-meta', { name, description, - tosUrl, iconUrl, bannerUrl, backgroundImageUrl, @@ -231,8 +210,6 @@ function save() { maintainerEmail, pinnedUsers: pinnedUsers.split('\n'), cacheRemoteFiles, - disableRegistration: !enableRegistration, - emailRequiredForSignup, enableServiceWorker, enableChartsForRemoteUser, enableChartsForFederatedInstances, diff --git a/packages/frontend/src/pages/channel-editor.vue b/packages/frontend/src/pages/channel-editor.vue index 9cb440d2bb..a74ab40473 100644 --- a/packages/frontend/src/pages/channel-editor.vue +++ b/packages/frontend/src/pages/channel-editor.vue @@ -11,6 +11,10 @@ <template #label>{{ i18n.ts.description }}</template> </MkTextarea> + <MkColorInput v-model="color"> + <template #label>{{ i18n.ts.color }}</template> + </MkColorInput> + <div> <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="ti ti-plus"></i> {{ i18n.ts._channel.setBanner }}</MkButton> <div v-else-if="bannerUrl"> @@ -42,8 +46,9 @@ </div> </MkFolder> - <div> + <div class="_buttons"> <MkButton primary @click="save()"><i class="ti ti-device-floppy"></i> {{ channelId ? i18n.ts.save : i18n.ts.create }}</MkButton> + <MkButton v-if="channelId" danger @click="archive()"><i class="ti ti-trash"></i> {{ i18n.ts.archive }}</MkButton> </div> </div> </MkSpacer> @@ -55,6 +60,7 @@ import { computed, ref, watch, defineAsyncComponent } from 'vue'; import MkTextarea from '@/components/MkTextarea.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkColorInput from '@/components/MkColorInput.vue'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; import { useRouter } from '@/router'; @@ -75,6 +81,7 @@ let name = $ref(null); let description = $ref(null); let bannerUrl = $ref<string | null>(null); let bannerId = $ref<string | null>(null); +let color = $ref('#000'); const pinnedNotes = ref([]); watch(() => bannerId, async () => { @@ -101,6 +108,7 @@ async function fetchChannel() { pinnedNotes.value = channel.pinnedNoteIds.map(id => ({ id, })); + color = channel.color; } fetchChannel(); @@ -128,6 +136,7 @@ function save() { description: description, bannerId: bannerId, pinnedNoteIds: pinnedNotes.value.map(x => x.id), + color: color, }; if (props.channelId) { @@ -143,6 +152,23 @@ function save() { } } +async function archive() { + const { canceled } = await os.confirm({ + type: 'warning', + title: i18n.t('channelArchiveConfirmTitle', { name: name }), + text: i18n.ts.channelArchiveConfirmDescription, + }); + + if (canceled) return; + + os.api('channels/update', { + channelId: props.channelId, + isArchived: true, + }).then(() => { + os.success(); + }); +} + function setBannerImage(evt) { selectFile(evt.currentTarget ?? evt.target, null).then(file => { bannerId = file.id; diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index 437c1fae31..af1b4d2056 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -28,6 +28,8 @@ </MkFoldableSection> </div> <div v-if="channel && tab === 'timeline'" class="_gaps"> + <MkInfo v-if="channel.isArchived" warn>{{ i18n.ts.thisChannelArchived }}</MkInfo> + <!-- スマホ・タブレットの場合、キーボードが表示されると投稿が見づらくなるので、デスクトップ場合のみ自動でフォーカスを当てる --> <MkPostForm v-if="$i && defaultStore.reactiveState.showFixedPostFormInChannel.value" :channel="channel" class="post-form _panel" fixed :autofocus="deviceKind === 'desktop'"/> @@ -36,6 +38,17 @@ <div v-else-if="tab === 'featured'"> <MkNotes :pagination="featuredPagination"/> </div> + <div v-else-if="tab === 'search'"> + <div class="_gaps"> + <div> + <MkInput v-model="searchQuery"> + <template #prefix><i class="ti ti-search"></i></template> + </MkInput> + <MkButton primary rounded style="margin-top: 8px;" @click="search()">{{ i18n.ts.search }}</MkButton> + </div> + <MkNotes v-if="searchPagination" :key="searchQuery" :pagination="searchPagination"/> + </div> + </div> </MkSpacer> <template #footer> <div :class="$style.footer"> @@ -63,8 +76,10 @@ import { deviceKind } from '@/scripts/device-kind'; import MkNotes from '@/components/MkNotes.vue'; import { url } from '@/config'; import MkButton from '@/components/MkButton.vue'; +import MkInput from '@/components/MkInput.vue'; import { defaultStore } from '@/store'; import MkNote from '@/components/MkNote.vue'; +import MkInfo from '@/components/MkInfo.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; const router = useRouter(); @@ -76,6 +91,8 @@ const props = defineProps<{ let tab = $ref('timeline'); let channel = $ref(null); let favorited = $ref(false); +let searchQuery = $ref(''); +let searchPagination = $ref(); const featuredPagination = $computed(() => ({ endpoint: 'notes/featured' as const, limit: 10, @@ -123,6 +140,21 @@ async function unfavorite() { }); } +async function search() { + const query = searchQuery.toString().trim(); + + if (query == null) return; + + searchPagination = { + endpoint: 'notes/search', + limit: 10, + params: { + query: searchQuery, + channelId: channel.id, + }, + }; +} + const headerActions = $computed(() => { if (channel && channel.userId) { const share = { @@ -160,6 +192,10 @@ const headerTabs = $computed(() => [{ key: 'featured', title: i18n.ts.featured, icon: 'ti ti-bolt', +}, { + key: 'search', + title: i18n.ts.search, + icon: 'ti ti-search', }]); definePageMetadata(computed(() => channel ? { @@ -170,7 +206,7 @@ definePageMetadata(computed(() => channel ? { <style lang="scss" module> .main { - min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px))); + min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px))); } .footer { diff --git a/packages/frontend/src/pages/channels.vue b/packages/frontend/src/pages/channels.vue index 70e7705d1d..e670cdd864 100644 --- a/packages/frontend/src/pages/channels.vue +++ b/packages/frontend/src/pages/channels.vue @@ -96,7 +96,7 @@ const ownedPagination = { async function search() { const query = searchQuery.toString().trim(); - if (query == null || query === '') return; + if (query == null) return; const type = searchType.toString().trim(); diff --git a/packages/frontend/src/pages/custom-emojis-manager.vue b/packages/frontend/src/pages/custom-emojis-manager.vue index 59cb3262b7..3f13f0787d 100644 --- a/packages/frontend/src/pages/custom-emojis-manager.vue +++ b/packages/frontend/src/pages/custom-emojis-manager.vue @@ -15,9 +15,10 @@ <div v-if="selectMode" class="_buttons"> <MkButton inline @click="selectAll">Select all</MkButton> <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> <MkButton inline @click="addTagBulk">Add tag</MkButton> <MkButton inline @click="removeTagBulk">Remove tag</MkButton> - <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline @click="setLisenceBulk">Set Lisence</MkButton> <MkButton inline danger @click="delBulk">Delete</MkButton> </div> <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> @@ -221,6 +222,18 @@ const setCategoryBulk = async () => { emojisPaginationComponent.value.reload(); }; +const setLisenceBulk = async () => { + const { canceled, result } = await os.inputText({ + title: 'License', + }); + if (canceled) return; + await os.apiWithDialog('admin/emoji/set-license-bulk', { + ids: selectedEmojis.value, + license: result, + }); + emojisPaginationComponent.value.reload(); +}; + const addTagBulk = async () => { const { canceled, result } = await os.inputText({ title: 'Tag', diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 35edcc7cda..816825e5b6 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue'; import MkInput from '@/components/MkInput.vue'; import { useRouter } from '@/router'; -const PRESET_DEFAULT = `/// @ 0.13.1 +const PRESET_DEFAULT = `/// @ 0.13.2 var name = "" @@ -51,7 +51,7 @@ Ui:render([ ]) `; -const PRESET_OMIKUJI = `/// @ 0.13.1 +const PRESET_OMIKUJI = `/// @ 0.13.2 // ユーザーごとに日替わりのおみくじのプリセット // 選択肢 @@ -94,7 +94,7 @@ Ui:render([ ]) `; -const PRESET_SHUFFLE = `/// @ 0.13.1 +const PRESET_SHUFFLE = `/// @ 0.13.2 // 巻き戻し可能な文字シャッフルのプリセット let string = "ペペロンチーノ" @@ -173,7 +173,7 @@ var cursor = 0 do() `; -const PRESET_QUIZ = `/// @ 0.13.1 +const PRESET_QUIZ = `/// @ 0.13.2 let title = '地理クイズ' let qas = [{ @@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({ Ui:render(qaEls) `; -const PRESET_TIMELINE = `/// @ 0.13.1 +const PRESET_TIMELINE = `/// @ 0.13.2 // APIリクエストを行いローカルタイムラインを表示するプリセット @fetch() { @@ -305,6 +305,11 @@ const PRESET_TIMELINE = `/// @ 0.13.1 // それぞれのノートごとにUI要素作成 let noteEls = [] each (let note, notes) { + // 表示名を設定していないアカウントはidを表示 + let userName = if Core:type(note.user.name) == "str" note.user.name else note.user.username + // リノートもしくはメディア・投票のみで本文が無いノートに代替表示文を設定 + let noteText = if Core:type(note.text) == "str" note.text else "(リノートもしくはメディア・投票のみのノート)" + let el = Ui:C:container({ bgColor: "#444" fgColor: "#fff" @@ -312,11 +317,11 @@ const PRESET_TIMELINE = `/// @ 0.13.1 rounded: true children: [ Ui:C:mfm({ - text: note.user.name + text: userName bold: true }) Ui:C:mfm({ - text: note.text + text: noteText }) ] }) diff --git a/packages/frontend/src/pages/gallery/edit.vue b/packages/frontend/src/pages/gallery/edit.vue index 1fae7686e5..cafcee0c33 100644 --- a/packages/frontend/src/pages/gallery/edit.vue +++ b/packages/frontend/src/pages/gallery/edit.vue @@ -2,7 +2,7 @@ <MkStickyContainer> <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> + <FormSuspense :p="init" class="_gaps"> <MkInput v-model="title"> <template #label>{{ i18n.ts.title }}</template> </MkInput> @@ -11,7 +11,7 @@ <template #label>{{ i18n.ts.description }}</template> </MkTextarea> - <div class=""> + <div class="_gaps_s"> <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> <div class="name">{{ file.name }}</div> <button v-tooltip="i18n.ts.remove" class="remove _button" @click="remove(file)"><i class="ti ti-x"></i></button> @@ -21,10 +21,12 @@ <MkSwitch v-model="isSensitive">{{ i18n.ts.markAsSensitive }}</MkSwitch> - <MkButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - <MkButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</MkButton> + <div class="_buttons"> + <MkButton v-if="postId" primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-else primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.publish }}</MkButton> - <MkButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkButton v-if="postId" danger @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> </FormSuspense> </MkSpacer> </MkStickyContainer> diff --git a/packages/frontend/src/pages/my-lists/list.vue b/packages/frontend/src/pages/my-lists/list.vue index 768a48746c..86201e8e0c 100644 --- a/packages/frontend/src/pages/my-lists/list.vue +++ b/packages/frontend/src/pages/my-lists/list.vue @@ -131,7 +131,7 @@ definePageMetadata(computed(() => list ? { <style lang="scss" module> .main { - min-height: calc(var(--containerHeight) - (var(--stickyTop, 0px) + var(--stickyBottom, 0px))); + min-height: calc(100cqh - (var(--stickyTop, 0px) + var(--stickyBottom, 0px))); } .userItem { diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue index ffeb8ba285..e97a4b07f1 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.image.vue @@ -33,8 +33,8 @@ const emit = defineEmits<{ let file: any = $ref(null); async function choose() { - os.selectDriveFile(false).then((fileResponse: any) => { - file = fileResponse; + os.selectDriveFile(false).then((fileResponse) => { + file = fileResponse[0]; emit('update:modelValue', { ...props.modelValue, fileId: fileResponse.id, diff --git a/packages/frontend/src/pages/role.vue b/packages/frontend/src/pages/role.vue index f2645394a2..fe39c594ba 100644 --- a/packages/frontend/src/pages/role.vue +++ b/packages/frontend/src/pages/role.vue @@ -1,8 +1,16 @@ <template> <MkStickyContainer> <template #header><MkPageHeader v-model:tab="tab" :tabs="headerTabs"/></template> - - <MkSpacer v-if="tab === 'users'" :content-max="1200"> + <MKSpacer v-if="!(typeof error === 'undefined')" :content-max="1200"> + <div :class="$style.root"> + <img :class="$style.img" src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/> + <p :class="$style.text"> + <i class="ti ti-alert-triangle"></i> + {{ error }} + </p> + </div> + </MKSpacer> + <MkSpacer v-else-if="tab === 'users'" :content-max="1200"> <div class="_gaps_s"> <div v-if="role">{{ role.description }}</div> <MkUserList :pagination="users" :extractor="(item) => item.user"/> @@ -13,7 +21,6 @@ </MkSpacer> </MkStickyContainer> </template> - <script lang="ts" setup> import { computed, watch } from 'vue'; import * as os from '@/os'; @@ -21,6 +28,7 @@ import MkUserList from '@/components/MkUserList.vue'; import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import MkTimeline from '@/components/MkTimeline.vue'; +import { instanceName } from '@/config'; const props = withDefaults(defineProps<{ role: string; @@ -31,12 +39,21 @@ const props = withDefaults(defineProps<{ let tab = $ref(props.initialTab); let role = $ref(); +let error = $ref(); watch(() => props.role, () => { os.api('roles/show', { roleId: props.role, }).then(res => { role = res; + document.title = `${role?.name} | ${instanceName}`; + }).catch((err) => { + if (err.code === 'NO_SUCH_ROLE') { + error = i18n.ts.noRole; + } else { + error = i18n.ts.somethingHappened; + } + document.title = `${error} | ${instanceName}`; }); }, { immediate: true }); @@ -63,4 +80,23 @@ definePageMetadata(computed(() => ({ icon: 'ti ti-badge', }))); </script> +<style lang="scss" module> +.root { + padding: 32px; + text-align: center; + align-items: center; +} + +.text { + margin: 0 0 8px 0; +} + +.img { + vertical-align: bottom; + width: 128px; + height: 128px; + margin-bottom: 16px; + border-radius: 16px; +} +</style> diff --git a/packages/frontend/src/pages/settings/account-info.vue b/packages/frontend/src/pages/settings/account-stats.vue index 584808b0b4..a0f1541b40 100644 --- a/packages/frontend/src/pages/settings/account-info.vue +++ b/packages/frontend/src/pages/settings/account-stats.vue @@ -1,18 +1,6 @@ <template> <div class="_gaps_m"> - <MkKeyValue> - <template #key>ID</template> - <template #value><span class="_monospace">{{ $i.id }}</span></template> - </MkKeyValue> - - <FormSection> - <MkKeyValue> - <template #key>{{ i18n.ts.registeredDate }}</template> - <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> - </MkKeyValue> - </FormSection> - - <FormSection v-if="stats"> + <FormSection v-if="stats" first> <template #label>{{ i18n.ts.statistics }}</template> <MkKeyValue oneline style="margin: 1em 0;"> <template #key>{{ i18n.ts.notesCount }}</template> diff --git a/packages/frontend/src/pages/settings/delete-account.vue b/packages/frontend/src/pages/settings/delete-account.vue deleted file mode 100644 index c6e79165c5..0000000000 --- a/packages/frontend/src/pages/settings/delete-account.vue +++ /dev/null @@ -1,52 +0,0 @@ -<template> -<div class="_gaps_m"> - <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> - <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> - <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</MkButton> - <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> -</div> -</template> - -<script lang="ts" setup> -import FormInfo from '@/components/MkInfo.vue'; -import MkButton from '@/components/MkButton.vue'; -import * as os from '@/os'; -import { signout, $i } from '@/account'; -import { i18n } from '@/i18n'; -import { definePageMetadata } from '@/scripts/page-metadata'; - -async function deleteAccount() { - { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.ts.deleteAccountConfirm, - }); - if (canceled) return; - } - - const { canceled, result: password } = await os.inputText({ - title: i18n.ts.password, - type: 'password', - }); - if (canceled) return; - - await os.apiWithDialog('i/delete-account', { - password: password, - }); - - await os.alert({ - title: i18n.ts._accountDelete.started, - }); - - await signout(); -} - -const headerActions = $computed(() => []); - -const headerTabs = $computed(() => []); - -definePageMetadata({ - title: i18n.ts._accountDelete.accountDelete, - icon: 'ti ti-alert-triangle', -}); -</script> diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue index d3fb422e01..73c2b2e604 100644 --- a/packages/frontend/src/pages/settings/drive.vue +++ b/packages/frontend/src/pages/settings/drive.vue @@ -119,6 +119,13 @@ function saveProfile() { os.api('i/update', { alwaysMarkNsfw: !!alwaysMarkNsfw, autoSensitive: !!autoSensitive, + }).catch(err => { + os.alert({ + type: 'error', + title: i18n.ts.error, + text: err.message, + }); + alwaysMarkNsfw = true; }); } diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 904fd3f952..ba0f3274fc 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -20,24 +20,71 @@ <option value="desktop"><i class="ti ti-device-desktop"/> {{ i18n.ts.desktop }}</option> </MkRadios> - <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> - <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> + <FormSection> + <div class="_gaps_s"> + <MkSwitch v-model="showFixedPostForm">{{ i18n.ts.showFixedPostForm }}</MkSwitch> + <MkSwitch v-model="showFixedPostFormInChannel">{{ i18n.ts.showFixedPostFormInChannel }}</MkSwitch> + </div> + </FormSection> <FormSection> - <template #label>{{ i18n.ts.behavior }}</template> + <template #label>{{ i18n.ts.displayOfNote }}</template> <div class="_gaps_m"> <div class="_gaps_s"> - <MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> - <MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> + <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> + <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> + <MkSwitch v-model="largeNoteReactions">{{ i18n.ts.largeNoteReactions }}</MkSwitch> + <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch> + <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> + <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> + <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch> + <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch> <MkSwitch v-model="useReactionPickerForContextMenu">{{ i18n.ts.useReactionPickerForContextMenu }}</MkSwitch> </div> - <MkSelect v-model="serverDisconnectedBehavior"> - <template #label>{{ i18n.ts.whenServerDisconnected }}</template> - <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> - <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> - <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + + <MkSelect v-model="instanceTicker"> + <template #label>{{ i18n.ts.instanceTicker }}</template> + <option value="none">{{ i18n.ts._instanceTicker.none }}</option> + <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> + <option value="always">{{ i18n.ts._instanceTicker.always }}</option> </MkSelect> + + <MkSelect v-model="nsfw"> + <template #label>{{ i18n.ts.nsfw }}</template> + <option value="respect">{{ i18n.ts._nsfw.respect }}</option> + <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> + <option value="force">{{ i18n.ts._nsfw.force }}</option> + </MkSelect> + <!-- + <MkRadios v-model="mediaListWithOneImageAppearance"> + <template #label>{{ i18n.ts.mediaListWithOneImageAppearance }}</template> + <option value="expand">{{ i18n.ts.default }}</option> + <option value="16_9">{{ i18n.t('limitTo', { x: '16:9' }) }}</option> + <option value="1_1">{{ i18n.t('limitTo', { x: '1:1' }) }}</option> + <option value="2_3">{{ i18n.t('limitTo', { x: '2:3' }) }}</option> + </MkRadios> + --> + </div> + </FormSection> + + <FormSection> + <template #label>{{ i18n.ts.notificationDisplay }}</template> + + <div class="_gaps_m"> + <MkRadios v-model="notificationPosition"> + <template #label>{{ i18n.ts.position }}</template> + <option value="leftTop"><i class="ti ti-align-box-left-top"></i> {{ i18n.ts.leftTop }}</option> + <option value="rightTop"><i class="ti ti-align-box-right-top"></i> {{ i18n.ts.rightTop }}</option> + <option value="leftBottom"><i class="ti ti-align-box-left-bottom"></i> {{ i18n.ts.leftBottom }}</option> + <option value="rightBottom"><i class="ti ti-align-box-right-bottom"></i> {{ i18n.ts.rightBottom }}</option> + </MkRadios> + + <MkRadios v-model="notificationStackAxis"> + <template #label>{{ i18n.ts.stackAxis }}</template> + <option value="vertical"><i class="ti ti-carousel-vertical"></i> {{ i18n.ts.vertical }}</option> + <option value="horizontal"><i class="ti ti-carousel-horizontal"></i> {{ i18n.ts.horizontal }}</option> + </MkRadios> </div> </FormSection> @@ -46,22 +93,15 @@ <div class="_gaps_m"> <div class="_gaps_s"> - <MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch> - <MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch> - <MkSwitch v-model="largeNoteReactions">{{ i18n.ts.largeNoteReactions }}</MkSwitch> - <MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch> - <MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch> - <MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch> <MkSwitch v-model="reduceAnimation">{{ i18n.ts.reduceUiAnimation }}</MkSwitch> <MkSwitch v-model="useBlurEffect">{{ i18n.ts.useBlurEffect }}</MkSwitch> <MkSwitch v-model="useBlurEffectForModal">{{ i18n.ts.useBlurEffectForModal }}</MkSwitch> - <MkSwitch v-model="showGapBetweenNotesInTimeline">{{ i18n.ts.showGapBetweenNotesInTimeline }}</MkSwitch> - <MkSwitch v-model="loadRawImages">{{ i18n.ts.loadRawImages }}</MkSwitch> <MkSwitch v-model="disableShowingAnimatedImages">{{ i18n.ts.disableShowingAnimatedImages }}</MkSwitch> <MkSwitch v-model="squareAvatars">{{ i18n.ts.squareAvatars }}</MkSwitch> <MkSwitch v-model="useSystemFont">{{ i18n.ts.useSystemFont }}</MkSwitch> <MkSwitch v-model="disableDrawer">{{ i18n.ts.disableDrawer }}</MkSwitch> <MkSwitch v-model="forceShowAds">{{ i18n.ts.forceShowAds }}</MkSwitch> + <MkSwitch v-model="enableDataSaverMode">{{ i18n.ts.dataSaver }}</MkSwitch> </div> <div> <MkRadios v-model="emojiStyle"> @@ -84,27 +124,29 @@ </FormSection> <FormSection> - <MkSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</MkSwitch> - </FormSection> - - <MkSelect v-model="instanceTicker"> - <template #label>{{ i18n.ts.instanceTicker }}</template> - <option value="none">{{ i18n.ts._instanceTicker.none }}</option> - <option value="remote">{{ i18n.ts._instanceTicker.remote }}</option> - <option value="always">{{ i18n.ts._instanceTicker.always }}</option> - </MkSelect> + <template #label>{{ i18n.ts.behavior }}</template> - <MkSelect v-model="nsfw"> - <template #label>{{ i18n.ts.nsfw }}</template> - <option value="respect">{{ i18n.ts._nsfw.respect }}</option> - <option value="ignore">{{ i18n.ts._nsfw.ignore }}</option> - <option value="force">{{ i18n.ts._nsfw.force }}</option> - </MkSelect> + <div class="_gaps_m"> + <div class="_gaps_s"> + <MkSwitch v-model="imageNewTab">{{ i18n.ts.openImageInNewTab }}</MkSwitch> + <MkSwitch v-model="enableInfiniteScroll">{{ i18n.ts.enableInfiniteScroll }}</MkSwitch> + </div> + <MkSelect v-model="serverDisconnectedBehavior"> + <template #label>{{ i18n.ts.whenServerDisconnected }}</template> + <option value="reload">{{ i18n.ts._serverDisconnectedBehavior.reload }}</option> + <option value="dialog">{{ i18n.ts._serverDisconnectedBehavior.dialog }}</option> + <option value="quiet">{{ i18n.ts._serverDisconnectedBehavior.quiet }}</option> + </MkSelect> + <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> + <template #label>{{ i18n.ts.numberOfPageCache }}</template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </MkRange> + </div> + </FormSection> - <MkRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" easing> - <template #label>{{ i18n.ts.numberOfPageCache }}</template> - <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> - </MkRange> + <FormSection> + <MkSwitch v-model="aiChanMode">{{ i18n.ts.aiChanMode }}</MkSwitch> + </FormSection> <FormLink to="/settings/deck">{{ i18n.ts.deck }}</FormLink> @@ -160,6 +202,7 @@ const disableDrawer = computed(defaultStore.makeGetterSetter('disableDrawer')); const disableShowingAnimatedImages = computed(defaultStore.makeGetterSetter('disableShowingAnimatedImages')); const forceShowAds = computed(defaultStore.makeGetterSetter('forceShowAds')); const loadRawImages = computed(defaultStore.makeGetterSetter('loadRawImages')); +const enableDataSaverMode = computed(defaultStore.makeGetterSetter('enableDataSaverMode')); const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); @@ -170,6 +213,9 @@ const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfin const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); const squareAvatars = computed(defaultStore.makeGetterSetter('squareAvatars')); const aiChanMode = computed(defaultStore.makeGetterSetter('aiChanMode')); +const mediaListWithOneImageAppearance = computed(defaultStore.makeGetterSetter('mediaListWithOneImageAppearance')); +const notificationPosition = computed(defaultStore.makeGetterSetter('notificationPosition')); +const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificationStackAxis')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); diff --git a/packages/frontend/src/pages/settings/import-export.vue b/packages/frontend/src/pages/settings/import-export.vue index a8274f5601..89b4104020 100644 --- a/packages/frontend/src/pages/settings/import-export.vue +++ b/packages/frontend/src/pages/settings/import-export.vue @@ -32,7 +32,7 @@ <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> </div> </MkFolder> - <MkFolder> + <MkFolder v-if="$i && !$i.movedTo"> <template #label>{{ i18n.ts.import }}</template> <template #icon><i class="ti ti-upload"></i></template> <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> @@ -47,7 +47,7 @@ <template #icon><i class="ti ti-download"></i></template> <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> </MkFolder> - <MkFolder> + <MkFolder v-if="$i && !$i.movedTo"> <template #label>{{ i18n.ts.import }}</template> <template #icon><i class="ti ti-upload"></i></template> <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> @@ -62,7 +62,7 @@ <template #icon><i class="ti ti-download"></i></template> <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> </MkFolder> - <MkFolder> + <MkFolder v-if="$i && !$i.movedTo"> <template #label>{{ i18n.ts.import }}</template> <template #icon><i class="ti ti-upload"></i></template> <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> @@ -77,13 +77,28 @@ <template #icon><i class="ti ti-download"></i></template> <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> </MkFolder> - <MkFolder> + <MkFolder v-if="$i && !$i.movedTo"> <template #label>{{ i18n.ts.import }}</template> <template #icon><i class="ti ti-upload"></i></template> <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> </MkFolder> </div> </FormSection> + <FormSection> + <template #label><i class="ti ti-antenna"></i> {{ i18n.ts.antennas }}</template> + <div class="_gaps_s"> + <MkFolder> + <template #label>{{ i18n.ts.export }}</template> + <template #icon><i class="ti ti-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportAntennas()"><i class="ti ti-download"></i> {{ i18n.ts.export }}</MkButton> + </MkFolder> + <MkFolder v-if="$i && !$i.movedTo"> + <template #label>{{ i18n.ts.import }}</template> + <template #icon><i class="ti ti-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importAntennas($event)"><i class="ti ti-upload"></i> {{ i18n.ts.import }}</MkButton> + </MkFolder> + </div> + </FormSection> </div> </template> @@ -97,6 +112,7 @@ import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; const excludeMutingUsers = ref(false); const excludeInactiveUsers = ref(false); @@ -150,6 +166,10 @@ const exportMuting = () => { os.api('i/export-mute', {}).then(onExportSuccess).catch(onError); }; +const exportAntennas = () => { + os.api('i/export-antennas', {}).then(onExportSuccess).catch(onError); +}; + const importFollowing = async (ev) => { const file = await selectFile(ev.currentTarget ?? ev.target); os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError); @@ -170,6 +190,11 @@ const importBlocking = async (ev) => { os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); }; +const importAntennas = async (ev) => { + const file = await selectFile(ev.currentTarget ?? ev.target); + os.api('i/import-antennas', { fileId: file.id }).then(onImportSuccess).catch(onError); +}; + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 17af7417fd..34a962ef4c 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -164,12 +164,12 @@ const menuDef = computed(() => [{ text: i18n.ts.importAndExport, to: '/settings/import-export', active: currentPage?.route.name === 'import-export', - }, /*{ + }, { icon: 'ti ti-plane', - text: i18n.ts.accountMigration, + text: `${i18n.ts.accountMigration} (${i18n.ts.experimental})`, to: '/settings/migration', active: currentPage?.route.name === 'migration', - },*/ { + }, { icon: 'ti ti-dots', text: i18n.ts.other, to: '/settings/other', diff --git a/packages/frontend/src/pages/settings/migration.vue b/packages/frontend/src/pages/settings/migration.vue index 2ef8af7481..541992875e 100644 --- a/packages/frontend/src/pages/settings/migration.vue +++ b/packages/frontend/src/pages/settings/migration.vue @@ -1,63 +1,121 @@ <template> <div class="_gaps_m"> - <FormSection first> + <FormInfo warn> + {{ i18n.ts.thisIsExperimentalFeature }} + </FormInfo> + <MkFolder :default-open="true"> + <template #icon><i class="ti ti-plane-arrival"></i></template> + <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template> + <template #caption>{{ i18n.ts._accountMigration.moveFromSub }}</template> + + <div class="_gaps_m"> + <FormInfo> + {{ i18n.ts._accountMigration.moveFromDescription }} + </FormInfo> + <div> + <MkButton :disabled="accountAliases.length >= 10" inline style="margin-right: 8px;" @click="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> + </div> + <div class="_gaps"> + <MkInput v-for="(_, i) in accountAliases" v-model="accountAliases[i]"> + <template #prefix><i class="ti ti-plane-arrival"></i></template> + <template #label>{{ i18n.t('_accountMigration.moveFromLabel', { n: i + 1 }) }}</template> + </MkInput> + </div> + </div> + </MkFolder> + + <MkFolder :default-open="!!$i?.movedTo"> + <template #icon><i class="ti ti-plane-departure"></i></template> <template #label>{{ i18n.ts._accountMigration.moveTo }}</template> - <MkInput v-model="moveToAccount" manual-save> - <template #prefix><i class="ti ti-plane-departure"></i></template> - <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template> - </MkInput> - </FormSection> - <FormInfo warn>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo> - <FormSection> - <template #label>{{ i18n.ts._accountMigration.moveFrom }}</template> - <MkInput v-model="accountAlias" manual-save> - <template #prefix><i class="ti ti-plane-arrival"></i></template> - <template #label>{{ i18n.ts._accountMigration.moveFromLabel }}</template> - </MkInput> - </FormSection> - <FormInfo warn>{{ i18n.ts._accountMigration.moveFromDescription }}</FormInfo> + <div class="_gaps_m"> + <FormInfo>{{ i18n.ts._accountMigration.moveAccountDescription }}</FormInfo> + + <template v-if="$i && !$i.movedTo"> + <FormInfo>{{ i18n.ts._accountMigration.moveAccountHowTo }}</FormInfo> + <FormInfo warn>{{ i18n.ts._accountMigration.moveCannotBeUndone }}</FormInfo> + + <MkInput v-model="moveToAccount"> + <template #prefix><i class="ti ti-plane-departure"></i></template> + <template #label>{{ i18n.ts._accountMigration.moveToLabel }}</template> + </MkInput> + <MkButton inline danger :disabled="!moveToAccount" @click="move"> + <i class="ti ti-check"></i> {{ i18n.ts._accountMigration.startMigration }} + </MkButton> + </template> + <template v-else-if="$i"> + <FormInfo>{{ i18n.ts._accountMigration.postMigrationNote }}</FormInfo> + <FormInfo warn>{{ i18n.ts._accountMigration.movedAndCannotBeUndone }}</FormInfo> + <div>{{ i18n.ts._accountMigration.movedTo }}</div> + <MkUserInfo v-if="movedTo" :user="movedTo" class="_panel _shadow" /> + </template> + </div> + </MkFolder> </div> </template> <script lang="ts" setup> -import { ref, watch } from 'vue'; -import FormSection from '@/components/form/section.vue'; +import { ref } from 'vue'; import FormInfo from '@/components/MkInfo.vue'; import MkInput from '@/components/MkInput.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkUserInfo from '@/components/MkUserInfo.vue'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; +import { toString } from 'misskey-js/built/acct'; +import { UserDetailed } from 'misskey-js/built/entities'; +import { unisonReload } from '@/scripts/unison-reload'; const moveToAccount = ref(''); -const accountAlias = ref(''); +const movedTo = ref<UserDetailed>(); +const accountAliases = ref(['']); + +async function init() { + if ($i?.movedTo) { + movedTo.value = await os.api('users/show', { userId: $i.movedTo }); + } else { + moveToAccount.value = ''; + } + + if ($i?.alsoKnownAs && $i.alsoKnownAs.length > 0) { + const alsoKnownAs = await os.api('users/show', { userIds: $i.alsoKnownAs }); + accountAliases.value = (alsoKnownAs && alsoKnownAs.length > 0) ? alsoKnownAs.map(user => `@${toString(user)}`) : ['']; + } else { + accountAliases.value = ['']; + } +} async function move(): Promise<void> { const account = moveToAccount.value; const confirm = await os.confirm({ type: 'warning', - text: i18n.t('migrationConfirm', { account: account.toString() }), + text: i18n.t('_accountMigration.migrationConfirm', { account }), }); if (confirm.canceled) return; - os.apiWithDialog('i/move', { + await os.apiWithDialog('i/move', { moveToAccount: account, }); + unisonReload(); +} + +function add(): void { + accountAliases.value.push(''); } async function save(): Promise<void> { - const account = accountAlias.value; - os.apiWithDialog('i/known-as', { - alsoKnownAs: account, + const alsoKnownAs = accountAliases.value.map(alias => alias.trim()).filter(alias => alias !== ''); + const i = await os.apiWithDialog('i/update', { + alsoKnownAs, }); + $i.alsoKnownAs = i.alsoKnownAs; + init(); } -watch(accountAlias, async () => { - await save(); -}); - -watch(moveToAccount, async () => { - await move(); -}); +init(); definePageMetadata({ title: i18n.ts.accountMigration, diff --git a/packages/frontend/src/pages/settings/other.vue b/packages/frontend/src/pages/settings/other.vue index 892ea61e75..776305d723 100644 --- a/packages/frontend/src/pages/settings/other.vue +++ b/packages/frontend/src/pages/settings/other.vue @@ -1,32 +1,85 @@ <template> <div class="_gaps_m"> + <!-- <MkSwitch v-model="$i.injectFeaturedNote" @update:model-value="onChangeInjectFeaturedNote"> - {{ i18n.ts.showFeaturedNotesInTimeline }} + <template #label>{{ i18n.ts.showFeaturedNotesInTimeline }}</template> </MkSwitch> + --> <!-- <MkSwitch v-model="reportError">{{ i18n.ts.sendErrorReports }}<template #caption>{{ i18n.ts.sendErrorReportsDescription }}</template></MkSwitch> --> - <FormLink to="/settings/account-info">{{ i18n.ts.accountInfo }}</FormLink> + <FormSection first> + <div class="_gaps_s"> + <MkFolder> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.accountInfo }}</template> + + <div class="_gaps_m"> + <MkKeyValue> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $i.id }}</span></template> + </MkKeyValue> + + <MkKeyValue> + <template #key>{{ i18n.ts.registeredDate }}</template> + <template #value><MkTime :time="$i.createdAt" mode="detail"/></template> + </MkKeyValue> + + <FormLink to="/settings/account-stats"><template #icon><i class="ti ti-info-circle"></i></template>{{ i18n.ts.statistics }}</FormLink> + </div> + </MkFolder> + + <MkFolder> + <template #icon><i class="ti ti-alert-triangle"></i></template> + <template #label>{{ i18n.ts.closeAccount }}</template> - <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> + <div class="_gaps_m"> + <FormInfo warn>{{ i18n.ts._accountDelete.mayTakeTime }}</FormInfo> + <FormInfo>{{ i18n.ts._accountDelete.sendEmail }}</FormInfo> + <MkButton v-if="!$i.isDeleted" danger @click="deleteAccount">{{ i18n.ts._accountDelete.requestAccountDelete }}</MkButton> + <MkButton v-else disabled>{{ i18n.ts._accountDelete.inProgress }}</MkButton> + </div> + </MkFolder> - <FormLink to="/settings/delete-account"><template #icon><i class="ti ti-alert-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink> + <MkFolder> + <template #icon><i class="ti ti-flask"></i></template> + <template #label>{{ i18n.ts.experimentalFeatures }}</template> + + <div class="_gaps_m"> + <MkSwitch v-model="enableCondensedLineForAcct"> + <template #label>Enable condensed line for acct</template> + </MkSwitch> + </div> + </MkFolder> + </div> + </FormSection> + + <FormSection> + <FormLink to="/registry"><template #icon><i class="ti ti-adjustments"></i></template>{{ i18n.ts.registry }}</FormLink> + </FormSection> </div> </template> <script lang="ts" setup> -import { computed } from 'vue'; +import { computed, watch } from 'vue'; import MkSwitch from '@/components/MkSwitch.vue'; import FormLink from '@/components/form/link.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import FormInfo from '@/components/MkInfo.vue'; +import MkKeyValue from '@/components/MkKeyValue.vue'; +import MkButton from '@/components/MkButton.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import { $i } from '@/account'; +import { signout, $i } from '@/account'; import { i18n } from '@/i18n'; import { definePageMetadata } from '@/scripts/page-metadata'; +import { unisonReload } from '@/scripts/unison-reload'; +import FormSection from '@/components/form/section.vue'; const reportError = computed(defaultStore.makeGetterSetter('reportError')); +const enableCondensedLineForAcct = computed(defaultStore.makeGetterSetter('enableCondensedLineForAcct')); function onChangeInjectFeaturedNote(v) { os.api('i/update', { @@ -36,6 +89,48 @@ function onChangeInjectFeaturedNote(v) { }); } +async function deleteAccount() { + { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (canceled) return; + } + + const { canceled, result: password } = await os.inputText({ + title: i18n.ts.password, + type: 'password', + }); + if (canceled) return; + + await os.apiWithDialog('i/delete-account', { + password: password, + }); + + await os.alert({ + title: i18n.ts._accountDelete.started, + }); + + await signout(); +} + +async function reloadAsk() { + const { canceled } = await os.confirm({ + type: 'info', + text: i18n.ts.reloadToApplySetting, + }); + if (canceled) return; + + unisonReload(); +} + +watch([ + enableCondensedLineForAcct, +], async () => { + await reloadAsk(); +}); + const headerActions = $computed(() => []); const headerTabs = $computed(() => []); diff --git a/packages/frontend/src/pages/settings/preferences-backups.vue b/packages/frontend/src/pages/settings/preferences-backups.vue index 092b9a9cc8..6613ce4c1d 100644 --- a/packages/frontend/src/pages/settings/preferences-backups.vue +++ b/packages/frontend/src/pages/settings/preferences-backups.vue @@ -88,6 +88,7 @@ const defaultStoreSaveKeys: (keyof typeof defaultStore['state'])[] = [ 'squareAvatars', 'numberOfPageCache', 'aiChanMode', + 'mediaListWithOneImageAppearance', ]; const coldDeviceStorageSaveKeys: (keyof typeof ColdDeviceStorage.default)[] = [ 'lightTheme', diff --git a/packages/frontend/src/pages/settings/profile.vue b/packages/frontend/src/pages/settings/profile.vue index a5f6c11f89..6ffd682610 100644 --- a/packages/frontend/src/pages/settings/profile.vue +++ b/packages/frontend/src/pages/settings/profile.vue @@ -37,19 +37,40 @@ <template #icon><i class="ti ti-list"></i></template> <template #label>{{ i18n.ts._profile.metadataEdit }}</template> - <div class="_gaps_m"> - <FormSplit v-for="(record, i) in fields" :min-width="250"> - <MkInput v-model="record.name" small> - <template #label>{{ i18n.ts._profile.metadataLabel }} #{{ i + 1 }}</template> - </MkInput> - <MkInput v-model="record.value" small> - <template #label>{{ i18n.ts._profile.metadataContent }} #{{ i + 1 }}</template> - </MkInput> - </FormSplit> - <div> + <div :class="$style.metadataRoot"> + <div :class="$style.metadataMargin"> <MkButton :disabled="fields.length >= 16" inline style="margin-right: 8px;" @click="addField"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> + <MkButton v-if="!fieldEditMode" :disabled="fields.length <= 1" inline danger style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkButton v-else inline style="margin-right: 8px;" @click="fieldEditMode = !fieldEditMode"><i class="ti ti-arrows-sort"></i> {{ i18n.ts.rearrange }}</MkButton> <MkButton inline primary @click="saveFields"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton> </div> + + <Sortable + v-model="fields" + class="_gaps_s" + item-key="id" + :animation="150" + :handle="'.' + $style.dragItemHandle" + @start="e => e.item.classList.add('active')" + @end="e => e.item.classList.remove('active')" + > + <template #item="{element, index}"> + <div :class="$style.fieldDragItem"> + <button v-if="!fieldEditMode" class="_button" :class="$style.dragItemHandle" tabindex="-1"><i class="ti ti-menu"></i></button> + <button v-if="fieldEditMode" :disabled="fields.length <= 1" class="_button" :class="$style.dragItemRemove" @click="deleteField(index)"><i class="ti ti-x"></i></button> + <div :class="$style.dragItemForm"> + <FormSplit :min-width="200"> + <MkInput v-model="element.name" small> + <template #label>{{ i18n.ts._profile.metadataLabel }}</template> + </MkInput> + <MkInput v-model="element.value" small> + <template #label>{{ i18n.ts._profile.metadataContent }}</template> + </MkInput> + </FormSplit> + </div> + </div> + </template> + </Sortable> </div> </MkFolder> <template #caption>{{ i18n.ts._profile.metadataDescription }}</template> @@ -76,7 +97,7 @@ </template> <script lang="ts" setup> -import { computed, reactive, watch } from 'vue'; +import { computed, reactive, ref, watch, defineAsyncComponent, onMounted, onUnmounted } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -94,6 +115,8 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { claimAchievement } from '@/scripts/achievements'; import { defaultStore } from '@/store'; +const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); + const reactionAcceptance = computed(defaultStore.makeGetterSetter('reactionAcceptance')); const profile = reactive({ @@ -113,22 +136,28 @@ watch(() => profile, () => { deep: true, }); -const fields = reactive($i.fields.map(field => ({ name: field.name, value: field.value }))); +const fields = ref($i?.fields.map(field => ({ id: Math.random().toString(), name: field.name, value: field.value })) ?? []); +const fieldEditMode = ref(false); function addField() { - fields.push({ + fields.value.push({ + id: Math.random().toString(), name: '', value: '', }); } -while (fields.length < 4) { +while (fields.value.length < 4) { addField(); } +function deleteField(index: number) { + fields.value.splice(index, 1); +} + function saveFields() { os.apiWithDialog('i/update', { - fields: fields.filter(field => field.name !== '' && field.value !== ''), + fields: fields.value.filter(field => field.name !== '' && field.value !== '').map(field => ({ name: field.name, value: field.value })), }); } @@ -248,3 +277,60 @@ definePageMetadata({ } } </style> +<style lang="scss" module> +.metadataRoot { + container-type: inline-size; +} + +.metadataMargin { + margin-bottom: 1.5em; +} + +.fieldDragItem { + display: flex; + padding-bottom: .75em; + align-items: flex-end; + border-bottom: solid 0.5px var(--divider); + + &:last-child { + border-bottom: 0; + } + + /* (drag button) 32px + (drag button margin) 8px + (input width) 200px * 2 + (input gap) 12px = 452px */ + @container (max-width: 452px) { + align-items: center; + } +} + +.dragItemHandle { + cursor: grab; + width: 32px; + height: 32px; + margin: 0 8px 0 0; + opacity: 0.5; + flex-shrink: 0; + + &:active { + cursor: grabbing; + } +} + +.dragItemRemove { + @extend .dragItemHandle; + + color: #ff2a2a; + opacity: 1; + cursor: pointer; + + &:hover, &:focus { + opacity: .7; + } + &:active { + cursor: pointer; + } +} + +.dragItemForm { + flex-grow: 1; +} +</style> diff --git a/packages/frontend/src/pages/timeline.tutorial.vue b/packages/frontend/src/pages/timeline.tutorial.vue index 0d0c932a5c..32228d28f4 100644 --- a/packages/frontend/src/pages/timeline.tutorial.vue +++ b/packages/frontend/src/pages/timeline.tutorial.vue @@ -1,7 +1,7 @@ <template> <div :class="$style.container"> <div :class="$style.title"> - <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._tutorial.title }}</div> + <div :class="$style.titleText"><i class="ti ti-info-circle"></i> {{ i18n.ts._timelineTutorial.title }}</div> <div :class="$style.step"> <button class="_button" :class="$style.stepArrow" :disabled="tutorial === 0" @click="tutorial--"> <i class="ti ti-chevron-left"></i> @@ -12,66 +12,30 @@ </button> </div> </div> + <div v-if="tutorial === 0" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step1_1 }}</div> - <div>{{ i18n.ts._tutorial.step1_2 }}</div> - <div>{{ i18n.ts._tutorial.step1_3 }}</div> + <div>{{ i18n.t('_timelineTutorial.step1_1', { name: instance.name ?? host }) }}</div> + <div>{{ i18n.t('_timelineTutorial.step1_2', { name: instance.name ?? host }) }}</div> </div> <div v-else-if="tutorial === 1" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step2_1 }}</div> - <div>{{ i18n.ts._tutorial.step2_2 }}</div> - <MkA class="_link" to="/settings/profile">{{ i18n.ts.editProfile }}</MkA> + <div>{{ i18n.ts._timelineTutorial.step2_1 }}</div> + <div>{{ i18n.t('_timelineTutorial.step2_2', { name: instance.name ?? host }) }}</div> </div> <div v-else-if="tutorial === 2" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step3_1 }}</div> - <div>{{ i18n.ts._tutorial.step3_2 }}</div> - <div>{{ i18n.ts._tutorial.step3_3 }}</div> - <small :class="$style.small">{{ i18n.ts._tutorial.step3_4 }}</small> + <div>{{ i18n.ts._timelineTutorial.step3_1 }}</div> + <div>{{ i18n.ts._timelineTutorial.step3_2 }}</div> </div> <div v-else-if="tutorial === 3" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step4_1 }}</div> - <div>{{ i18n.ts._tutorial.step4_2 }}</div> - </div> - <div v-else-if="tutorial === 4" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step5_1 }}</div> - <I18n :src="i18n.ts._tutorial.step5_2" tag="div"> - <template #featured> - <MkA class="_link" to="/explore">{{ i18n.ts.featured }}</MkA> - </template> - <template #explore> - <MkA class="_link" to="/explore#users">{{ i18n.ts.explore }}</MkA> - </template> - </I18n> - <div>{{ i18n.ts._tutorial.step5_3 }}</div> - <small :class="$style.small">{{ i18n.ts._tutorial.step5_4 }}</small> - </div> - <div v-else-if="tutorial === 5" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step6_1 }}</div> - <div>{{ i18n.ts._tutorial.step6_2 }}</div> - <div>{{ i18n.ts._tutorial.step6_3 }}</div> - </div> - <div v-else-if="tutorial === 6" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step7_1 }}</div> - <I18n :src="i18n.ts._tutorial.step7_2" tag="div"> - <template #help> - <a href="https://misskey-hub.net/help.html" target="_blank" class="_link">{{ i18n.ts.help }}</a> - </template> - </I18n> - <div>{{ i18n.ts._tutorial.step7_3 }}</div> - </div> - <div v-else-if="tutorial === 7" :class="$style.body"> - <div>{{ i18n.ts._tutorial.step8_1 }}</div> - <div>{{ i18n.ts._tutorial.step8_2 }}</div> - <small :class="$style.small">{{ i18n.ts._tutorial.step8_3 }}</small> + <div>{{ i18n.ts._timelineTutorial.step4_1 }}</div> + <div>{{ i18n.ts._timelineTutorial.step4_2 }}</div> </div> <div :class="$style.footer"> <template v-if="tutorial === tutorialsNumber - 1"> - <MkPushNotificationAllowButton :class="$style.footerItem" primary show-only-to-register @click="tutorial = -1"/> - <MkButton :class="$style.footerItem" :primary="false" @click="tutorial = -1">{{ i18n.ts.noThankYou }}</MkButton> + <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial = -1">{{ i18n.ts.done }} <i class="ti ti-check"></i></MkButton> </template> <template v-else> - <MkButton :class="$style.footerItem" primary @click="tutorial++"><i class="ti ti-check"></i> {{ i18n.ts.next }}</MkButton> + <MkButton :class="$style.footerItem" primary rounded gradate @click="tutorial++">{{ i18n.ts.next }} <i class="ti ti-arrow-right"></i></MkButton> </template> </div> </div> @@ -80,15 +44,16 @@ <script lang="ts" setup> import { computed } from 'vue'; import MkButton from '@/components/MkButton.vue'; -import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import { host } from '@/config'; -const tutorialsNumber = 8; +const tutorialsNumber = 4; const tutorial = computed({ - get() { return defaultStore.reactiveState.tutorial.value || 0; }, - set(value) { defaultStore.set('tutorial', value); }, + get() { return defaultStore.reactiveState.timelineTutorial.value || 0; }, + set(value) { defaultStore.set('timelineTutorial', value); }, }); </script> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index 9f13f7a1dd..1bf4cdc99a 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -3,7 +3,7 @@ <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="$i ? headerTabs : headerTabsWhenNotLogin" :display-my-avatar="true"/></template> <MkSpacer :content-max="800"> <div ref="rootEl" v-hotkey.global="keymap"> - <XTutorial v-if="$i && defaultStore.reactiveState.tutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> + <XTutorial v-if="$i && defaultStore.reactiveState.timelineTutorial.value != -1" class="_panel" style="margin-bottom: var(--margin);"/> <MkPostForm v-if="defaultStore.reactiveState.showFixedPostForm.value" :class="$style.postForm" class="post-form _panel" fixed style="margin-bottom: var(--margin);"/> <div v-if="queue > 0" :class="$style.new"><button class="_buttonPrimary" @click="top()">{{ i18n.ts.newNoteRecived }}</button></div> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 8c3478d8f2..5bc1578268 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -7,7 +7,7 @@ <!-- <div class="punished" v-if="user.isSilenced"><i class="ti ti-alert-triangle" style="margin-right: 8px;"></i> {{ i18n.ts.userSilenced }}</div> --> <div class="profile _gaps"> - <MkAccountMoved v-if="user.movedToUri" :host="user.movedToUri.host" :acct="user.movedToUri.username"/> + <MkAccountMoved v-if="user.movedTo" :moved-to="user.movedTo"/> <MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!" class="warn"/> <div :key="user.id" class="main _panel"> @@ -21,6 +21,9 @@ <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> + <button v-if="!isEditingMemo && !memoDraft" class="_button add-note-button" @click="showMemoTextarea"> + <i class="ti ti-edit"/> {{ i18n.ts.addMemo }} + </button> </div> </div> <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ i18n.ts.followsYou }}</span> @@ -45,6 +48,22 @@ {{ role.name }} </span> </div> + <div v-if="iAmModerator" class="moderationNote"> + <MkTextarea v-model="moderationNote" manual-save> + <template #label>Moderation note</template> + </MkTextarea> + </div> + <div v-if="isEditingMemo || memoDraft" class="memo" :class="{'no-memo': !memoDraft}"> + <div class="heading" v-text="i18n.ts.memo"/> + <textarea + ref="memoTextareaEl" + v-model="memoDraft" + rows="1" + @focus="isEditingMemo = true" + @blur="updateMemo" + @input="adjustMemoTextarea" + /> + </div> <div class="description"> <MkOmit> <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i"/> @@ -113,13 +132,14 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, computed, onMounted, onUnmounted } from 'vue'; +import { defineAsyncComponent, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'; import calcAge from 's-age'; import * as misskey from 'misskey-js'; import MkNote from '@/components/MkNote.vue'; import MkFollowButton from '@/components/MkFollowButton.vue'; import MkAccountMoved from '@/components/MkAccountMoved.vue'; import MkRemoteCaution from '@/components/MkRemoteCaution.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; import MkOmit from '@/components/MkOmit.vue'; import MkInfo from '@/components/MkInfo.vue'; import { getScrollPosition } from '@/scripts/scroll'; @@ -129,10 +149,11 @@ import { userPage } from '@/filters/user'; import * as os from '@/os'; import { useRouter } from '@/router'; import { i18n } from '@/i18n'; -import { $i } from '@/account'; +import { $i, iAmModerator } from '@/account'; import { dateString } from '@/filters/date'; import { confetti } from '@/scripts/confetti'; import MkNotes from '@/components/MkNotes.vue'; +import { api } from '@/os'; const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); @@ -151,6 +172,14 @@ let parallaxAnimationId = $ref<null | number>(null); let narrow = $ref<null | boolean>(null); let rootEl = $ref<null | HTMLElement>(null); let bannerEl = $ref<null | HTMLElement>(null); +let memoTextareaEl = $ref<null | HTMLElement>(null); +let memoDraft = $ref(props.user.memo); +let isEditingMemo = $ref(false); +let moderationNote = $ref(props.user.moderationNote); + +watch($$(moderationNote), async () => { + await os.api('admin/update-user-note', { userId: props.user.id, text: moderationNote }); +}); const pagination = { endpoint: 'users/notes' as const, @@ -193,6 +222,31 @@ function parallax() { banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; } +function showMemoTextarea() { + isEditingMemo = true; + nextTick(() => { + memoTextareaEl?.focus(); + }); +} + +function adjustMemoTextarea() { + if (!memoTextareaEl) return; + memoTextareaEl.style.height = '0px'; + memoTextareaEl.style.height = `${memoTextareaEl.scrollHeight}px`; +} + +async function updateMemo() { + await api('users/update-memo', { + memo: memoDraft, + userId: props.user.id, + }); + isEditingMemo = false; +} + +watch([props.user], () => { + memoDraft = props.user.memo; +}); + onMounted(() => { window.requestAnimationFrame(parallaxLoop); narrow = rootEl!.clientWidth < 1000; @@ -208,6 +262,9 @@ onMounted(() => { }); } } + nextTick(() => { + adjustMemoTextarea(); + }); }); onUnmounted(() => { @@ -323,6 +380,16 @@ onUnmounted(() => { font-weight: bold; } } + + > .add-note-button { + background: rgba(0, 0, 0, 0.2); + color: #fff; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + border-radius: 24px; + padding: 4px 8px; + font-size: 80%; + } } } } @@ -369,6 +436,43 @@ onUnmounted(() => { } } + > .moderationNote { + margin: 12px 24px 0 154px; + } + + > .memo { + margin: 12px 24px 0 154px; + background: transparent; + color: var(--fg); + border: 1px solid var(--divider); + border-radius: 8px; + padding: 8px; + line-height: 0; + + > .heading { + text-align: left; + color: var(--fgTransparent); + line-height: 1.5; + font-size: 85%; + } + + textarea { + margin: 0; + padding: 0; + resize: none; + border: none; + outline: none; + width: 100%; + height: auto; + min-height: 0; + line-height: 1.5; + color: var(--fg); + overflow: hidden; + background: transparent; + font-family: inherit; + } + } + > .description { padding: 24px 24px 24px 154px; font-size: 0.95em; @@ -504,6 +608,14 @@ onUnmounted(() => { justify-content: center; } + > .moderationNote { + margin: 16px 16px 0 16px; + } + + > .memo { + margin: 16px 16px 0 16px; + } + > .description { padding: 16px; text-align: center; diff --git a/packages/frontend/src/pages/welcome.entrance.a.vue b/packages/frontend/src/pages/welcome.entrance.a.vue index 4d8d76db18..929152bd5a 100644 --- a/packages/frontend/src/pages/welcome.entrance.a.vue +++ b/packages/frontend/src/pages/welcome.entrance.a.vue @@ -13,35 +13,7 @@ <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> </div> <div class="contents"> - <div class="main"> - <img :src="instance.iconUrl || instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> - <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> - <div class="fg"> - <h1> - <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> - <!-- <img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> --> - <span class="text">{{ instanceName }}</span> - </h1> - <div class="about"> - <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> - </div> - <div v-if="instance.disableRegistration" class="warn"> - <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> - </div> - <div class="action _gaps_s"> - <MkButton full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton> - <MkButton full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton> - <MkButton full rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> - </div> - </div> - </div> - <div v-if="instance.policies.ltlAvailable" class="tl"> - <div class="title">{{ i18n.ts.letsLookAtTimeline }}</div> - <div class="body"> - <MkTimeline src="local"/> - </div> - </div> + <MkVisitorDashboard/> </div> <div v-if="instances && instances.length > 0" class="federation"> <MarqueeText :duration="40"> @@ -60,16 +32,15 @@ import { } from 'vue'; import { Instance } from 'misskey-js/built/entities'; import XTimeline from './welcome.timeline.vue'; import MarqueeText from '@/components/MkMarquee.vue'; -import XSigninDialog from '@/components/MkSigninDialog.vue'; -import XSignupDialog from '@/components/MkSignupDialog.vue'; -import MkButton from '@/components/MkButton.vue'; import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.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 MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; let meta = $ref<Instance>(); let instances = $ref<any[]>(); @@ -84,45 +55,6 @@ os.apiGet('federation/instances', { }).then(_instances => { instances = _instances; }); - -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-question-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" scoped> @@ -202,89 +134,11 @@ function exploreOtherServers() { position: relative; width: min(430px, calc(100% - 32px)); margin-left: 128px; - padding: 150px 0 100px 0; + padding: 100px 0 100px 0; @media (max-width: 1200px) { margin: auto; } - - > .main { - position: relative; - background: var(--panel); - border-radius: var(--radius); - box-shadow: 0 12px 32px rgb(0 0 0 / 25%); - text-align: center; - - > .icon { - width: 85px; - margin-top: -47px; - border-radius: 100%; - vertical-align: bottom; - } - - > .menu { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - height: 32px; - border-radius: 8px; - font-size: 18px; - } - - > .fg { - position: relative; - z-index: 1; - - > h1 { - display: block; - margin: 0; - padding: 16px 32px 24px 32px; - font-size: 1.4em; - - > .logo { - vertical-align: bottom; - max-height: 120px; - max-width: min(100%, 300px); - } - } - - > .about { - padding: 0 32px; - } - - > .warn { - padding: 32px 32px 0 32px; - } - - > .action { - padding: 32px; - - > * { - line-height: 28px; - } - } - } - } - - > .tl { - position: relative; - background: var(--panel); - border-radius: var(--radius); - overflow: clip; - box-shadow: 0 12px 32px rgb(0 0 0 / 25%); - margin-top: 16px; - - > .title { - padding: 12px 16px; - border-bottom: solid 1px var(--divider); - } - - > .body { - height: 350px; - overflow: auto; - } - } } > .federation { diff --git a/packages/frontend/src/pages/welcome.entrance.b.vue b/packages/frontend/src/pages/welcome.entrance.b.vue deleted file mode 100644 index 03bf174710..0000000000 --- a/packages/frontend/src/pages/welcome.entrance.b.vue +++ /dev/null @@ -1,239 +0,0 @@ -<template> -<div v-if="meta" class="rsqzvsbo"> - <div class="top"> - <MkFeaturedPhotos class="bg"/> - <XTimeline class="tl"/> - <div class="shape"></div> - <div class="main"> - <h1> - <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> - </h1> - <div class="about"> - <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> - </div> - <div class="action"> - <MkButton class="signup" inline gradate @click="signup()">{{ i18n.ts.signup }}</MkButton> - <MkButton class="signin" inline @click="signin()">{{ i18n.ts.login }}</MkButton> - </div> - <div v-if="onlineUsersCount && stats" class="status"> - <div> - <I18n :src="i18n.ts.nUsers" text-tag="span" class="users"> - <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> - </I18n> - <I18n :src="i18n.ts.nNotes" text-tag="span" class="notes"> - <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> - </I18n> - </div> - <I18n :src="i18n.ts.onlineUsersCount" text-tag="span" class="online"> - <template #n><b>{{ onlineUsersCount }}</b></template> - </I18n> - </div> - </div> - <img src="/client-assets/misskey.svg" class="misskey"/> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { toUnicode } from 'punycode/'; -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 MkNote from '@/components/MkNote.vue'; -import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; -import { host, instanceName } from '@/config'; -import * as os from '@/os'; -import number from '@/filters/number'; -import { i18n } from '@/i18n'; - -export default defineComponent({ - components: { - MkButton, - MkNote, - XTimeline, - MkFeaturedPhotos, - }, - - data() { - return { - host: toUnicode(host), - instanceName, - meta: null, - stats: null, - tags: [], - onlineUsersCount: null, - i18n, - }; - }, - - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - - os.api('stats').then(stats => { - this.stats = stats; - }); - - os.api('get-online-users-count').then(res => { - this.onlineUsersCount = res.count; - }); - - os.api('hashtags/list', { - sort: '+mentionedLocalUsers', - limit: 8, - }).then(tags => { - this.tags = tags; - }); - }, - - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - showMenu(ev) { - os.popupMenu([{ - text: i18n.t('aboutX', { x: instanceName }), - 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-question-circle', - action: () => { - window.open('https://misskey-hub.net/help.md', '_blank'); - }, - }], ev.currentTarget ?? ev.target); - }, - - number, - }, -}); -</script> - -<style lang="scss" scoped> -.rsqzvsbo { - > .top { - min-height: 100vh; - box-sizing: border-box; - - > .bg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } - - > .tl { - position: absolute; - top: 0; - bottom: 0; - right: 64px; - margin: auto; - width: 500px; - height: calc(100% - 128px); - overflow: hidden; - -webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 128px, rgba(0,0,0,1) calc(100% - 128px), rgba(0,0,0,0) 100%); - } - - > .shape { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--accent); - clip-path: polygon(0% 0%, 40% 0%, 22% 100%, 0% 100%); - } - - > .misskey { - position: absolute; - bottom: 64px; - left: 64px; - width: 160px; - } - - > .main { - position: relative; - width: min(450px, 100%); - padding: 64px; - color: #fff; - font-size: 1.1em; - - @media (max-width: 1200px) { - margin: auto; - } - - > h1 { - display: block; - margin: 0 0 32px 0; - padding: 0; - - > .logo { - vertical-align: bottom; - max-height: 100px; - } - } - - > .about { - padding: 0; - } - - > .action { - margin: 32px 0; - - > * { - line-height: 32px; - } - - > .signup { - background: var(--panel); - color: var(--fg); - } - - > .signin { - background: var(--accent); - color: inherit; - } - } - - > .status { - margin: 32px 0; - border-top: solid 1px rgba(255, 255, 255, 0.5); - font-size: 90%; - - > div { - padding: 16px 0; - - > span:not(:last-child) { - padding-right: 1em; - margin-right: 1em; - border-right: solid 1px rgba(255, 255, 255, 0.5); - } - } - } - } - } -} -</style> diff --git a/packages/frontend/src/pages/welcome.entrance.c.vue b/packages/frontend/src/pages/welcome.entrance.c.vue deleted file mode 100644 index eca4e5764d..0000000000 --- a/packages/frontend/src/pages/welcome.entrance.c.vue +++ /dev/null @@ -1,308 +0,0 @@ -<template> -<div v-if="meta" class="rsqzvsbo"> - <div class="top"> - <MkFeaturedPhotos class="bg"/> - <div class="fade"></div> - <div class="emojis"> - <MkEmoji :normal="true" :no-style="true" emoji="👍"/> - <MkEmoji :normal="true" :no-style="true" emoji="❤"/> - <MkEmoji :normal="true" :no-style="true" emoji="😆"/> - <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> - <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> - </div> - <div class="main"> - <img src="/client-assets/misskey.svg" class="misskey"/> - <div class="form _panel"> - <div class="bg"> - <div class="fade"></div> - </div> - <div class="fg"> - <h1> - <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> - </h1> - <div class="about"> - <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> - </div> - <div class="action"> - <MkButton inline gradate @click="signup()">{{ i18n.ts.signup }}</MkButton> - <MkButton inline @click="signin()">{{ i18n.ts.login }}</MkButton> - </div> - <div v-if="onlineUsersCount && stats" class="status"> - <div> - <I18n :src="i18n.ts.nUsers" text-tag="span" class="users"> - <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> - </I18n> - <I18n :src="i18n.ts.nNotes" text-tag="span" class="notes"> - <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> - </I18n> - </div> - <I18n :src="i18n.ts.onlineUsersCount" text-tag="span" class="online"> - <template #n><b>{{ onlineUsersCount }}</b></template> - </I18n> - </div> - <button class="_button _acrylic menu" @click="showMenu"><i class="ti ti-dots"></i></button> - </div> - </div> - <nav class="nav"> - <MkA to="/announcements">{{ i18n.ts.announcements }}</MkA> - <MkA to="/explore">{{ i18n.ts.explore }}</MkA> - <MkA to="/channels">{{ i18n.ts.channel }}</MkA> - <MkA to="/featured">{{ i18n.ts.featured }}</MkA> - </nav> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { toUnicode } from 'punycode/'; -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 MkNote from '@/components/MkNote.vue'; -import MkFeaturedPhotos from '@/components/MkFeaturedPhotos.vue'; -import { host, instanceName } from '@/config'; -import * as os from '@/os'; -import number from '@/filters/number'; -import { i18n } from '@/i18n'; - -export default defineComponent({ - components: { - MkButton, - MkNote, - MkFeaturedPhotos, - XTimeline, - }, - - data() { - return { - host: toUnicode(host), - instanceName, - meta: null, - stats: null, - tags: [], - onlineUsersCount: null, - i18n, - }; - }, - - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - - os.api('stats').then(stats => { - this.stats = stats; - }); - - os.api('get-online-users-count').then(res => { - this.onlineUsersCount = res.count; - }); - - os.api('hashtags/list', { - sort: '+mentionedLocalUsers', - limit: 8, - }).then(tags => { - this.tags = tags; - }); - }, - - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - showMenu(ev) { - os.popupMenu([{ - text: i18n.t('aboutX', { x: instanceName }), - 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-question-circle', - action: () => { - window.open('https://misskey-hub.net/help.md', '_blank'); - }, - }], ev.currentTarget ?? ev.target); - }, - - number, - }, -}); -</script> - -<style lang="scss" scoped> -.rsqzvsbo { - > .top { - display: flex; - text-align: center; - min-height: 100vh; - box-sizing: border-box; - padding: 16px; - - > .bg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - } - - > .fade { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.25); - } - - > .emojis { - position: absolute; - bottom: 32px; - left: 35px; - - > * { - margin-right: 8px; - } - - @media (max-width: 1200px) { - display: none; - } - } - - > .main { - position: relative; - width: min(460px, 100%); - margin: auto; - - > .misskey { - width: 150px; - margin-bottom: 16px; - - @media (max-width: 450px) { - width: 130px; - } - } - - > .form { - position: relative; - box-shadow: 0 12px 32px rgb(0 0 0 / 25%); - - > .bg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 128px; - background-position: center; - background-size: cover; - opacity: 0.75; - - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 128px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - } - } - - > .fg { - position: relative; - z-index: 1; - - > h1 { - display: block; - margin: 0; - padding: 32px 32px 24px 32px; - - > .logo { - vertical-align: bottom; - max-height: 120px; - } - } - - > .about { - padding: 0 32px; - } - - > .action { - padding: 32px; - - > * { - line-height: 28px; - } - } - - > .status { - border-top: solid 0.5px var(--divider); - padding: 32px; - font-size: 90%; - - > div { - > span:not(:last-child) { - padding-right: 1em; - margin-right: 1em; - border-right: solid 0.5px var(--divider); - } - } - - > .online { - ::v-deep(b) { - color: #41b781; - } - - ::v-deep(span) { - opacity: 0.7; - } - } - } - - > .menu { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - height: 32px; - border-radius: 8px; - } - } - } - - > .nav { - position: relative; - z-index: 2; - margin-top: 20px; - color: #fff; - text-shadow: 0 0 8px black; - font-size: 0.9em; - - > *:not(:last-child) { - margin-right: 1.5em; - } - } - } - } -} -</style> diff --git a/packages/frontend/src/pages/welcome.setup.vue b/packages/frontend/src/pages/welcome.setup.vue index 212d156a83..7728d97a65 100644 --- a/packages/frontend/src/pages/welcome.setup.vue +++ b/packages/frontend/src/pages/welcome.setup.vue @@ -1,8 +1,11 @@ <template> -<form class="mk-setup" @submit.prevent="submit()"> - <h1>Welcome to Misskey!</h1> - <div class="_gaps_m"> - <p>{{ i18n.ts.intro }}</p> +<form :class="$style.root" class="_panel" @submit.prevent="submit()"> + <div :class="$style.title"> + <div>Welcome to Misskey!</div> + <div :class="$style.version">v{{ version }}</div> + </div> + <div class="_gaps_m" style="padding: 32px;"> + <div>{{ i18n.ts.intro }}</div> <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> @@ -12,8 +15,8 @@ <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="ti ti-lock"></i></template> </MkInput> - <div class="bottom"> - <MkButton gradate type="submit" :disabled="submitting" data-cy-admin-ok> + <div> + <MkButton gradate large rounded type="submit" :disabled="submitting" data-cy-admin-ok style="margin: 0 auto;"> {{ submitting ? i18n.ts.processing : i18n.ts.done }}<MkEllipsis v-if="submitting"/> </MkButton> </div> @@ -25,7 +28,7 @@ import { } from 'vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; -import { host } from '@/config'; +import { host, version } from '@/config'; import * as os from '@/os'; import { login } from '@/account'; import { i18n } from '@/i18n'; @@ -54,36 +57,28 @@ function submit() { } </script> -<style lang="scss" scoped> -.mk-setup { +<style lang="scss" module> +.root { border-radius: var(--radius); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); overflow: hidden; max-width: 500px; margin: 32px auto; +} - > h1 { - margin: 0; - font-size: 1.5em; - text-align: center; - padding: 32px; - background: var(--accent); - color: #fff; - } - - > div { - padding: 32px; - background: var(--panel); - - > p { - margin-top: 0; - } +.title { + margin: 0; + font-size: 1.5em; + text-align: center; + padding: 32px; + background: var(--accentedBg); + color: var(--accent); + font-weight: bold; +} - > .bottom { - > * { - margin: 0 auto; - } - } - } +.version { + font-size: 70%; + font-weight: normal; + opacity: 0.7; } </style> diff --git a/packages/frontend/src/pages/welcome.timeline.vue b/packages/frontend/src/pages/welcome.timeline.vue index 6a507ee1ed..6ec6e3f863 100644 --- a/packages/frontend/src/pages/welcome.timeline.vue +++ b/packages/frontend/src/pages/welcome.timeline.vue @@ -33,7 +33,7 @@ import { $i } from '@/account'; let notes = $ref<Note[]>([]); let isScrolling = $ref(false); -let scrollEl = $ref<HTMLElement>(); +let scrollEl = $shallowRef<HTMLElement>(); os.apiGet('notes/featured').then(_notes => { notes = _notes; diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 0769ec2614..e46c1eeb77 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -164,7 +164,7 @@ export const routes = [{ }, { path: '/migration', name: 'migration', - component: page(() => import('./pages/settings/migration.vue')) + component: page(() => import('./pages/settings/migration.vue')), }, { path: '/custom-css', name: 'general', @@ -174,13 +174,9 @@ export const routes = [{ name: 'profile', component: page(() => import('./pages/settings/accounts.vue')), }, { - path: '/account-info', + path: '/account-stats', name: 'other', - component: page(() => import('./pages/settings/account-info.vue')), - }, { - path: '/delete-account', - name: 'other', - component: page(() => import('./pages/settings/delete-account.vue')), + component: page(() => import('./pages/settings/account-stats.vue')), }, { path: '/other', name: 'other', @@ -428,6 +424,10 @@ export const routes = [{ name: 'other-settings', component: page(() => import('./pages/admin/other-settings.vue')), }, { + path: '/server-rules', + name: 'server-rules', + component: page(() => import('./pages/admin/server-rules.vue')), + }, { path: '/', component: page(() => import('./pages/_empty_.vue')), }], diff --git a/packages/frontend/src/scripts/achievements.ts b/packages/frontend/src/scripts/achievements.ts index 25e8b71a12..fbca005769 100644 --- a/packages/frontend/src/scripts/achievements.ts +++ b/packages/frontend/src/scripts/achievements.ts @@ -60,6 +60,7 @@ export const ACHIEVEMENT_TYPES = [ 'iLoveMisskey', 'foundTreasure', 'client30min', + 'client60min', 'noteDeletedWithin1min', 'postedAtLateNight', 'postedAt0min0sec', @@ -343,6 +344,11 @@ export const ACHIEVEMENT_BADGES = { bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', frame: 'bronze', }, + 'client60min': { + img: '/fluent-emoji/1f552.png', + bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', + frame: 'silver', + }, 'noteDeletedWithin1min': { img: '/fluent-emoji/1f5d1.png', bg: 'linear-gradient(0deg, rgb(220 223 225), rgb(172 192 207))', @@ -458,6 +464,7 @@ const claimingQueue = new Set<string>(); export async function claimAchievement(type: typeof ACHIEVEMENT_TYPES[number]) { if ($i == null) return; + if ($i.movedTo) return; if (claimedAchievements.includes(type)) return; claimingQueue.add(type); claimedAchievements.push(type); diff --git a/packages/frontend/src/scripts/aiscript/api.ts b/packages/frontend/src/scripts/aiscript/api.ts index 1b47eaa420..b6b7445b67 100644 --- a/packages/frontend/src/scripts/aiscript/api.ts +++ b/packages/frontend/src/scripts/aiscript/api.ts @@ -17,6 +17,7 @@ export function createAiScriptEnv(opts) { title: title.value, text: text.value, }); + return values.NULL; }), 'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => { const confirm = await os.confirm({ diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts index 52e610e437..ed01b49054 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/scripts/get-drive-file-menu.ts @@ -36,6 +36,12 @@ function toggleSensitive(file: Misskey.entities.DriveFile) { os.api('drive/files/update', { fileId: file.id, isSensitive: !file.isSensitive, + }).catch(err => { + os.alert({ + type: 'error', + title: i18n.ts.error, + text: err.message, + }); }); } @@ -74,6 +80,12 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile) { icon: 'ti ti-text-caption', action: () => describe(file), }, null, { + text: i18n.ts.createNoteFromTheFile, + icon: 'ti ti-pencil', + action: () => os.post({ + initialFiles: [file], + }), + }, { text: i18n.ts.copyUrl, icon: 'ti ti-link', action: () => copyUrl(file), diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index d91f0b0eb6..c8a6100253 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -211,6 +211,12 @@ export function getNoteMenu(props: { }, {}, 'closed'); } + function showRenotes(): void { + os.popup(defineAsyncComponent(() => import('@/components/MkRenotedUsersDialog.vue')), { + noteId: appearNote.id, + }, {}, 'closed'); + } + async function translate(): Promise<void> { if (props.translation.value != null) return; props.translating.value = true; @@ -241,8 +247,12 @@ export function getNoteMenu(props: { text: i18n.ts.details, action: openDetail, }, { - icon: 'ti ti-users', - text: i18n.ts.reactions, + icon: 'ti ti-repeat', + text: i18n.ts.renotesList, + action: showRenotes, + }, { + icon: 'ti ti-icons', + text: i18n.ts.reactionsList, action: showReactions, }, { icon: 'ti ti-copy', diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index fe941c77b2..6ff9fb63f1 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -98,6 +98,27 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router }); } + async function editMemo(): Promise<void> { + const userDetailed = await os.api('users/show', { + userId: user.id, + }); + const { canceled, result } = await os.form(i18n.ts.editMemo, { + memo: { + type: 'string', + required: true, + multiline: true, + label: i18n.ts.memo, + default: userDetailed.memo, + }, + }); + if (canceled) return; + + os.apiWithDialog('users/update-memo', { + memo: result.memo, + userId: user.id, + }); + } + let menu = [{ icon: 'ti ti-at', text: i18n.ts.copyUsername, @@ -123,6 +144,12 @@ export function getUserMenu(user: misskey.entities.UserDetailed, router: Router os.post({ specified: user, initialText: `@${user.username} ` }); }, }, null, { + icon: 'ti ti-pencil', + text: i18n.ts.editMemo, + action: () => { + editMemo(); + }, + }, { type: 'parent', icon: 'ti ti-list', text: i18n.ts.addToList, diff --git a/packages/frontend/src/scripts/please-login.ts b/packages/frontend/src/scripts/please-login.ts index b8fb853cc1..c101a127f3 100644 --- a/packages/frontend/src/scripts/please-login.ts +++ b/packages/frontend/src/scripts/please-login.ts @@ -17,5 +17,5 @@ export function pleaseLogin(path?: string) { }, }, 'closed'); - if (!path) throw new Error('signin required'); + throw new Error('signin required'); } diff --git a/packages/frontend/src/scripts/select-file.ts b/packages/frontend/src/scripts/select-file.ts index ec5f8f65e9..fe9f0a2447 100644 --- a/packages/frontend/src/scripts/select-file.ts +++ b/packages/frontend/src/scripts/select-file.ts @@ -6,70 +6,76 @@ import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import { uploadFile } from '@/scripts/upload'; -function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { +export function chooseFileFromPc(multiple: boolean, keepOriginal = false): Promise<DriveFile[]> { return new Promise((res, rej) => { - const keepOriginal = ref(defaultStore.state.keepOriginalUploading); - - const chooseFileFromPc = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = multiple; - input.onchange = () => { - const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal.value)); + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = multiple; + input.onchange = () => { + const promises = Array.from(input.files).map(file => uploadFile(file, defaultStore.state.uploadFolder, undefined, keepOriginal)); - Promise.all(promises).then(driveFiles => { - res(multiple ? driveFiles : driveFiles[0]); - }).catch(err => { - // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない - }); + Promise.all(promises).then(driveFiles => { + res(driveFiles); + }).catch(err => { + // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない + }); - // 一応廃棄 - (window as any).__misskey_input_ref__ = null; - }; + // 一応廃棄 + (window as any).__misskey_input_ref__ = null; + }; - // https://qiita.com/fukasawah/items/b9dc732d95d99551013d - // iOS Safari で正常に動かす為のおまじない - (window as any).__misskey_input_ref__ = input; + // https://qiita.com/fukasawah/items/b9dc732d95d99551013d + // iOS Safari で正常に動かす為のおまじない + (window as any).__misskey_input_ref__ = input; - input.click(); - }; + input.click(); + }); +} - const chooseFileFromDrive = () => { - os.selectDriveFile(multiple).then(files => { - res(files); - }); - }; +export function chooseFileFromDrive(multiple: boolean): Promise<DriveFile[]> { + return new Promise((res, rej) => { + os.selectDriveFile(multiple).then(files => { + res(files); + }); + }); +} - const chooseFileFromUrl = () => { - os.inputText({ - title: i18n.ts.uploadFromUrl, - type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription, - }).then(({ canceled, result: url }) => { - if (canceled) return; +export function chooseFileFromUrl(): Promise<DriveFile> { + return new Promise((res, rej) => { + os.inputText({ + title: i18n.ts.uploadFromUrl, + type: 'url', + placeholder: i18n.ts.uploadFromUrlDescription, + }).then(({ canceled, result: url }) => { + if (canceled) return; - const marker = Math.random().toString(); // TODO: UUIDとか使う + const marker = Math.random().toString(); // TODO: UUIDとか使う - const connection = stream.useChannel('main'); - connection.on('urlUploadFinished', urlResponse => { - if (urlResponse.marker === marker) { - res(multiple ? [urlResponse.file] : urlResponse.file); - connection.dispose(); - } - }); + const connection = stream.useChannel('main'); + connection.on('urlUploadFinished', urlResponse => { + if (urlResponse.marker === marker) { + res(urlResponse.file); + connection.dispose(); + } + }); - os.api('drive/files/upload-from-url', { - url: url, - folderId: defaultStore.state.uploadFolder, - marker, - }); + os.api('drive/files/upload-from-url', { + url: url, + folderId: defaultStore.state.uploadFolder, + marker, + }); - os.alert({ - title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime, - }); + os.alert({ + title: i18n.ts.uploadFromUrlRequested, + text: i18n.ts.uploadFromUrlMayTakeTime, }); - }; + }); + }); +} + +function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile[]> { + return new Promise((res, rej) => { + const keepOriginal = ref(defaultStore.state.keepOriginalUploading); os.popupMenu([label ? { text: label, @@ -81,23 +87,23 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv }, { text: i18n.ts.upload, icon: 'ti ti-upload', - action: chooseFileFromPc, + action: () => chooseFileFromPc(multiple, keepOriginal.value).then(files => res(files)), }, { text: i18n.ts.fromDrive, icon: 'ti ti-cloud', - action: chooseFileFromDrive, + action: () => chooseFileFromDrive(multiple).then(files => res(files)), }, { text: i18n.ts.fromUrl, icon: 'ti ti-link', - action: chooseFileFromUrl, + action: () => chooseFileFromUrl().then(file => res([file])), }], src); }); } export function selectFile(src: any, label: string | null = null): Promise<DriveFile> { - return select(src, label, false) as Promise<DriveFile>; + return select(src, label, false).then(files => files[0]); } export function selectFiles(src: any, label: string | null = null): Promise<DriveFile[]> { - return select(src, label, true) as Promise<DriveFile[]>; + return select(src, label, true); } diff --git a/packages/frontend/src/scripts/show-moved-dialog.ts b/packages/frontend/src/scripts/show-moved-dialog.ts new file mode 100644 index 0000000000..acb26c36e2 --- /dev/null +++ b/packages/frontend/src/scripts/show-moved-dialog.ts @@ -0,0 +1,16 @@ +import * as os from '@/os'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; + +export function showMovedDialog() { + if (!$i) return; + if (!$i.movedTo) return; + + os.alert({ + type: 'error', + title: i18n.ts.accountMovedShort, + text: i18n.ts.operationForbidden, + }); + + throw new Error('account moved'); +} diff --git a/packages/frontend/src/scripts/upload.ts b/packages/frontend/src/scripts/upload.ts index 9a39652ef5..2dd11c9fa2 100644 --- a/packages/frontend/src/scripts/upload.ts +++ b/packages/frontend/src/scripts/upload.ts @@ -83,7 +83,13 @@ export function uploadFile( // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい uploads.value = uploads.value.filter(x => x.id !== id); - if (ev.target?.response) { + if (xhr.status === 413) { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit, + }); + } else if (ev.target?.response) { const res = JSON.parse(ev.target.response); if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { alert({ diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index a935093240..245bcbefe1 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -38,7 +38,11 @@ export const pageViewInterruptors: PageViewInterruptor[] = []; // TODO: それぞれいちいちwhereとかdefaultというキーを付けなきゃいけないの冗長なのでなんとかする(ただ型定義が面倒になりそう) // あと、現行の定義の仕方なら「whereが何であるかに関わらずキー名の重複不可」という制約を付けられるメリットもあるからそのメリットを引き継ぐ方法も考えないといけない export const defaultStore = markRaw(new Storage('base', { - tutorial: { + accountSetupWizard: { + where: 'account', + default: 0, + }, + timelineTutorial: { where: 'account', default: 0, }, @@ -164,7 +168,7 @@ export const defaultStore = markRaw(new Storage('base', { }, animation: { where: 'device', - default: !matchMedia('(prefers-reduced-motion)').matches, + default: !window.matchMedia('(prefers-reduced-motion)').matches, }, animatedMfm: { where: 'device', @@ -182,9 +186,13 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + enableDataSaverMode: { + where: 'device', + default: false, + }, disableShowingAnimatedImages: { where: 'device', - default: matchMedia('(prefers-reduced-motion)').matches, + default: window.matchMedia('(prefers-reduced-motion)').matches, }, emojiStyle: { where: 'device', @@ -306,6 +314,22 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + mediaListWithOneImageAppearance: { + where: 'device', + default: 'expand' as 'expand' | '16_9' | '1_1' | '2_3', + }, + notificationPosition: { + where: 'device', + default: 'rightBottom' as 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom', + }, + notificationStackAxis: { + where: 'device', + default: 'horizontal' as 'vertical' | 'horizontal', + }, + enableCondensedLineForAcct: { + where: 'device', + default: true, + }, })); // TODO: 他のタブと永続化されたstateを同期 diff --git a/packages/frontend/src/themes/d-botanical.json5 b/packages/frontend/src/themes/d-botanical.json5 index c03b95e2d7..33cf7aa817 100644 --- a/packages/frontend/src/themes/d-botanical.json5 +++ b/packages/frontend/src/themes/d-botanical.json5 @@ -13,8 +13,7 @@ fgHighlighted: '#fff', divider: 'rgba(255, 255, 255, 0.14)', panel: 'rgb(47, 47, 44)', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', header: ':alpha<0.7<@panel', navBg: '#363636', renote: '@accent', diff --git a/packages/frontend/src/themes/d-dark.json5 b/packages/frontend/src/themes/d-dark.json5 index d24ce4df69..63144e88ea 100644 --- a/packages/frontend/src/themes/d-dark.json5 +++ b/packages/frontend/src/themes/d-dark.json5 @@ -13,8 +13,7 @@ fgHighlighted: '#fff', divider: 'rgba(255, 255, 255, 0.14)', panel: '#2d2d2d', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', header: ':alpha<0.7<@panel', navBg: '#363636', renote: '@accent', diff --git a/packages/frontend/src/themes/d-future.json5 b/packages/frontend/src/themes/d-future.json5 index b6fa1ab0c1..0962a12411 100644 --- a/packages/frontend/src/themes/d-future.json5 +++ b/packages/frontend/src/themes/d-future.json5 @@ -14,8 +14,7 @@ fgOnAccent: '#000', divider: 'rgba(255, 255, 255, 0.1)', panel: '#18181c', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', renote: '@accent', mention: '#f2c97d', mentionMe: '@accent', diff --git a/packages/frontend/src/themes/d-green-lime.json5 b/packages/frontend/src/themes/d-green-lime.json5 index a6983b9ac2..9522f534a4 100644 --- a/packages/frontend/src/themes/d-green-lime.json5 +++ b/packages/frontend/src/themes/d-green-lime.json5 @@ -14,8 +14,7 @@ fgOnAccent: '#192320', divider: '#e7fffb24', panel: '#192320', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', popup: '#293330', renote: '@accent', mentionMe: '#ffaa00', diff --git a/packages/frontend/src/themes/d-green-orange.json5 b/packages/frontend/src/themes/d-green-orange.json5 index 62adc39e29..e542782c66 100644 --- a/packages/frontend/src/themes/d-green-orange.json5 +++ b/packages/frontend/src/themes/d-green-orange.json5 @@ -14,8 +14,7 @@ fgOnAccent: '#192320', divider: '#e7fffb24', panel: '#192320', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', + panelHeaderDivider: 'rgba(0, 0, 0, 0)', popup: '#293330', renote: '@accent', mentionMe: '#b4e900', diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts index eae4f0091c..53042a4ce7 100644 --- a/packages/frontend/src/ui/_common_/common.ts +++ b/packages/frontend/src/ui/_common_/common.ts @@ -76,7 +76,7 @@ export function openInstanceMenu(ev: MouseEvent) { } : undefined], }, null, { text: i18n.ts.help, - icon: 'ti ti-question-circle', + icon: 'ti ti-help-circle', action: () => { window.open('https://misskey-hub.net/help.html', '_blank'); }, diff --git a/packages/frontend/src/ui/_common_/common.vue b/packages/frontend/src/ui/_common_/common.vue index 5a32c076a4..71a4285e9d 100644 --- a/packages/frontend/src/ui/_common_/common.vue +++ b/packages/frontend/src/ui/_common_/common.vue @@ -10,14 +10,16 @@ <XUpload v-if="uploads.length > 0"/> <TransitionGroup - tag="div" :class="$style.notifications" + tag="div" :class="[$style.notifications, $style[`notificationsPosition-${defaultStore.state.notificationPosition}`], $style[`notificationsStackAxis-${defaultStore.state.notificationStackAxis}`]]" :move-class="defaultStore.state.animation ? $style.transition_notification_move : ''" :enter-active-class="defaultStore.state.animation ? $style.transition_notification_enterActive : ''" :leave-active-class="defaultStore.state.animation ? $style.transition_notification_leaveActive : ''" :enter-from-class="defaultStore.state.animation ? $style.transition_notification_enterFrom : ''" :leave-to-class="defaultStore.state.animation ? $style.transition_notification_leaveTo : ''" > - <XNotification v-for="notification in notifications" :key="notification.id" :notification="notification" :class="$style.notification"/> + <div v-for="notification in notifications" :key="notification.id" :class="$style.notification"> + <XNotification :notification="notification"/> + </div> </TransitionGroup> <XStreamIndicator/> @@ -30,7 +32,7 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; import * as misskey from 'misskey-js'; import { swInject } from './sw-inject'; import XNotification from './notification.vue'; @@ -85,7 +87,10 @@ if ($i) { .transition_notification_leaveActive { transition: opacity 0.3s, transform 0.3s !important; } -.transition_notification_enterFrom, +.transition_notification_enterFrom { + opacity: 0; + transform: translateX(250px); +} .transition_notification_leaveTo { opacity: 0; transform: translateX(-250px); @@ -94,35 +99,90 @@ if ($i) { .notifications { position: fixed; z-index: 3900000; - left: 0; - width: 250px; - top: 32px; - padding: 0 32px; + padding: 0 var(--margin); pointer-events: none; - container-type: inline-size; -} + display: flex; -.notification { - & + .notification { - margin-top: 8px; + &.notificationsPosition-leftTop { + top: var(--margin); + left: 0; + } + + &.notificationsPosition-rightTop { + top: var(--margin); + right: 0; + } + + &.notificationsPosition-leftBottom { + bottom: calc(var(--minBottomSpacing) + var(--margin)); + left: 0; } -} -@media (max-width: 500px) { - .notifications { - top: initial; + &.notificationsPosition-rightBottom { bottom: calc(var(--minBottomSpacing) + var(--margin)); - padding: 0 var(--margin); - display: flex; - flex-direction: column-reverse; + right: 0; } - .notification { - & + .notification { - margin-top: 0; - margin-bottom: 8px; + &.notificationsStackAxis-vertical { + width: 250px; + + &.notificationsPosition-leftTop, + &.notificationsPosition-rightTop { + flex-direction: column; + + .notification { + & + .notification { + margin-top: 8px; + } + } + } + + &.notificationsPosition-leftBottom, + &.notificationsPosition-rightBottom { + flex-direction: column-reverse; + + .notification { + & + .notification { + margin-bottom: 8px; + } + } } } + + &.notificationsStackAxis-horizontal { + width: 100%; + + &.notificationsPosition-leftTop, + &.notificationsPosition-leftBottom { + flex-direction: row; + + .notification { + & + .notification { + margin-left: 8px; + } + } + } + + &.notificationsPosition-rightTop, + &.notificationsPosition-rightBottom { + flex-direction: row-reverse; + + .notification { + & + .notification { + margin-right: 8px; + } + } + } + + .notification { + width: 250px; + flex-shrink: 0; + } + } +} + +.notification { + container-type: inline-size; } </style> diff --git a/packages/frontend/src/ui/deck/channel-column.vue b/packages/frontend/src/ui/deck/channel-column.vue index ff0cba33ac..9605d1b22e 100644 --- a/packages/frontend/src/ui/deck/channel-column.vue +++ b/packages/frontend/src/ui/deck/channel-column.vue @@ -40,7 +40,7 @@ if (props.column.channelId == null) { } async function setChannel() { - const channels = await os.api('channels/followed', { + const channels = await os.api('channels/my-favorites', { limit: 100, }); const { canceled, result: channel } = await os.select({ diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue index e895847bd9..402bbe0352 100644 --- a/packages/frontend/src/ui/deck/column.vue +++ b/packages/frontend/src/ui/deck/column.vue @@ -22,7 +22,7 @@ <span :class="$style.title"><slot name="header"></slot></span> <button v-tooltip="i18n.ts.settings" :class="$style.menu" class="_button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button> </header> - <div v-show="active" ref="body" v-container :class="$style.body"> + <div v-show="active" ref="body" :class="$style.body"> <slot></slot> </div> </section> @@ -243,7 +243,7 @@ function onDrop(ev) { <style lang="scss" module> .root { --root-margin: 10px; - --deckColumnHeaderHeight: 40px; + --deckColumnHeaderHeight: 38px; height: 100%; overflow: clip; @@ -318,10 +318,7 @@ function onDrop(ev) { background: var(--panelHeaderBg); box-shadow: 0 1px 0 0 var(--panelHeaderDivider); cursor: pointer; - - &, * { - user-select: none; - } + user-select: none; } .title { @@ -365,7 +362,7 @@ function onDrop(ev) { overflow-x: clip; -webkit-overflow-scrolling: touch; box-sizing: border-box; - container-type: inline-size; + container-type: size; background-color: var(--bg); } </style> diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index 2462967515..27d0c26ac4 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -2,7 +2,7 @@ <div :class="[$style.root, { [$style.withWallpaper]: wallpaper }]"> <XSidebar v-if="!isMobile" :class="$style.sidebar"/> - <MkStickyContainer v-container :class="$style.contents"> + <MkStickyContainer :class="$style.contents"> <template #header><XStatusBars :class="$style.statusbars"/></template> <main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> <div :class="$style.content" style="container-type: inline-size;"> diff --git a/packages/frontend/src/ui/universal.widgets.vue b/packages/frontend/src/ui/universal.widgets.vue index d11649c603..3e0c38bb83 100644 --- a/packages/frontend/src/ui/universal.widgets.vue +++ b/packages/frontend/src/ui/universal.widgets.vue @@ -3,7 +3,7 @@ <XWidgets :class="$style.widgets" :edit="editMode" :widgets="widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="editMode = false"/> <button v-if="editMode" class="_textButton" style="font-size: 0.9em;" @click="editMode = false"><i class="ti ti-check"></i> {{ i18n.ts.editWidgetsExit }}</button> - <button v-else class="_textButton mk-widget-edit" :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> + <button v-else class="_textButton" data-cy-widget-edit :class="$style.edit" style="font-size: 0.9em;" @click="editMode = true"><i class="ti ti-pencil"></i> {{ i18n.ts.editWidgets }}</button> </div> </template> diff --git a/packages/frontend/src/ui/visitor.vue b/packages/frontend/src/ui/visitor.vue index 6c96440ebd..623abbda39 100644 --- a/packages/frontend/src/ui/visitor.vue +++ b/packages/frontend/src/ui/visitor.vue @@ -1,19 +1,286 @@ <template> -<DesignB/> +<div class="mk-app"> + <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> + + <div v-if="!narrow && !root" class="side"> + <div class="banner" :style="{ backgroundImage: instance.backgroundImageUrl ? `url(${ instance.backgroundImageUrl })` : 'none' }"></div> + <div class="dashboard"> + <MkVisitorDashboard/> + </div> + </div> + + <div class="main"> + <div v-if="!root" class="header"> + <div v-if="narrow === false" class="wide"> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i> {{ i18n.ts.home }}</MkA> + <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i> {{ i18n.ts.timeline }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i> {{ i18n.ts.explore }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i> {{ i18n.ts.channel }}</MkA> + </div> + <div v-else-if="narrow === true" class="narrow"> + <button class="menu _button" @click="showMenu = true"> + <i class="ti ti-menu-2 icon"></i> + </button> + </div> + </div> + <div class="contents"> + <main v-if="!root" style="container-type: inline-size;"> + <RouterView/> + </main> + <main v-else> + <RouterView/> + </main> + </div> + </div> + + <Transition :name="'tray-back'"> + <div + v-if="showMenu" + class="menu-back _modalBg" + @click="showMenu = false" + @touchstart.passive="showMenu = false" + ></div> + </Transition> + + <Transition :name="'tray'"> + <div v-if="showMenu" class="menu"> + <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> + <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> + <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> + <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> + <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> + <div class="divider"></div> + <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> + <MkA to="/play" class="link" active-class="active"><i class="ti ti-player-play icon"></i>Play</MkA> + <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA> + <div class="action"> + <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> + <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> + </div> + </div> + </Transition> +</div> <XCommon/> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; -//import DesignA from './visitor/a.vue'; -import DesignB from './visitor/b.vue'; +<script lang="ts" setup> +import { ComputedRef, onMounted, provide } from 'vue'; import XCommon from './_common_/common.vue'; +import { host, instanceName } from '@/config'; +import * as os from '@/os'; +import { instance } from '@/instance'; +import XSigninDialog from '@/components/MkSigninDialog.vue'; +import XSignupDialog from '@/components/MkSignupDialog.vue'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import MkVisitorDashboard from '@/components/MkVisitorDashboard.vue'; + +const DESKTOP_THRESHOLD = 1100; + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); + +const announcements = { + endpoint: 'announcements', + limit: 10, +}; + +const isTimelineAvailable = $ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable); + +let showMenu = $ref(false); +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); +let narrow = $ref(window.innerWidth < 1280); +let meta = $ref(); -export default defineComponent({ - components: { - XCommon, - //DesignA, - DesignB, - }, +const keymap = $computed(() => { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': () => { + mainRouter.push('/search'); + }, + }; +}); + +const root = $computed(() => mainRouter.currentRoute.value.name === 'index'); + +os.api('meta', { detail: true }).then(res => { + meta = res; +}); + +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} + +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} + +onMounted(() => { + if (!isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true; + }, { passive: true }); + } +}); + +defineExpose({ + showMenu: $$(showMenu), }); </script> + +<style> +.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} +</style> + +<style lang="scss" scoped> +.tray-enter-active, +.tray-leave-active { + opacity: 1; + transform: translateX(0); + transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-enter-from, +.tray-leave-active { + opacity: 0; + transform: translateX(-240px); +} + +.tray-back-enter-active, +.tray-back-leave-active { + opacity: 1; + transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); +} +.tray-back-enter-from, +.tray-back-leave-active { + opacity: 0; +} + +.mk-app { + display: flex; + min-height: 100vh; + + > .side { + position: sticky; + top: 0; + left: 0; + width: 500px; + height: 100vh; + background: var(--accent); + + > .banner { + position: absolute; + top: 0; + left: 0; + width: 100%; + aspect-ratio: 1.5; + background-position: center; + background-size: cover; + -webkit-mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); + mask-image: linear-gradient(rgba(0, 0, 0, 1.0), transparent); + } + + > .dashboard { + position: relative; + padding: 32px; + box-sizing: border-box; + max-height: 100%; + overflow: auto; + } + } + + > .main { + flex: 1; + min-width: 0; + + > .header { + background: var(--panel); + + > .wide { + line-height: 50px; + padding: 0 16px; + + > .link { + padding: 0 16px; + } + } + + > .narrow { + > .menu { + padding: 16px; + } + } + } + } + + > .menu-back { + position: fixed; + z-index: 1001; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + } + + > .menu { + position: fixed; + z-index: 1001; + top: 0; + left: 0; + width: 240px; + height: 100vh; + background: var(--panel); + + > .link { + display: block; + padding: 16px; + + > .icon { + margin-right: 1em; + } + } + + > .divider { + margin: 8px auto; + width: calc(100% - 32px); + border-top: solid 0.5px var(--divider); + } + + > .action { + padding: 16px; + + > button { + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + + &._button { + background: var(--panel); + } + + &:first-child { + margin-bottom: 16px; + } + } + } + } +} +</style> diff --git a/packages/frontend/src/ui/visitor/a.vue b/packages/frontend/src/ui/visitor/a.vue deleted file mode 100644 index 4761036075..0000000000 --- a/packages/frontend/src/ui/visitor/a.vue +++ /dev/null @@ -1,263 +0,0 @@ -<template> -<div class="mk-app"> - <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> - <div> - <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> - <div v-if="meta" class="about"> - <!-- eslint-disable-next-line vue/no-v-html --> - <div class="desc" v-html="meta.description || i18n.ts.introMisskey"></div> - </div> - <div class="action"> - <button class="_button primary" @click="signup()">{{ i18n.ts.signup }}</button> - <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> - </div> - </div> - </div> - <div v-else class="banner-mini" :style="{ backgroundImage: `url(${ instance.bannerUrl })` }"> - <div> - <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> - </div> - </div> - - <div class="main"> - <div ref="contents" class="contents" :class="{ wallpaper }"> - <header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header"> - <XHeader :info="pageInfo"/> - </header> - <main ref="main" style="container-type: inline-size;"> - <RouterView/> - </main> - <div class="powered-by"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> - </div> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XHeader from './header.vue'; -import { host, instanceName } from '@/config'; -import * as os from '@/os'; -import MkButton from '@/components/MkButton.vue'; -import { defaultStore, ColdDeviceStorage } from '@/store'; -import { mainRouter } from '@/router'; -import { instance } from '@/instance'; -import { i18n } from '@/i18n'; - -const DESKTOP_THRESHOLD = 1100; - -export default defineComponent({ - components: { - XHeader, - MkButton, - }, - - data() { - return { - host, - instanceName, - pageInfo: null, - meta: null, - narrow: window.innerWidth < 1280, - announcements: { - endpoint: 'announcements', - limit: 10, - }, - mainRouter, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - defaultStore, - instance, - i18n, - }; - }, - - computed: { - keymap(): any { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - this.defaultStore.set('darkMode', !this.defaultStore.state.darkMode); - }, - 's': () => { - mainRouter.push('/search'); - }, - 'h|/': this.help, - }; - }, - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, - - mounted() { - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - }, - - methods: { - // @ThatOneCalculator: Are these methods even used? - // I can't find references to them anywhere else in the code... - - // setParallax(el) { - // new simpleParallax(el); - // }, - - changePage(page) { - if (page == null) return; - // eslint-disable-next-line no-undef - if (page[symbols.PAGE_INFO]) { - // eslint-disable-next-line no-undef - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank'); - }, - }, -}); -</script> - -<style lang="scss" scoped> -.mk-app { - min-height: 100vh; - - > .banner { - position: relative; - width: 100%; - text-align: center; - background-position: center; - background-size: cover; - - > div { - height: 100%; - background: rgba(0, 0, 0, 0.3); - - * { - color: #fff; - } - - > h1 { - margin: 0; - padding: 96px 32px 0 32px; - text-shadow: 0 0 8px black; - - > .logo { - vertical-align: bottom; - max-height: 150px; - } - } - - > .about { - padding: 32px; - max-width: 580px; - margin: 0 auto; - box-sizing: border-box; - text-shadow: 0 0 8px black; - } - - > .action { - padding-bottom: 64px; - - > button { - display: inline-block; - padding: 10px 20px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; - background: var(--panel); - color: var(--fg); - - &.primary { - background: var(--accent); - color: #fff; - } - - &:first-child { - margin-right: 16px; - } - } - } - } - } - - > .banner-mini { - position: relative; - width: 100%; - text-align: center; - background-position: center; - background-size: cover; - - > div { - position: relative; - z-index: 1; - height: 100%; - background: rgba(0, 0, 0, 0.3); - - * { - color: #fff !important; - } - - > header { - - } - - > h1 { - margin: 0; - padding: 32px; - text-shadow: 0 0 8px black; - - > .logo { - vertical-align: bottom; - max-height: 100px; - } - } - } - } - - > .main { - > .contents { - position: relative; - z-index: 1; - - > .header { - position: sticky; - top: 0; - left: 0; - z-index: 1000; - } - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid var(--divider); - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } - } -} -</style> - -<style lang="scss"> -</style> diff --git a/packages/frontend/src/ui/visitor/b.vue b/packages/frontend/src/ui/visitor/b.vue deleted file mode 100644 index 5287a670c5..0000000000 --- a/packages/frontend/src/ui/visitor/b.vue +++ /dev/null @@ -1,266 +0,0 @@ -<template> -<div class="mk-app"> - <a v-if="root" href="https://github.com/misskey-dev/misskey" target="_blank" class="github-corner" aria-label="View source on GitHub"><svg width="80" height="80" viewBox="0 0 250 250" style="fill:var(--panel); color:var(--fg); position: fixed; z-index: 10; top: 0; border: 0; right: 0;" aria-hidden="true"><path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path></svg></a> - - <div v-if="!narrow && !root" class="side"> - <XKanban class="kanban" full/> - </div> - - <div class="main"> - <XKanban v-if="narrow && !root" class="banner" :powered-by="root"/> - - <div class="contents"> - <XHeader v-if="!root" class="header"/> - <main v-if="!root" style="container-type: inline-size;"> - <RouterView/> - </main> - <main v-else> - <RouterView/> - </main> - <div v-if="!root" class="powered-by"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> - </div> - </div> - </div> - - <Transition :name="'tray-back'"> - <div - v-if="showMenu" - class="menu-back _modalBg" - @click="showMenu = false" - @touchstart.passive="showMenu = false" - ></div> - </Transition> - - <Transition :name="'tray'"> - <div v-if="showMenu" class="menu"> - <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> - <MkA to="/announcements" class="link" active-class="active"><i class="ti ti-speakerphone icon"></i>{{ i18n.ts.announcements }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> - <div class="divider"></div> - <MkA to="/pages" class="link" active-class="active"><i class="ti ti-news icon"></i>{{ i18n.ts.pages }}</MkA> - <MkA to="/play" class="link" active-class="active"><i class="ti ti-player-play icon"></i>Play</MkA> - <MkA to="/gallery" class="link" active-class="active"><i class="ti ti-icons icon"></i>{{ i18n.ts.gallery }}</MkA> - <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> - <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> - </div> - </div> - </Transition> -</div> -</template> - -<script lang="ts" setup> -import { ComputedRef, onMounted, provide } from 'vue'; -import XHeader from './header.vue'; -import XKanban from './kanban.vue'; -import { host, instanceName } from '@/config'; -import * as os from '@/os'; -import { instance } from '@/instance'; -import XSigninDialog from '@/components/MkSigninDialog.vue'; -import XSignupDialog from '@/components/MkSignupDialog.vue'; -import { ColdDeviceStorage, defaultStore } from '@/store'; -import { mainRouter } from '@/router'; -import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata'; -import { i18n } from '@/i18n'; - -const DESKTOP_THRESHOLD = 1100; - -let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); - -provide('router', mainRouter); -provideMetadataReceiver((info) => { - pageMetadata = info; - if (pageMetadata.value) { - document.title = `${pageMetadata.value.title} | ${instanceName}`; - } -}); - -const announcements = { - endpoint: 'announcements', - limit: 10, -}; - -const isTimelineAvailable = $ref(instance.policies?.ltlAvailable || instance.policies?.gtlAvailable); - -let showMenu = $ref(false); -let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); -let narrow = $ref(window.innerWidth < 1280); -let meta = $ref(); - -const keymap = $computed(() => { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 's': () => { - mainRouter.push('/search'); - }, - }; -}); - -const root = $computed(() => mainRouter.currentRoute.value.name === 'index'); - -os.api('meta', { detail: true }).then(res => { - meta = res; -}); - -function signin() { - os.popup(XSigninDialog, { - autoSet: true, - }, {}, 'closed'); -} - -function signup() { - os.popup(XSignupDialog, { - autoSet: true, - }, {}, 'closed'); -} - -onMounted(() => { - if (!isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true; - }, { passive: true }); - } -}); - -defineExpose({ - showMenu: $$(showMenu), -}); -</script> - -<style> -.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}} -</style> - -<style lang="scss" scoped> -.tray-enter-active, -.tray-leave-active { - opacity: 1; - transform: translateX(0); - transition: transform 300ms cubic-bezier(0.23, 1, 0.32, 1), opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-enter-from, -.tray-leave-active { - opacity: 0; - transform: translateX(-240px); -} - -.tray-back-enter-active, -.tray-back-leave-active { - opacity: 1; - transition: opacity 300ms cubic-bezier(0.23, 1, 0.32, 1); -} -.tray-back-enter-from, -.tray-back-leave-active { - opacity: 0; -} - -.mk-app { - display: flex; - min-height: 100vh; - background-position: center; - background-size: cover; - background-attachment: fixed; - - > .side { - width: 500px; - height: 100vh; - - > .kanban { - position: fixed; - top: 0; - left: 0; - width: 500px; - height: 100vh; - overflow: auto; - } - } - - > .main { - flex: 1; - min-width: 0; - - > .banner { - } - - > .contents { - position: relative; - z-index: 1; - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid var(--divider); - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } - } - - > .menu-back { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - } - - > .menu { - position: fixed; - z-index: 1001; - top: 0; - left: 0; - width: 240px; - height: 100vh; - background: var(--panel); - - > .link { - display: block; - padding: 16px; - - > .icon { - margin-right: 1em; - } - } - - > .divider { - margin: 8px auto; - width: calc(100% - 32px); - border-top: solid 0.5px var(--divider); - } - - > .action { - padding: 16px; - - > button { - display: block; - width: 100%; - padding: 10px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; - - &._button { - background: var(--panel); - } - - &:first-child { - margin-bottom: 16px; - } - } - } - } -} -</style> diff --git a/packages/frontend/src/ui/visitor/header.vue b/packages/frontend/src/ui/visitor/header.vue deleted file mode 100644 index 7de81f6431..0000000000 --- a/packages/frontend/src/ui/visitor/header.vue +++ /dev/null @@ -1,211 +0,0 @@ -<template> -<div class="sqxihjet"> - <div v-if="narrow === false" class="wide"> - <div class="content"> - <MkA to="/" class="link" active-class="active"><i class="ti ti-home icon"></i>{{ i18n.ts.home }}</MkA> - <MkA v-if="isTimelineAvailable" to="/timeline" class="link" active-class="active"><i class="ti ti-message icon"></i>{{ i18n.ts.timeline }}</MkA> - <MkA to="/explore" class="link" active-class="active"><i class="ti ti-hash icon"></i>{{ i18n.ts.explore }}</MkA> - <MkA to="/channels" class="link" active-class="active"><i class="ti ti-device-tv icon"></i>{{ i18n.ts.channel }}</MkA> - <div class="right"> - <button class="_button search" @click="search()"><i class="ti ti-search icon"></i><span>{{ i18n.ts.search }}</span></button> - <button class="_buttonPrimary signup" @click="signup()">{{ i18n.ts.signup }}</button> - <button class="_button login" @click="signin()">{{ i18n.ts.login }}</button> - </div> - </div> - </div> - <div v-else-if="narrow === true" class="narrow"> - <button class="menu _button" @click="$parent.showMenu = true"> - <i class="ti ti-menu-2 icon"></i> - </button> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import XSigninDialog from '@/components/MkSigninDialog.vue'; -import XSignupDialog from '@/components/MkSignupDialog.vue'; -import * as os from '@/os'; -import { instance } from '@/instance'; -import { mainRouter } from '@/router'; -import { i18n } from '@/i18n'; - -export default defineComponent({ - data() { - return { - narrow: null, - showMenu: false, - isTimelineAvailable: instance.policies.ltlAvailable || instance.policies.gtlAvailable, - i18n, - }; - }, - - mounted() { - this.narrow = this.$el.clientWidth < 1300; - }, - - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - search() { - mainRouter.push('/search'); - }, - }, -}); -</script> - -<style lang="scss" scoped> -.sqxihjet { - $height: 50px; - position: sticky; - width: 50px; - top: 0; - left: 0; - z-index: 1000; - line-height: $height; - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--X16); - - > .wide { - > .content { - max-width: 1400px; - margin: 0 auto; - display: flex; - align-items: center; - - > .link { - $line: 3px; - display: inline-block; - padding: 0 16px; - line-height: $height - ($line * 2); - border-top: solid $line transparent; - border-bottom: solid $line transparent; - - > .icon { - margin-right: 0.5em; - } - - &.page { - border-bottom-color: var(--accent); - } - } - - > .page { - > .title { - display: inline-block; - vertical-align: bottom; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - - > .icon + .text { - margin-left: 8px; - } - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: middle; - margin-right: 8px; - pointer-events: none; - } - - &._button { - &:hover { - color: var(--fgHighlighted); - } - } - - &.selected { - box-shadow: 0 -2px 0 0 var(--accent) inset; - color: var(--fgHighlighted); - } - } - - > .action { - padding: 0 0 0 16px; - } - } - - > .right { - margin-left: auto; - - > .search { - background: var(--bg); - border-radius: 999px; - width: 230px; - line-height: $height - 20px; - margin-right: 16px; - text-align: left; - - > * { - opacity: 0.7; - } - - > .icon { - padding: 0 16px; - } - } - - > .signup { - border-radius: 999px; - padding: 0 24px; - line-height: $height - 20px; - } - - > .login { - padding: 0 16px; - } - } - } - } - - > .narrow { - display: flex; - - > .menu, - > .action { - width: $height; - height: $height; - font-size: 20px; - } - - > .title { - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - position: relative; - text-align: center; - - > .icon + .text { - margin-left: 8px; - } - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: middle; - margin-right: 8px; - pointer-events: none; - } - } - } -} -</style> diff --git a/packages/frontend/src/ui/visitor/kanban.vue b/packages/frontend/src/ui/visitor/kanban.vue deleted file mode 100644 index ce7fcfe944..0000000000 --- a/packages/frontend/src/ui/visitor/kanban.vue +++ /dev/null @@ -1,261 +0,0 @@ -<!-- eslint-disable vue/no-v-html --> -<template> -<div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ instance.backgroundImageUrl })` }"> - <div class="back" :class="{ transparent }"></div> - <div class="contents"> - <div class="wrapper"> - <h1 v-if="meta" :class="{ full }"> - <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA> - </h1> - <template v-if="full"> - <div v-if="meta" class="about"> - <div class="desc" v-html="meta.description || i18n.ts.introMisskey"></div> - </div> - <div class="action"> - <button class="_buttonPrimary" @click="signup()">{{ i18n.ts.signup }}</button> - <button class="_button" @click="signin()">{{ i18n.ts.login }}</button> - </div> - <div class="announcements panel"> - <header>{{ i18n.ts.announcements }}</header> - <MkPagination v-slot="{items}" :pagination="announcements" class="list"> - <section v-for="announcement in items" :key="announcement.id" class="item"> - <div class="title">{{ announcement.title }}</div> - <div class="content"> - <Mfm :text="announcement.text"/> - <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/> - </div> - </section> - </MkPagination> - </div> - <div v-if="poweredBy" class="powered-by"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/misskey-dev/misskey" target="_blank">Misskey</a></small> - </div> - </template> - </div> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { host, instanceName } from '@/config'; -import * as os from '@/os'; -import MkPagination from '@/components/MkPagination.vue'; -import XSigninDialog from '@/components/MkSigninDialog.vue'; -import XSignupDialog from '@/components/MkSignupDialog.vue'; -import MkButton from '@/components/MkButton.vue'; -import { instance } from '@/instance'; -import { i18n } from '@/i18n'; - -export default defineComponent({ - components: { - MkPagination, - MkButton, - }, - - props: { - full: { - type: Boolean, - required: false, - default: false, - }, - transparent: { - type: Boolean, - required: false, - default: false, - }, - poweredBy: { - type: Boolean, - required: false, - default: false, - }, - }, - - data() { - return { - host, - instanceName, - pageInfo: null, - meta: null, - narrow: window.innerWidth < 1280, - announcements: { - endpoint: 'announcements', - limit: 10, - }, - instance, - i18n, - }; - }, - - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, - - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true, - }, {}, 'closed'); - }, - - signup() { - os.popup(XSignupDialog, { - autoSet: true, - }, {}, 'closed'); - }, - }, -}); -</script> - -<style lang="scss" scoped> -.rwqkcmrc { - position: relative; - text-align: center; - background-position: center; - background-size: cover; - // TODO: パララックスにしたい - - > .back { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.3); - - &.transparent { - -webkit-backdrop-filter: var(--blur, blur(12px)); - backdrop-filter: var(--blur, blur(12px)); - } - } - - > .contents { - position: relative; - z-index: 1; - height: inherit; - overflow: auto; - - > .wrapper { - max-width: 380px; - padding: 0 16px; - box-sizing: border-box; - margin: 0 auto; - - > .panel { - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - background: rgba(0, 0, 0, 0.5); - border-radius: var(--radius); - - &, * { - color: #fff !important; - } - } - - > h1 { - display: block; - margin: 0; - padding: 32px 0 32px 0; - color: #fff; - - &.full { - padding: 64px 0 0 0; - - > .link { - > ::v-deep(.logo) { - max-height: 130px; - } - } - } - - > .link { - display: block; - - > ::v-deep(.logo) { - vertical-align: bottom; - max-height: 100px; - } - } - } - - > .about { - display: block; - margin: 24px 0; - text-align: center; - box-sizing: border-box; - text-shadow: 0 0 8px black; - color: #fff; - } - - > .action { - > button { - display: block; - width: 100%; - padding: 10px; - box-sizing: border-box; - text-align: center; - border-radius: 999px; - - &._button { - background: var(--panel); - } - - &:first-child { - margin-bottom: 16px; - } - } - } - - > .announcements { - margin: 32px 0; - text-align: left; - - > header { - padding: 12px 16px; - border-bottom: solid 1px rgba(255, 255, 255, 0.5); - } - - > .list { - max-height: 300px; - overflow: auto; - - > .item { - padding: 12px 16px; - - & + .item { - border-top: solid 1px rgba(255, 255, 255, 0.5); - } - - > .title { - font-weight: bold; - } - - > .content { - > img { - max-width: 100%; - } - } - } - } - } - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid rgba(255, 255, 255, 0.5); - color: #fff; - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } - } -} -</style> |