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