merge: misskey 2024.7 (!583)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/583 Approved-by: Marie <github@yuugi.dev> Approved-by: Julia Johannesen <julia@insertdomain.name>
This commit is contained in:
commit
f5560783ea
598 changed files with 23943 additions and 9812 deletions
|
|
@ -153,7 +153,7 @@ onMounted(() => {
|
|||
background: linear-gradient(0deg, #ffee20, #eb7018);
|
||||
}
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
@ -173,7 +173,7 @@ onMounted(() => {
|
|||
background: linear-gradient(0deg, #e1e1e1, #7c7c7c);
|
||||
}
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAntennaEditor from './MkAntennaEditor.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAntennaEditor,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
created: action('created'),
|
||||
updated: action('updated'),
|
||||
deleted: action('deleted'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAntennaEditor v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/antennas/create', async ({ request }) => {
|
||||
action('POST /api/antennas/create')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('/api/antennas/update', async ({ request }) => {
|
||||
action('POST /api/antennas/update')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('/api/antennas/delete', async ({ request }) => {
|
||||
action('POST /api/antennas/delete')(await request.json());
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAntennaEditor>;
|
||||
175
packages/frontend/src/components/MkAntennaEditor.vue
Normal file
175
packages/frontend/src/components/MkAntennaEditor.vue
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<div>
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="name">
|
||||
<template #label>{{ i18n.ts.name }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="src">
|
||||
<template #label>{{ i18n.ts.antennaSource }}</template>
|
||||
<option value="all">{{ i18n.ts._antennaSources.all }}</option>
|
||||
<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
|
||||
<option value="users">{{ i18n.ts._antennaSources.users }}</option>
|
||||
<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
|
||||
<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
|
||||
</MkSelect>
|
||||
<MkSelect v-if="src === 'list'" v-model="userListId">
|
||||
<template #label>{{ i18n.ts.userList }}</template>
|
||||
<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
|
||||
</MkSelect>
|
||||
<MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
|
||||
<template #label>{{ i18n.ts.users }}</template>
|
||||
<template #caption>{{ i18n.ts.antennaUsersDescription }} <button class="_textButton" @click="addUser">{{ i18n.ts.addUser }}</button></template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-model="excludeBots">{{ i18n.ts.antennaExcludeBots }}</MkSwitch>
|
||||
<MkSwitch v-model="withReplies">{{ i18n.ts.withReplies }}</MkSwitch>
|
||||
<MkTextarea v-model="keywords">
|
||||
<template #label>{{ i18n.ts.antennaKeywords }}</template>
|
||||
<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
|
||||
</MkTextarea>
|
||||
<MkTextarea v-model="excludeKeywords">
|
||||
<template #label>{{ i18n.ts.antennaExcludeKeywords }}</template>
|
||||
<template #caption>{{ i18n.ts.antennaKeywordsDescription }}</template>
|
||||
</MkTextarea>
|
||||
<MkSwitch v-model="localOnly">{{ i18n.ts.localOnly }}</MkSwitch>
|
||||
<MkSwitch v-model="caseSensitive">{{ i18n.ts.caseSensitive }}</MkSwitch>
|
||||
<MkSwitch v-model="withFile">{{ i18n.ts.withFileAntenna }}</MkSwitch>
|
||||
</div>
|
||||
<div :class="$style.actions">
|
||||
<div class="_buttons">
|
||||
<MkButton inline primary @click="saveAntenna()"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton v-if="initialAntenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepMerge } from '@/scripts/merge.js';
|
||||
import type { DeepPartial } from '@/scripts/merge.js';
|
||||
|
||||
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
|
||||
id?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
antenna?: DeepPartial<PartialAllowedAntenna>;
|
||||
}>();
|
||||
|
||||
const initialAntenna = deepMerge<PartialAllowedAntenna>(props.antenna ?? {}, {
|
||||
name: '',
|
||||
src: 'all',
|
||||
userListId: null,
|
||||
users: [],
|
||||
keywords: [],
|
||||
excludeKeywords: [],
|
||||
excludeBots: false,
|
||||
withReplies: false,
|
||||
caseSensitive: false,
|
||||
localOnly: false,
|
||||
withFile: false,
|
||||
isActive: true,
|
||||
hasUnreadNote: false,
|
||||
notify: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
|
||||
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
|
||||
(ev: 'deleted'): void,
|
||||
}>();
|
||||
|
||||
const name = ref<string>(initialAntenna.name);
|
||||
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src);
|
||||
const userListId = ref<string | null>(initialAntenna.userListId);
|
||||
const users = ref<string>(initialAntenna.users.join('\n'));
|
||||
const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n'));
|
||||
const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
|
||||
const caseSensitive = ref<boolean>(initialAntenna.caseSensitive);
|
||||
const localOnly = ref<boolean>(initialAntenna.localOnly);
|
||||
const excludeBots = ref<boolean>(initialAntenna.excludeBots);
|
||||
const withReplies = ref<boolean>(initialAntenna.withReplies);
|
||||
const withFile = ref<boolean>(initialAntenna.withFile);
|
||||
const userLists = ref<Misskey.entities.UserList[] | null>(null);
|
||||
|
||||
watch(() => src.value, async () => {
|
||||
if (src.value === 'list' && userLists.value === null) {
|
||||
userLists.value = await misskeyApi('users/lists/list');
|
||||
}
|
||||
});
|
||||
|
||||
async function saveAntenna() {
|
||||
const antennaData = {
|
||||
name: name.value,
|
||||
src: src.value,
|
||||
userListId: userListId.value,
|
||||
excludeBots: excludeBots.value,
|
||||
withReplies: withReplies.value,
|
||||
withFile: withFile.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
localOnly: localOnly.value,
|
||||
users: users.value.trim().split('\n').map(x => x.trim()),
|
||||
keywords: keywords.value.trim().split('\n').map(x => x.trim().split(' ')),
|
||||
excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
|
||||
};
|
||||
|
||||
if (initialAntenna.id == null) {
|
||||
const res = await os.apiWithDialog('antennas/create', antennaData);
|
||||
emit('created', res);
|
||||
} else {
|
||||
const res = await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: initialAntenna.id });
|
||||
emit('updated', res);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAntenna() {
|
||||
if (initialAntenna.id == null) return;
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await misskeyApi('antennas/delete', {
|
||||
antennaId: initialAntenna.id,
|
||||
});
|
||||
|
||||
os.success();
|
||||
emit('deleted');
|
||||
}
|
||||
|
||||
function addUser() {
|
||||
os.selectUser({ includeSelf: true }).then(user => {
|
||||
users.value = users.value.trim();
|
||||
users.value += '\n@' + Misskey.acct.toString(user as any);
|
||||
users.value = users.value.trim();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.actions {
|
||||
margin-top: 16px;
|
||||
padding: 24px 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAntennaEditorDialog from './MkAntennaEditorDialog.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAntennaEditorDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
created: action('created'),
|
||||
updated: action('updated'),
|
||||
deleted: action('deleted'),
|
||||
closed: action('closed'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAntennaEditorDialog v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/antennas/create', async ({ request }) => {
|
||||
action('POST /api/antennas/create')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('/api/antennas/update', async ({ request }) => {
|
||||
action('POST /api/antennas/update')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('/api/antennas/delete', async ({ request }) => {
|
||||
action('POST /api/antennas/delete')(await request.json());
|
||||
return HttpResponse.json();
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkAntennaEditorDialog>;
|
||||
63
packages/frontend/src/components/MkAntennaEditorDialog.vue
Normal file
63
packages/frontend/src/components/MkAntennaEditorDialog.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:withOkButton="false"
|
||||
:width="500"
|
||||
:height="550"
|
||||
@close="close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ antenna == null ? i18n.ts.createAntenna : i18n.ts.editAntenna }}</template>
|
||||
<XAntennaEditor
|
||||
:antenna="antenna"
|
||||
@created="onAntennaCreated"
|
||||
@updated="onAntennaUpdated"
|
||||
@deleted="onAntennaDeleted"
|
||||
/>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import XAntennaEditor from '@/components/MkAntennaEditor.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
defineProps<{
|
||||
antenna?: Misskey.entities.Antenna;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
|
||||
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
|
||||
(ev: 'deleted'): void,
|
||||
(ev: 'closed'): void,
|
||||
}>();
|
||||
|
||||
const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
function onAntennaCreated(newAntenna: Misskey.entities.Antenna) {
|
||||
emit('created', newAntenna);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onAntennaUpdated(editedAntenna: Misskey.entities.Antenna) {
|
||||
emit('updated', editedAntenna);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onAntennaDeleted() {
|
||||
emit('deleted');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialog.value?.close();
|
||||
}
|
||||
</script>
|
||||
|
|
@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
|
|
@ -55,6 +56,7 @@ const props = defineProps<{
|
|||
asLike?: boolean;
|
||||
name?: string;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -248,7 +250,6 @@ function onMousedown(evt: MouseEvent): void {
|
|||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,6 @@ async function requestRender() {
|
|||
});
|
||||
} else if (props.provider === 'mcaptcha' && props.instanceUrl && props.sitekey) {
|
||||
const { default: Widget } = await import('@mcaptcha/vanilla-glue');
|
||||
// @ts-expect-error avoid typecheck error
|
||||
new Widget({
|
||||
siteKey: {
|
||||
instanceUrl: new URL(props.instanceUrl),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkChannelFollowButton from './MkChannelFollowButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkChannelFollowButton,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkChannelFollowButton v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
channel: channel(),
|
||||
full: true,
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
|
||||
await expect(buttonElement).toHaveTextContent(i18n.ts.follow);
|
||||
await userEvent.click(buttonElement);
|
||||
await sleep(1000);
|
||||
await expect(buttonElement).toHaveTextContent(i18n.ts.unfollow);
|
||||
await userEvent.click(buttonElement);
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/channels/follow', async ({ request }) => {
|
||||
action('POST /api/channels/follow')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.post('/api/channels/unfollow', async ({ request }) => {
|
||||
action('POST /api/channels/unfollow')(await request.json());
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkChannelFollowButton>;
|
||||
|
|
@ -26,17 +26,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
channel: Record<string, any>;
|
||||
channel: Misskey.entities.Channel;
|
||||
full?: boolean;
|
||||
}>(), {
|
||||
full: false,
|
||||
});
|
||||
|
||||
const isFollowing = ref<boolean>(props.channel.isFollowing);
|
||||
const isFollowing = ref(props.channel.isFollowing);
|
||||
const wait = ref(false);
|
||||
|
||||
async function onClick() {
|
||||
|
|
@ -86,17 +87,7 @@ async function onClick() {
|
|||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkChannelList from './MkChannelList.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkChannelList,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkChannelList v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
pagination: {
|
||||
endpoint: 'channels/search',
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
// NOTE: ロードが終わるまで待つ
|
||||
delay: 3000,
|
||||
},
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/channels/search', async ({ request, params }) => {
|
||||
action('POST /api/channels/search')(await request.json());
|
||||
return HttpResponse.json(params.untilId === 'lastchannel' ? [] : [
|
||||
channel(),
|
||||
channel('lastchannel', 'Last Channel', null),
|
||||
]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies StoryObj<typeof MkChannelList>;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
import MkChannelPreview from './MkChannelPreview.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkChannelPreview,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkChannelPreview v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
channel: channel(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies StoryObj<typeof MkChannelPreview>;
|
||||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div style="position: relative;">
|
||||
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1" @click="updateLastReadedAt">
|
||||
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" @click="updateLastReadedAt">
|
||||
<div class="banner" :style="bannerStyle">
|
||||
<div class="fade"></div>
|
||||
<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
|
||||
|
|
@ -80,6 +80,7 @@ const bannerStyle = computed(() => {
|
|||
<style lang="scss" scoped>
|
||||
.eftoefju {
|
||||
display: block;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
|
|
@ -87,6 +88,22 @@ const bannerStyle = computed(() => {
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 0 2px var(--focus);
|
||||
}
|
||||
}
|
||||
|
||||
> .banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
|
|
|||
80
packages/frontend/src/components/MkChart.stories.impl.ts
Normal file
80
packages/frontend/src/components/MkChart.stories.impl.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { getChartResolver } from '../../.storybook/charts.js';
|
||||
import MkChart from './MkChart.vue';
|
||||
|
||||
const Base = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkChart,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkChart v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
src: 'federation',
|
||||
span: 'hour',
|
||||
nowForChromatic: 1716263640000,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.get('/api/charts/federation', getChartResolver(
|
||||
['deliveredInstances', 'inboxInstances', 'stalled', 'sub', 'pub', 'pubsub', 'subActive', 'pubActive'],
|
||||
)),
|
||||
http.get('/api/charts/notes', getChartResolver(
|
||||
['local.total', 'remote.total'],
|
||||
{ accumulate: true },
|
||||
)),
|
||||
http.get('/api/charts/drive', getChartResolver(
|
||||
['local.incSize', 'local.decSize', 'remote.incSize', 'remote.decSize'],
|
||||
{ mulMap: { 'local.incSize': 1e7, 'local.decSize': 5e6, 'remote.incSize': 1e6, 'remote.decSize': 5e5 } },
|
||||
)),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkChart>;
|
||||
export const FederationChart = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
src: 'federation',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkChart>;
|
||||
export const NotesTotalChart = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
src: 'notes-total',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkChart>;
|
||||
export const DriveChart = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
src: 'drive',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkChart>;
|
||||
|
|
@ -19,8 +19,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
id-denylist violation when setting it. This is causing about 60+ lint issues.
|
||||
As this is part of Chart.js's API it makes sense to disable the check here.
|
||||
*/
|
||||
import { onMounted, ref, shallowRef, watch, PropType } from 'vue';
|
||||
import { onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApiGet } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
|
||||
|
|
@ -34,44 +35,63 @@ import MkChartLegend from '@/components/MkChartLegend.vue';
|
|||
|
||||
initChart();
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
args: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 90,
|
||||
},
|
||||
span: {
|
||||
type: String as PropType<'hour' | 'day'>,
|
||||
required: true,
|
||||
},
|
||||
detailed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
bar: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
aspectRatio: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
type ChartSrc =
|
||||
| 'federation'
|
||||
| 'ap-request'
|
||||
| 'users'
|
||||
| 'users-total'
|
||||
| 'active-users'
|
||||
| 'notes'
|
||||
| 'local-notes'
|
||||
| 'remote-notes'
|
||||
| 'notes-total'
|
||||
| 'drive'
|
||||
| 'drive-files'
|
||||
| 'instance-requests'
|
||||
| 'instance-users'
|
||||
| 'instance-users-total'
|
||||
| 'instance-notes'
|
||||
| 'instance-notes-total'
|
||||
| 'instance-ff'
|
||||
| 'instance-ff-total'
|
||||
| 'instance-drive-usage'
|
||||
| 'instance-drive-usage-total'
|
||||
| 'instance-drive-files'
|
||||
| 'instance-drive-files-total'
|
||||
| 'per-user-notes'
|
||||
| 'per-user-pv'
|
||||
| 'per-user-following'
|
||||
| 'per-user-followers'
|
||||
| 'per-user-drive'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src: ChartSrc;
|
||||
args?: {
|
||||
host?: string;
|
||||
user?: Misskey.entities.UserLite;
|
||||
withoutAll?: boolean;
|
||||
};
|
||||
limit?: number;
|
||||
span: 'hour' | 'day';
|
||||
detailed?: boolean;
|
||||
stacked?: boolean;
|
||||
bar?: boolean;
|
||||
aspectRatio?: number | null;
|
||||
nowForChromatic?: number;
|
||||
}>(), {
|
||||
args: undefined,
|
||||
limit: 90,
|
||||
detailed: false,
|
||||
stacked: false,
|
||||
bar: false,
|
||||
aspectRatio: null,
|
||||
|
||||
/**
|
||||
* @desc Overwrites current date to fix background lines of chart.
|
||||
* @ignore Only used for Chromatic. Don't use this for production.
|
||||
* @see https://github.com/misskey-dev/misskey/pull/13830#issuecomment-2155886151
|
||||
*/
|
||||
nowForChromatic: undefined,
|
||||
});
|
||||
|
||||
const legendEl = shallowRef<InstanceType<typeof MkChartLegend>>();
|
||||
|
|
@ -94,7 +114,8 @@ const getColor = (i) => {
|
|||
return colorSets[i % colorSets.length];
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const now = props.nowForChromatic != null ? new Date(props.nowForChromatic) : new Date();
|
||||
let chartInstance: Chart | null = null;
|
||||
let chartData: {
|
||||
series: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkChartLegend from './MkChartLegend.vue';
|
||||
void MkChartLegend;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkChartTooltip from './MkChartTooltip.vue';
|
||||
void MkChartTooltip;
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkClickerGame from './MkClickerGame.vue';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkClickerGame,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkClickerGame v-bind="props" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
await sleep(1000);
|
||||
const canvas = within(canvasElement);
|
||||
const count = canvas.getByTestId('count');
|
||||
await expect(count).toHaveTextContent('0');
|
||||
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
|
||||
await userEvent.click(buttonElement);
|
||||
await expect(count).toHaveTextContent('1');
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/i/registry/get', async ({ request }) => {
|
||||
action('POST /api/i/registry/get')(await request.json());
|
||||
return HttpResponse.json({
|
||||
error: {
|
||||
message: 'No such key.',
|
||||
code: 'NO_SUCH_KEY',
|
||||
id: 'ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a',
|
||||
},
|
||||
}, {
|
||||
status: 400,
|
||||
});
|
||||
}),
|
||||
http.post('/api/i/registry/set', async ({ request }) => {
|
||||
action('POST /api/i/registry/set')(await request.json());
|
||||
return HttpResponse.json(undefined, { status: 204 });
|
||||
}),
|
||||
http.post('/api/i/claim-achievement', async ({ request }) => {
|
||||
action('POST /api/i/claim-achievement')(await request.json());
|
||||
return HttpResponse.json(undefined, { status: 204 });
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkClickerGame>;
|
||||
|
|
@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div>
|
||||
<div v-if="game.ready" :class="$style.game">
|
||||
<div :class="$style.cps" class="">{{ number(cps) }}cps</div>
|
||||
<div :class="$style.count" class=""><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
|
||||
<div :class="$style.count" class="" data-testid="count"><i class="ti ti-cookie" style="font-size: 70%;"></i> {{ number(cookies) }}</div>
|
||||
<button v-click-anime class="_button" @click="onClick">
|
||||
<img src="/client-assets/cookie.png" :class="$style.img">
|
||||
</button>
|
||||
|
|
@ -35,7 +35,9 @@ const prevCookies = ref(0);
|
|||
function onClick(ev: MouseEvent) {
|
||||
const x = ev.clientX;
|
||||
const y = ev.clientY;
|
||||
os.popup(MkPlusOneEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkPlusOneEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
|
||||
saveData.value!.cookies++;
|
||||
saveData.value!.totalCookies++;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { clip } from '../../.storybook/fakes.js';
|
||||
import MkClipPreview from './MkClipPreview.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkClipPreview,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkClipPreview v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
clip: clip(),
|
||||
noUserInfo: false,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies StoryObj<typeof MkClipPreview>;
|
||||
|
|
@ -12,10 +12,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="clip.lastClippedAt">{{ i18n.ts.updatedAt }}: <MkTime :time="clip.lastClippedAt" mode="detail"/></div>
|
||||
<div v-if="clip.notesCount != null">{{ i18n.ts.notesCount }}: {{ number(clip.notesCount) }} / {{ $i?.policies.noteEachClipsLimit }} ({{ i18n.tsx.remainingN({ n: remaining }) }})</div>
|
||||
</div>
|
||||
<div :class="$style.divider"></div>
|
||||
<div>
|
||||
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
|
||||
</div>
|
||||
<template v-if="!props.noUserInfo">
|
||||
<div :class="$style.divider"></div>
|
||||
<div>
|
||||
<MkAvatar :user="clip.user" :class="$style.userAvatar" indicator link preview/> <MkUserName :user="clip.user" :nowrap="false"/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MkA>
|
||||
</template>
|
||||
|
|
@ -27,9 +29,12 @@ import { i18n } from '@/i18n.js';
|
|||
import { $i } from '@/account.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
clip: Misskey.entities.Clip;
|
||||
}>();
|
||||
noUserInfo?: boolean;
|
||||
}>(), {
|
||||
noUserInfo: false,
|
||||
});
|
||||
|
||||
const remaining = computed(() => {
|
||||
return ($i?.policies && props.clip.notesCount != null) ? ($i.policies.noteEachClipsLimit - props.clip.notesCount) : i18n.ts.unknown;
|
||||
|
|
@ -40,6 +45,14 @@ const remaining = computed(() => {
|
|||
.link {
|
||||
display: block;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
.root {
|
||||
box-shadow: inset 0 0 0 2px var(--focus);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkCode_core from './MkCode.core.vue';
|
||||
void MkCode_core;
|
||||
44
packages/frontend/src/components/MkCode.stories.impl.ts
Normal file
44
packages/frontend/src/components/MkCode.stories.impl.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkCode from './MkCode.vue';
|
||||
const code = `for (let i, 100) {
|
||||
<: if (i % 15 == 0) "FizzBuzz"
|
||||
elif (i % 3 == 0) "Fizz"
|
||||
elif (i % 5 == 0) "Buzz"
|
||||
else i
|
||||
}`;
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCode,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCode v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
code,
|
||||
lang: 'is',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCode>;
|
||||
|
|
@ -30,7 +30,7 @@ import * as os from '@/os.js';
|
|||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import MkCodeEditor from './MkCodeEditor.vue';
|
||||
const code = `for (let i, 100) {
|
||||
<: if (i % 15 == 0) "FizzBuzz"
|
||||
elif (i % 3 == 0) "Fizz"
|
||||
elif (i % 5 == 0) "Buzz"
|
||||
else i
|
||||
}`;
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCodeEditor,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
code,
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
'change': action('change'),
|
||||
'keydown': action('keydown'),
|
||||
'enter': action('enter'),
|
||||
'update:modelValue': action('update:modelValue'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCodeEditor v-model="code" v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
lang: 'aiscript',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><Suspense><story/></Suspense></div></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies StoryObj<typeof MkCodeEditor>;
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkCodeInline from './MkCodeInline.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCodeInline,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCodeInline v-bind="props"/>',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
code: '<: "Hello, world!"',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCodeInline>;
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import MkColorInput from './MkColorInput.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkColorInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
color: '#cccccc',
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
'update:modelValue': action('update:modelValue'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkColorInput v-model="color" v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
decorators: [
|
||||
() => ({
|
||||
template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 800px; width: 100%; margin: 3rem"><story/></div></div>',
|
||||
}),
|
||||
],
|
||||
} satisfies StoryObj<typeof MkColorInput>;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkContainer from './MkContainer.vue';
|
||||
void MkContainer;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
import MkContextMenu from './MkContextMenu.vue';
|
||||
import * as os from '@/os.js';
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
return {
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(args.items, ev);
|
||||
},
|
||||
},
|
||||
template: '<div @contextmenu.stop="onContextmenu">Right Click Here</div>',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
items: [],
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const target = canvas.getByText('Right Click Here');
|
||||
await userEvent.pointer({ keys: '[MouseRight>]', target });
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkContextMenu>;
|
||||
export const SomeTabs = {
|
||||
...Empty,
|
||||
args: {
|
||||
items: [
|
||||
{
|
||||
text: 'Home',
|
||||
icon: 'ti ti-home',
|
||||
action() {},
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkContextMenu>;
|
||||
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:leaveToClass="defaultStore.state.animation ? $style.transition_fade_leaveTo : ''"
|
||||
>
|
||||
<div ref="rootEl" :class="$style.root" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
|
||||
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
|
||||
<MkMenu :items="items" :align="'left'" @close="emit('closed')"/>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkCropperDialog from './MkCropperDialog.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCropperDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
'ok': action('ok'),
|
||||
'cancel': action('cancel'),
|
||||
'closed': action('closed'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCropperDialog v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
file: file(),
|
||||
aspectRatio: NaN,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
// NOTE: ロードが終わるまで待つ
|
||||
delay: 3000,
|
||||
},
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.get('/proxy/image.webp', async ({ request }) => {
|
||||
const url = new URL(request.url).searchParams.get('url');
|
||||
if (url === 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true') {
|
||||
const image = await (await fetch('client-assets/fedi.jpg')).blob();
|
||||
return new HttpResponse(image, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
}),
|
||||
http.post('/api/drive/files/create', async ({ request }) => {
|
||||
action('POST /api/drive/files/create')(await request.formData());
|
||||
return HttpResponse.json(file());
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCropperDialog>;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { emojiDetailed } from '../../.storybook/fakes.js';
|
||||
import MkCustomEmojiDetailedDialog from './MkCustomEmojiDetailedDialog.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCustomEmojiDetailedDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCustomEmojiDetailedDialog v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
emoji: emojiDetailed(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCustomEmojiDetailedDialog>;
|
||||
79
packages/frontend/src/components/MkCwButton.stories.impl.ts
Normal file
79
packages/frontend/src/components/MkCwButton.stories.impl.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
import MkCwButton from './MkCwButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkCwButton,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showContent: false,
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
'update:modelValue': action('update:modelValue'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkCwButton v-model="showContent" v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
text: 'Some CW content',
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
|
||||
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.show);
|
||||
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
|
||||
await userEvent.click(buttonElement);
|
||||
await expect(buttonElement).toHaveTextContent(i18n.ts._cw.hide);
|
||||
await userEvent.click(buttonElement);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
// NOTE: テストが終わるまで待つ
|
||||
delay: 5000,
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCwButton>;
|
||||
export const IncludesTextAndDriveFile = {
|
||||
...Default,
|
||||
args: {
|
||||
text: 'Some CW content',
|
||||
files: [file()],
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const buttonElement = canvas.getByRole<HTMLButtonElement>('button');
|
||||
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.chars({ count: 15 }));
|
||||
await expect(buttonElement).toHaveTextContent(' / ');
|
||||
await expect(buttonElement).toHaveTextContent(i18n.tsx._cw.files({ count: 1 }));
|
||||
},
|
||||
} satisfies StoryObj<typeof MkCwButton>;
|
||||
|
|
@ -45,11 +45,11 @@ function toggle() {
|
|||
.label {
|
||||
margin-left: 4px;
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: '(';
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: ')';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDateSeparatedList from './MkDateSeparatedList.vue';
|
||||
void MkDateSeparatedList;
|
||||
|
|
@ -130,7 +130,7 @@ export default defineComponent({
|
|||
el.style.left = '';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const classes = {
|
||||
[$style['date-separated-list']]: true,
|
||||
[$style['date-separated-list-nogap']]: props.noGap,
|
||||
|
|
|
|||
159
packages/frontend/src/components/MkDialog.stories.impl.ts
Normal file
159
packages/frontend/src/components/MkDialog.stories.impl.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkDialog from './MkDialog.vue';
|
||||
const Base = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
done: action('done'),
|
||||
closed: action('closed'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkDialog v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
text: 'Hello, world!',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const Success = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
type: 'success',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const Error = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
type: 'error',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const Warning = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
type: 'warning',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const Info = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
type: 'info',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const Question = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
type: 'question',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const Waiting = {
|
||||
...Base,
|
||||
args: {
|
||||
...Base.args,
|
||||
type: 'waiting',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const DialogWithActions = {
|
||||
...Question,
|
||||
args: {
|
||||
...Question.args,
|
||||
text: i18n.ts.areYouSure,
|
||||
actions: [
|
||||
{
|
||||
text: i18n.ts.yes,
|
||||
primary: true,
|
||||
callback() {
|
||||
action('YES')();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.no,
|
||||
callback() {
|
||||
action('NO')();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const DialogWithDangerActions = {
|
||||
...Warning,
|
||||
args: {
|
||||
...Warning.args,
|
||||
text: i18n.ts.resetAreYouSure,
|
||||
actions: [
|
||||
{
|
||||
text: i18n.ts.yes,
|
||||
danger: true,
|
||||
primary: true,
|
||||
callback() {
|
||||
action('YES')();
|
||||
},
|
||||
},
|
||||
{
|
||||
text: i18n.ts.no,
|
||||
callback() {
|
||||
action('NO')();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
export const DialogWithInput = {
|
||||
...Question,
|
||||
args: {
|
||||
...Question.args,
|
||||
title: 'Hello, world!',
|
||||
text: undefined,
|
||||
input: {
|
||||
placeholder: i18n.ts.inputMessageHere,
|
||||
type: 'text',
|
||||
default: null,
|
||||
minLength: 2,
|
||||
maxLength: 3,
|
||||
},
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 0, min: 2 }));
|
||||
const okButton = canvas.getByRole('button', { name: i18n.ts.ok });
|
||||
await expect(okButton).toBeDisabled();
|
||||
const input = canvas.getByRole<HTMLInputElement>('combobox');
|
||||
await waitFor(() => userEvent.hover(input));
|
||||
await waitFor(() => userEvent.click(input));
|
||||
await waitFor(() => userEvent.type(input, 'M'));
|
||||
await expect(canvasElement).toHaveTextContent(i18n.tsx._dialog.charactersBelow({ current: 1, min: 2 }));
|
||||
await waitFor(() => userEvent.type(input, 'i'));
|
||||
await expect(okButton).toBeEnabled();
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDialog>;
|
||||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')">
|
||||
<MkModal ref="modal" :preferType="'dialog'" :zPriority="'high'" @click="done(true)" @closed="emit('closed')" @esc="cancel()">
|
||||
<div :class="$style.root">
|
||||
<div v-if="icon" :class="$style.icon">
|
||||
<i :class="icon"></i>
|
||||
|
|
@ -36,7 +36,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
|
||||
<template v-for="item in select.items">
|
||||
<optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle">
|
||||
<option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option>
|
||||
</optgroup>
|
||||
<option v-else :value="item.value">{{ item.text }}</option>
|
||||
</template>
|
||||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
|
|
@ -51,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef, computed } from 'vue';
|
||||
import { ref, shallowRef, computed } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
|
@ -67,11 +72,16 @@ type Input = {
|
|||
maxLength?: number;
|
||||
};
|
||||
|
||||
type SelectItem = {
|
||||
value: any;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
items: {
|
||||
value: any;
|
||||
text: string;
|
||||
}[];
|
||||
items: (SelectItem | {
|
||||
sectionTitle: string;
|
||||
items: SelectItem[];
|
||||
})[];
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -156,10 +166,6 @@ function onBgClick() {
|
|||
if (props.cancelableByBgClick) cancel();
|
||||
}
|
||||
*/
|
||||
function onKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === 'Escape') cancel();
|
||||
}
|
||||
|
||||
function onInputKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === 'Enter' && okButtonDisabledReason.value === null) {
|
||||
evt.preventDefault();
|
||||
|
|
@ -167,14 +173,6 @@ function onInputKeydown(evt: KeyboardEvent) {
|
|||
ok();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDivider from './MkDivider.vue';
|
||||
void MkDivider;
|
||||
32
packages/frontend/src/components/MkDivider.vue
Normal file
32
packages/frontend/src/components/MkDivider.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="default" :style="[
|
||||
marginTopBottom ? { marginTop: marginTopBottom, marginBottom: marginTopBottom } : {},
|
||||
marginLeftRight ? { marginLeft: marginLeftRight, marginRight: marginLeftRight } : {},
|
||||
borderStyle ? { borderStyle: borderStyle } : {},
|
||||
borderWidth ? { borderWidth: borderWidth } : {},
|
||||
borderColor ? { borderColor: borderColor } : {},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
marginTopBottom?: string;
|
||||
marginLeftRight?: string;
|
||||
borderStyle?: string;
|
||||
borderWidth?: string;
|
||||
borderColor?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.default {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
</style>
|
||||
54
packages/frontend/src/components/MkDonation.stories.impl.ts
Normal file
54
packages/frontend/src/components/MkDonation.stories.impl.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
import MkDonation from './MkDonation.vue';
|
||||
import { instance } from '@/instance.js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkDonation,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
closed: action('closed'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkDonation v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
// @ts-expect-error name is used for mocking instance
|
||||
name: 'Misskey Hub',
|
||||
},
|
||||
decorators: [
|
||||
(_, { args }) => ({
|
||||
setup() {
|
||||
// @ts-expect-error name is used for mocking instance
|
||||
instance.name = args.name;
|
||||
onBeforeUnmount(() => instance.name = null);
|
||||
},
|
||||
template: '<story/>',
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDonation>;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkDrive_file from './MkDrive.file.vue';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkDrive_file,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
chosen: action('chosen'),
|
||||
dragstart: action('dragstart'),
|
||||
dragend: action('dragend'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkDrive_file v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
file: file(),
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
// NOTE: ロードが終わるまで待つ
|
||||
delay: 3000,
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDrive_file>;
|
||||
|
|
@ -119,14 +119,14 @@ function onDragend() {
|
|||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b65a5;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
|
@ -137,14 +137,14 @@ function onDragend() {
|
|||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b588c;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #ce2212;
|
||||
}
|
||||
}
|
||||
|
|
@ -163,8 +163,8 @@ function onDragend() {
|
|||
}
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -185,8 +185,8 @@ function onDragend() {
|
|||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
@ -194,14 +194,14 @@ function onDragend() {
|
|||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
|
|
@ -209,8 +209,8 @@ function onDragend() {
|
|||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkDrive_folder from './MkDrive.folder.vue';
|
||||
import { folder } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkDrive_folder,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
chosen: action('chosen'),
|
||||
move: action('move'),
|
||||
upload: action('upload'),
|
||||
removeFile: action('removeFile'),
|
||||
removeFolder: action('removeFolder'),
|
||||
dragstart: action('dragstart'),
|
||||
dragend: action('dragend'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkDrive_folder v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
folder: folder(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/drive/folders/delete', async ({ request }) => {
|
||||
action('POST /api/drive/folders/delete')(await request.json());
|
||||
return HttpResponse.json(undefined, { status: 204 });
|
||||
}),
|
||||
http.post('/api/drive/folders/update', async ({ request }) => {
|
||||
const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest;
|
||||
action('POST /api/drive/folders/update')(req);
|
||||
return HttpResponse.json({
|
||||
...folder(),
|
||||
id: req.folderId,
|
||||
name: req.name ?? folder().name,
|
||||
parentId: req.parentId ?? folder().parentId,
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDrive_folder>;
|
||||
|
|
@ -27,7 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<p v-if="defaultStore.state.uploadFolder == folder.id" :class="$style.upload">
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
</p>
|
||||
<button v-if="selectMode" class="_button" :class="[$style.checkbox, { [$style.checked]: isSelected }]" @click.prevent.stop="checkboxClicked"></button>
|
||||
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
|
||||
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -39,7 +41,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
|
|
@ -53,6 +55,7 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'unchose', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'move', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
|
||||
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
|
||||
|
|
@ -68,7 +71,11 @@ const isDragging = ref(false);
|
|||
const title = computed(() => props.folder.name);
|
||||
|
||||
function checkboxClicked() {
|
||||
emit('chosen', props.folder);
|
||||
if (props.isSelected) {
|
||||
emit('unchose', props.folder);
|
||||
} else {
|
||||
emit('chosen', props.folder);
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
|
|
@ -222,6 +229,17 @@ function rename() {
|
|||
});
|
||||
}
|
||||
|
||||
function move() {
|
||||
os.selectDriveFolder(false).then(folder => {
|
||||
if (folder[0] && folder[0].id === props.folder.id) return;
|
||||
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: props.folder.id,
|
||||
parentId: folder[0] ? folder[0].id : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFolder() {
|
||||
misskeyApi('drive/folders/delete', {
|
||||
folderId: props.folder.id,
|
||||
|
|
@ -257,15 +275,20 @@ function onContextmenu(ev: MouseEvent) {
|
|||
text: i18n.ts.openInWindow,
|
||||
icon: 'ti ti-app-window',
|
||||
action: () => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
|
||||
initialFolder: props.folder,
|
||||
}, {
|
||||
}, 'closed');
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}, { type: 'divider' }, {
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ti ti-forms',
|
||||
action: rename,
|
||||
}, {
|
||||
text: i18n.ts.move,
|
||||
icon: 'ti ti ti-folder-symlink',
|
||||
action: move,
|
||||
}, { type: 'divider' }, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ti ti-trash',
|
||||
|
|
@ -295,7 +318,7 @@ function onContextmenu(ev: MouseEvent) {
|
|||
cursor: pointer;
|
||||
|
||||
&.draghover {
|
||||
&:after {
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
|
@ -309,17 +332,43 @@ function onContextmenu(ev: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
.checkboxWrapper {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border: solid 1px #000;
|
||||
border-radius: 50%;
|
||||
bottom: 2px;
|
||||
right: 2px;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.checked {
|
||||
background: var(--accent);
|
||||
> .checkbox {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #fff;
|
||||
border: solid 2px var(--divider);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.checked {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
|
||||
&::after {
|
||||
content: "\ea5e";
|
||||
font-family: 'tabler-icons';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDrive_navFolder from './MkDrive.navFolder.vue';
|
||||
void MkDrive_navFolder;
|
||||
82
packages/frontend/src/components/MkDrive.stories.impl.ts
Normal file
82
packages/frontend/src/components/MkDrive.stories.impl.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkDrive from './MkDrive.vue';
|
||||
import { file, folder } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkDrive,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
selected: action('selected'),
|
||||
'change-selection': action('change-selection'),
|
||||
'move-root': action('move-root'),
|
||||
cd: action('cd'),
|
||||
'open-folder': action('open-folder'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkDrive v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
// NOTE: ロードが終わるまで待つ
|
||||
delay: 3000,
|
||||
},
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/drive/files', async ({ request }) => {
|
||||
action('POST /api/drive/files')(await request.json());
|
||||
return HttpResponse.json([file()]);
|
||||
}),
|
||||
http.post('/api/drive/folders', async ({ request }) => {
|
||||
action('POST /api/drive/folders')(await request.json());
|
||||
return HttpResponse.json([folder(crypto.randomUUID())]);
|
||||
}),
|
||||
http.post('/api/drive/folders/create', async ({ request }) => {
|
||||
const req = await request.json() as Misskey.entities.DriveFoldersCreateRequest;
|
||||
action('POST /api/drive/folders/create')(req);
|
||||
return HttpResponse.json(folder(crypto.randomUUID(), req.name, req.parentId));
|
||||
}),
|
||||
http.post('/api/drive/folders/delete', async ({ request }) => {
|
||||
action('POST /api/drive/folders/delete')(await request.json());
|
||||
return HttpResponse.json(undefined, { status: 204 });
|
||||
}),
|
||||
http.post('/api/drive/folders/update', async ({ request }) => {
|
||||
const req = await request.json() as Misskey.entities.DriveFoldersUpdateRequest;
|
||||
action('POST /api/drive/folders/update')(req);
|
||||
return HttpResponse.json({
|
||||
...folder(),
|
||||
id: req.folderId,
|
||||
name: req.name ?? folder().name,
|
||||
parentId: req.parentId ?? folder().parentId,
|
||||
});
|
||||
}),
|
||||
]
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDrive>;
|
||||
|
|
@ -59,6 +59,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:selectMode="select === 'folder'"
|
||||
:isSelected="selectedFolders.some(x => x.id === f.id)"
|
||||
@chosen="chooseFolder"
|
||||
@unchose="unchoseFolder"
|
||||
@move="move"
|
||||
@upload="upload"
|
||||
@removeFile="removeFile"
|
||||
|
|
@ -438,6 +439,11 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
|
|||
}
|
||||
}
|
||||
|
||||
function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) {
|
||||
selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id);
|
||||
emit('change-selection', selectedFolders.value);
|
||||
}
|
||||
|
||||
function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) {
|
||||
if (!target) {
|
||||
goRoot();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkDriveFileThumbnail from './MkDriveFileThumbnail.vue';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkDriveFileThumbnail,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkDriveFileThumbnail v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
file: file(),
|
||||
fit: 'contain',
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
// NOTE: ロードが終わるまで待つ
|
||||
delay: 3000,
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkDriveFileThumbnail>;
|
||||
|
|
@ -26,7 +26,7 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
fit: string;
|
||||
fit: 'cover' | 'contain';
|
||||
}>();
|
||||
|
||||
const is = computed(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
|
||||
void MkDriveSelectDialog;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDriveWindow from './MkDriveWindow.vue';
|
||||
void MkDriveWindow;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkEmojiPicker_section from './MkEmojiPicker.section.vue';
|
||||
void MkEmojiPicker_section;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkEmojiPicker from './MkEmojiPicker.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkEmojiPicker,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
chosen: action('chosen'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkEmojiPicker v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const faceSection = canvas.getByText(/face/i);
|
||||
await waitFor(() => userEvent.click(faceSection));
|
||||
const grinning = canvasElement.querySelector('[data-emoji="😀"]');
|
||||
await expect(grinning).toBeInTheDocument();
|
||||
if (grinning == null) throw new Error(); // NOTE: not called
|
||||
await waitFor(() => userEvent.click(grinning));
|
||||
const recentUsedSection = canvas.getByText(new RegExp(i18n.ts.recentUsed)).parentElement;
|
||||
await expect(recentUsedSection).toBeInTheDocument();
|
||||
if (recentUsedSection == null) throw new Error(); // NOTE: not called
|
||||
await expect(within(recentUsedSection).getByAltText('😀')).toBeInTheDocument();
|
||||
await expect(within(recentUsedSection).queryByAltText('😬')).toEqual(null);
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkEmojiPicker>;
|
||||
|
|
@ -5,7 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer, asWindow }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
|
||||
<input ref="searchEl" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" autocapitalize="off" @input="input()" @paste.stop="paste" @keydown.stop.prevent.enter="onEnter">
|
||||
<input
|
||||
ref="searchEl"
|
||||
:value="q"
|
||||
class="search"
|
||||
data-prevent-emoji-insert
|
||||
:class="{ filled: q != null && q != '' }"
|
||||
:placeholder="i18n.ts.search"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
@input="input()"
|
||||
@paste.stop="paste"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<!-- FirefoxのTabフォーカスが想定外の挙動となるためtabindex="-1"を追加 https://github.com/misskey-dev/misskey/issues/10744 -->
|
||||
<div ref="emojisEl" class="emojis" tabindex="-1">
|
||||
<section class="result">
|
||||
|
|
@ -139,6 +151,7 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: string): void;
|
||||
(ev: 'esc'): void;
|
||||
}>();
|
||||
|
||||
const searchEl = shallowRef<HTMLInputElement>();
|
||||
|
|
@ -402,7 +415,9 @@ function chosen(emoji: any, ev?: MouseEvent) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
const key = getKey(emoji);
|
||||
|
|
@ -431,9 +446,18 @@ function paste(event: ClipboardEvent): void {
|
|||
}
|
||||
}
|
||||
|
||||
function onEnter(ev: KeyboardEvent) {
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
|
||||
done();
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
done();
|
||||
}
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
emit('esc');
|
||||
}
|
||||
}
|
||||
|
||||
function done(query?: string): boolean | void {
|
||||
|
|
@ -700,11 +724,6 @@ defineExpose({
|
|||
border-radius: var(--radius-xs);
|
||||
font-size: 24px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkEmojiPickerDialog from './MkEmojiPickerDialog.vue';
|
||||
void MkEmojiPickerDialog;
|
||||
|
|
@ -9,10 +9,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
v-slot="{ type, maxHeight }"
|
||||
:zPriority="'middle'"
|
||||
:preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
|
||||
:hasInteractionWithOtherFocusTrappedEls="true"
|
||||
:transparentBg="true"
|
||||
:manualShowing="manualShowing"
|
||||
:src="src"
|
||||
@click="modal?.close()"
|
||||
@esc="modal?.close()"
|
||||
@opening="opening"
|
||||
@close="emit('close')"
|
||||
@closed="emit('closed')"
|
||||
|
|
@ -28,6 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:asDrawer="type === 'drawer'"
|
||||
:max-height="maxHeight"
|
||||
@chosen="chosen"
|
||||
@esc="modal?.close()"
|
||||
/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" tabindex="-1">
|
||||
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel">
|
||||
<article>
|
||||
<header>
|
||||
<h1 :title="flash.title">{{ flash.title }}</h1>
|
||||
|
|
@ -39,6 +39,10 @@ const props = defineProps<{
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
|
||||
<button :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>
|
||||
<div :class="$style.headerTextMain">
|
||||
<MkCondensedLine :minScale="2 / 3"><slot name="label"></slot></MkCondensedLine>
|
||||
</div>
|
||||
<div :class="$style.headerTextSub">
|
||||
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
|
||||
|
|
@ -147,6 +147,10 @@ onMounted(() => {
|
|||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
background: var(--buttonHoverBg);
|
||||
|
|
@ -190,6 +194,12 @@ onMounted(() => {
|
|||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.headerTextMain,
|
||||
.headerTextSub {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.headerTextSub {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
|||
import { useStream } from '@/stream.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { host } from '@/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
|
@ -63,7 +65,7 @@ const hasPendingFollowRequestFromYou = ref(props.user.hasPendingFollowRequestFro
|
|||
const wait = ref(false);
|
||||
const connection = useStream().useChannel('main');
|
||||
|
||||
if (props.user.isFollowing == null) {
|
||||
if (props.user.isFollowing == null && $i) {
|
||||
misskeyApi('users/show', {
|
||||
userId: props.user.id,
|
||||
})
|
||||
|
|
@ -78,6 +80,8 @@ function onFollowChange(user: Misskey.entities.UserDetailed) {
|
|||
}
|
||||
|
||||
async function onClick() {
|
||||
pleaseLogin(undefined, { type: 'web', path: `/@${props.user.username}@${props.user.host ?? host}` });
|
||||
|
||||
wait.value = true;
|
||||
|
||||
try {
|
||||
|
|
@ -121,6 +125,8 @@ async function onClick() {
|
|||
});
|
||||
hasPendingFollowRequestFromYou.value = true;
|
||||
|
||||
if ($i == null) return;
|
||||
|
||||
claimAchievement('following1');
|
||||
|
||||
if ($i.followingCount >= 10) {
|
||||
|
|
@ -183,17 +189,7 @@ onBeforeUnmount(() => {
|
|||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ function leaveHover(): void {
|
|||
|
||||
> article {
|
||||
> footer {
|
||||
&:before {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ function leaveHover(): void {
|
|||
text-shadow: 0 0 8px #000;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:enterToClass="defaultStore.state.animation && props.transition?.enterToClass || undefined"
|
||||
:leaveFromClass="defaultStore.state.animation && props.transition?.leaveFromClass || undefined"
|
||||
>
|
||||
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined"/>
|
||||
<img v-show="!hide" key="img" ref="img" :height="imgHeight" :width="imgWidth" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async"/>
|
||||
<canvas v-show="hide" key="canvas" ref="canvas" :class="$style.canvas" :width="canvasWidth" :height="canvasHeight" :title="title ?? undefined" tabindex="-1"/>
|
||||
<img v-show="!hide" key="img" ref="img" :height="imgHeight ?? undefined" :width="imgWidth ?? undefined" :class="$style.img" :src="src ?? undefined" :title="title ?? undefined" :alt="alt ?? undefined" loading="eager" decoding="async" tabindex="-1"/>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -151,22 +151,26 @@ function drawImage(bitmap: CanvasImageSource) {
|
|||
}
|
||||
|
||||
function drawAvg() {
|
||||
if (!canvas.value || !props.hash) return;
|
||||
if (!canvas.value) return;
|
||||
|
||||
const color = (props.hash != null && extractAvgColorFromBlurhash(props.hash)) || '#888';
|
||||
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// avgColorでお茶をにごす
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(0, 0, canvasWidth.value, canvasHeight.value);
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (props.hash == null) return;
|
||||
if (import.meta.env.MODE === 'test' && props.hash == null) return;
|
||||
|
||||
drawAvg();
|
||||
|
||||
if (props.hash == null) return;
|
||||
|
||||
if (props.onlyAvgColor) return;
|
||||
|
||||
const work = await canvasPromise;
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ const props = defineProps<{
|
|||
const emit = defineEmits<{
|
||||
(ev: 'change', _ev: KeyboardEvent): void;
|
||||
(ev: 'keydown', _ev: KeyboardEvent): void;
|
||||
(ev: 'enter'): void;
|
||||
(ev: 'enter', _ev: KeyboardEvent): void;
|
||||
(ev: 'update:modelValue', value: string | number): void;
|
||||
}>();
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ const onKeydown = (ev: KeyboardEvent) => {
|
|||
emit('keydown', ev);
|
||||
|
||||
if (ev.code === 'Enter') {
|
||||
emit('enter');
|
||||
emit('enter', ev);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { federationInstance } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import { getChartResolver } from '../../.storybook/charts.js';
|
||||
import MkInstanceCardMini from './MkInstanceCardMini.vue';
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkInstanceCardMini,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkInstanceCardMini v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
instance: federationInstance(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.get('/undefined/preview.webp', async ({ request }) => {
|
||||
const urlStr = new URL(request.url).searchParams.get('url');
|
||||
if (urlStr == null) {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
const url = new URL(urlStr);
|
||||
|
||||
if (url.href.startsWith('https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/')) {
|
||||
const image = await (await fetch(`client-assets/${url.pathname.split('/').pop()}`)).blob();
|
||||
return new HttpResponse(image, {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return new HttpResponse(null, { status: 404 });
|
||||
}
|
||||
}),
|
||||
http.get('/api/charts/instance', getChartResolver(['requests.received'])),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkInstanceCardMini>;
|
||||
|
|
@ -29,8 +29,8 @@ const chartValues = ref<number[] | null>(null);
|
|||
|
||||
misskeyApiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
|
||||
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
|
||||
res['requests.received'].splice(0, 1);
|
||||
chartValues.value = res['requests.received'];
|
||||
res.requests.received.splice(0, 1);
|
||||
chartValues.value = res.requests.received;
|
||||
});
|
||||
|
||||
function getInstanceIcon(instance): string {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ import { computed } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')">
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :preferType="preferedModalType" :anchor="anchor" :transparentBg="true" :src="src" @click="modal?.close()" @closed="emit('closed')" @esc="modal?.close()">
|
||||
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
|
||||
<div class="main">
|
||||
<template v-for="item in items" :key="item.text">
|
||||
|
|
|
|||
|
|
@ -38,11 +38,13 @@ const el = ref<HTMLElement | { $el: HTMLElement }>();
|
|||
|
||||
if (isEnabledUrlPreview.value) {
|
||||
useTooltip(el, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
showing,
|
||||
url: props.url,
|
||||
source: el.value instanceof HTMLElement ? el.value : el.value?.$el,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
||||
<button v-if="hide" :class="$style.hidden" @click="show">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b v-if="audio.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.audio}${audio.size ? ' ' + bytes(audio.size) : ''})` : '' }}</b>
|
||||
<b v-else style="display: block;"><i class="ti ti-music"></i> {{ defaultStore.state.dataSaver.media && audio.size ? bytes(audio.size) : i18n.ts.audio }}</b>
|
||||
|
|
@ -39,11 +39,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<audio
|
||||
ref="audioEl"
|
||||
preload="metadata"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<source :src="audio.url">
|
||||
</audio>
|
||||
<div :class="[$style.controlsChild, $style.controlsLeft]">
|
||||
<button class="_button" :class="$style.controlButton" @click="togglePlayPause">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="togglePlayPause"
|
||||
>
|
||||
<i v-if="isPlaying" class="ti ti-player-pause-filled"></i>
|
||||
<i v-else class="ti ti-player-play-filled"></i>
|
||||
</button>
|
||||
|
|
@ -52,13 +57,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<a class="_button" :class="$style.controlButton" :href="audio.url" :download="audio.name" target="_blank">
|
||||
<i class="ph-download ph-bold ph-lg"></i>
|
||||
</a>
|
||||
<button class="_button" :class="$style.controlButton" @click="showMenu">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="() => {}"
|
||||
@mousedown.prevent.stop="showMenu"
|
||||
>
|
||||
<i class="ti ti-settings"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsTime]">{{ hms(elapsedTimeMs) }}</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsVolume]">
|
||||
<button class="_button" :class="$style.controlButton" @click="toggleMute">
|
||||
<button
|
||||
:class="['_button', $style.controlButton]"
|
||||
tabindex="-1"
|
||||
@click.stop="toggleMute"
|
||||
>
|
||||
<i v-if="volume === 0" class="ti ti-volume-3"></i>
|
||||
<i v-else class="ti ti-volume"></i>
|
||||
</button>
|
||||
|
|
@ -83,6 +97,7 @@ import type { MenuItem } from '@/types/menu.js';
|
|||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import MkMediaRange from '@/components/MkMediaRange.vue';
|
||||
|
|
@ -93,32 +108,44 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const keymap = {
|
||||
'up': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
'up': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
'down': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
'down': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'left': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||
}
|
||||
'left': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.max(audioEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'right': () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||
}
|
||||
'right': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && audioEl.value) {
|
||||
audioEl.value.currentTime = Math.min(audioEl.value.currentTime + 5, audioEl.value.duration);
|
||||
}
|
||||
},
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
};
|
||||
} as const satisfies Keymap;
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
|
|
@ -129,9 +156,21 @@ function hasFocus() {
|
|||
const playerEl = shallowRef<HTMLDivElement>();
|
||||
const audioEl = shallowRef<HTMLAudioElement>();
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.audio.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
||||
|
||||
async function show() {
|
||||
if (props.audio.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
}
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
|
|
@ -361,7 +400,7 @@ onDeactivated(() => {
|
|||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -427,6 +466,10 @@ onDeactivated(() => {
|
|||
color: var(--accent);
|
||||
background-color: var(--accentedBg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false">
|
||||
<div v-if="media.isSensitive && hide" :class="$style.sensitive" @click="show">
|
||||
<span style="font-size: 1.6em;"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ i18n.ts.sensitive }}</b>
|
||||
<span>{{ i18n.ts.clickToShow }}</span>
|
||||
|
|
@ -24,24 +24,30 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef, watch, ref } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkMediaAudio from '@/components/MkMediaAudio.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
media: Misskey.entities.DriveFile;
|
||||
}>(), {
|
||||
});
|
||||
}>();
|
||||
|
||||
const audioEl = shallowRef<HTMLAudioElement>();
|
||||
const hide = ref(true);
|
||||
|
||||
watch(audioEl, () => {
|
||||
if (audioEl.value) {
|
||||
audioEl.value.volume = 0.3;
|
||||
async function show() {
|
||||
if (props.media.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
});
|
||||
|
||||
hide.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -84,11 +84,21 @@ const url = computed(() => (props.raw || defaultStore.state.loadRawImages)
|
|||
: props.image.thumbnailUrl,
|
||||
);
|
||||
|
||||
function onclick() {
|
||||
async function onclick(ev: MouseEvent) {
|
||||
if (!props.controls) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hide.value) {
|
||||
ev.stopPropagation();
|
||||
if (props.image.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import XModPlayer from '@/components/SkModPlayer.vue';
|
|||
import * as os from '@/os.js';
|
||||
import { FILE_TYPE_BROWSERSAFE, FILE_EXT_TRACKER_MODULES, FILE_TYPE_TRACKER_MODULES } from '@/const.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { focusParent } from '@/scripts/focus.js';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
|
|
@ -51,7 +52,9 @@ const gallery = shallowRef<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);
|
||||
let lightbox: PhotoSwipeLightbox | null;
|
||||
let lightbox: PhotoSwipeLightbox | null = null;
|
||||
|
||||
let activeEl: HTMLElement | null = null;
|
||||
|
||||
const popstateHandler = (): void => {
|
||||
if (lightbox?.pswp && lightbox.pswp.isOpen === true) {
|
||||
|
|
@ -62,7 +65,7 @@ const popstateHandler = (): void => {
|
|||
async function calcAspectRatio() {
|
||||
if (!gallery.value) return;
|
||||
|
||||
let img = props.mediaList[0];
|
||||
const img = props.mediaList[0];
|
||||
|
||||
if (props.mediaList.length !== 1 || !(img.properties.width && img.properties.height)) {
|
||||
gallery.value.style.aspectRatio = '';
|
||||
|
|
@ -139,18 +142,17 @@ onMounted(() => {
|
|||
bgOpacity: 1,
|
||||
showAnimationDuration: 100,
|
||||
hideAnimationDuration: 100,
|
||||
returnFocus: false,
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
lightbox.on('itemData', (ev) => {
|
||||
const { itemData } = ev;
|
||||
|
||||
lightbox.addFilter('itemData', (itemData) => {
|
||||
// element is children
|
||||
const { element } = itemData;
|
||||
|
||||
const id = element?.dataset.id;
|
||||
const file = props.mediaList.find(media => media.id === id);
|
||||
if (!file) return;
|
||||
if (!file) return itemData;
|
||||
|
||||
itemData.src = file.url;
|
||||
itemData.w = Number(file.properties.width);
|
||||
|
|
@ -162,50 +164,60 @@ onMounted(() => {
|
|||
itemData.alt = file.comment ?? undefined;
|
||||
itemData.comment = file.comment;
|
||||
itemData.thumbCropped = true;
|
||||
|
||||
return itemData;
|
||||
});
|
||||
|
||||
lightbox.on('uiRegister', () => {
|
||||
lightbox?.pswp?.ui?.registerElement({
|
||||
name: 'altText',
|
||||
className: 'pwsp__alt-text-container',
|
||||
className: 'pswp__alt-text-container',
|
||||
appendTo: 'wrapper',
|
||||
onInit: (el, pwsp) => {
|
||||
let textBox = document.createElement('p');
|
||||
textBox.className = 'pwsp__alt-text _acrylic';
|
||||
onInit: (el, pswp) => {
|
||||
const textBox = document.createElement('p');
|
||||
textBox.className = 'pswp__alt-text _acrylic';
|
||||
el.appendChild(textBox);
|
||||
|
||||
pwsp.on('change', (a) => {
|
||||
if (pwsp.currSlide?.data.comment) {
|
||||
pswp.on('change', () => {
|
||||
if (pswp.currSlide?.data.comment) {
|
||||
textBox.style.display = '';
|
||||
} else {
|
||||
textBox.style.display = 'none';
|
||||
}
|
||||
|
||||
textBox.textContent = pwsp.currSlide?.data.comment;
|
||||
textBox.textContent = pswp.currSlide?.data.comment;
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
lightbox.init();
|
||||
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
lightbox.on('beforeOpen', () => {
|
||||
lightbox.on('afterInit', () => {
|
||||
activeEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
focusParent(activeEl, true, true);
|
||||
lightbox?.pswp?.element?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
history.pushState(null, '', '#pswp');
|
||||
});
|
||||
|
||||
lightbox.on('close', () => {
|
||||
lightbox.on('destroy', () => {
|
||||
focusParent(activeEl, true, false);
|
||||
activeEl = null;
|
||||
if (window.location.hash === '#pswp') {
|
||||
history.back();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('popstate', popstateHandler);
|
||||
|
||||
lightbox.init();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('popstate', popstateHandler);
|
||||
lightbox?.destroy();
|
||||
lightbox = null;
|
||||
activeEl = null;
|
||||
});
|
||||
|
||||
const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
||||
|
|
@ -214,6 +226,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
|||
if (isModule(file)) return true;
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
|
||||
const openGallery = () => {
|
||||
if (props.mediaList.filter(media => previewable(media)).length > 0) {
|
||||
lightbox?.loadAndOpen(0);
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openGallery,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
@ -317,7 +339,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
|||
backdrop-filter: var(--modalBgFilter);
|
||||
}
|
||||
|
||||
.pwsp__alt-text-container {
|
||||
.pswp__alt-text-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
|
@ -331,7 +353,7 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
|||
max-width: 800px;
|
||||
}
|
||||
|
||||
.pwsp__alt-text {
|
||||
.pswp__alt-text {
|
||||
color: var(--fg);
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
@contextmenu.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<button v-if="hide" :class="$style.hidden" @click="hide = false">
|
||||
<button v-if="hide" :class="$style.hidden" @click="show">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b v-if="video.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media ? ` (${i18n.ts.video}${video.size ? ' ' + bytes(video.size) : ''})` : '' }}</b>
|
||||
<b v-else style="display: block;"><i class="ti ti-movie"></i> {{ defaultStore.state.dataSaver.media && video.size ? bytes(video.size) : i18n.ts.video }}</b>
|
||||
|
|
@ -115,6 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref, shallowRef, computed, watch, onDeactivated, onActivated, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { hms } from '@/filters/hms.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
|
@ -130,32 +131,44 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
const keymap = {
|
||||
'up': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
'up': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.min(volume.value + 0.1, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
'down': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
'down': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
volume.value = Math.max(volume.value - 0.1, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'left': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
||||
}
|
||||
'left': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.max(videoEl.value.currentTime - 5, 0);
|
||||
}
|
||||
},
|
||||
},
|
||||
'right': () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
||||
}
|
||||
'right': {
|
||||
allowRepeat: true,
|
||||
callback: () => {
|
||||
if (hasFocus() && videoEl.value) {
|
||||
videoEl.value.currentTime = Math.min(videoEl.value.currentTime + 5, videoEl.value.duration);
|
||||
}
|
||||
},
|
||||
},
|
||||
'space': () => {
|
||||
if (hasFocus()) {
|
||||
togglePlayPause();
|
||||
}
|
||||
},
|
||||
};
|
||||
} as const satisfies Keymap;
|
||||
|
||||
// PlayerElもしくはその子要素にフォーカスがあるかどうか
|
||||
function hasFocus() {
|
||||
|
|
@ -163,9 +176,21 @@ function hasFocus() {
|
|||
return playerEl.value === document.activeElement || playerEl.value.contains(document.activeElement);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-destructure
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const hide = ref((defaultStore.state.nsfw === 'force' || defaultStore.state.dataSaver.media) ? true : (props.video.isSensitive && defaultStore.state.nsfw !== 'ignore'));
|
||||
|
||||
async function show() {
|
||||
if (props.video.isSensitive && defaultStore.state.confirmWhenRevealingSensitiveMedia) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.ts.sensitiveMediaRevealConfirm,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
hide.value = false;
|
||||
}
|
||||
|
||||
// Menu
|
||||
const menuShowing = ref(false);
|
||||
|
||||
|
|
@ -471,7 +496,7 @@ onDeactivated(() => {
|
|||
position: relative;
|
||||
overflow: clip;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -578,6 +603,10 @@ onDeactivated(() => {
|
|||
border-radius: 99rem;
|
||||
|
||||
font-size: 1.1rem;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.videoLoading {
|
||||
|
|
@ -641,6 +670,10 @@ onDeactivated(() => {
|
|||
&:hover {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import { nextTick, onMounted, onUnmounted, provide, shallowRef, watch } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ const props = defineProps<{
|
|||
targetElement: HTMLElement;
|
||||
rootElement: HTMLElement;
|
||||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -27,6 +26,8 @@ const emit = defineEmits<{
|
|||
(ev: 'actioned'): void;
|
||||
}>();
|
||||
|
||||
provide('isNestingMenu', true);
|
||||
|
||||
const el = shallowRef<HTMLElement>();
|
||||
const align = 'left';
|
||||
|
||||
|
|
|
|||
|
|
@ -4,23 +4,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div role="menu">
|
||||
<div role="menu" @focusin.passive.stop="() => {}">
|
||||
<div
|
||||
ref="itemsEl" v-hotkey="keymap"
|
||||
ref="itemsEl"
|
||||
v-hotkey="keymap"
|
||||
tabindex="0"
|
||||
class="_popup _shadow"
|
||||
:class="[$style.root, { [$style.center]: align === 'center', [$style.asDrawer]: asDrawer }]"
|
||||
:style="{ width: (width && !asDrawer) ? width + 'px' : '', maxHeight: maxHeight ? maxHeight + 'px' : '' }"
|
||||
@contextmenu.self="e => e.preventDefault()"
|
||||
:class="{
|
||||
[$style.root]: true,
|
||||
[$style.center]: align === 'center',
|
||||
[$style.asDrawer]: asDrawer,
|
||||
}"
|
||||
:style="{
|
||||
width: (width && !asDrawer) ? `${width}px` : '',
|
||||
maxHeight: maxHeight ? `min(${maxHeight}px, calc(100dvh - 32px))` : 'calc(100dvh - 32px)',
|
||||
}"
|
||||
@keydown.stop="() => {}"
|
||||
@contextmenu.self.prevent="() => {}"
|
||||
>
|
||||
<template v-for="(item, i) in (items2 ?? [])">
|
||||
<div v-if="item.type === 'divider'" role="separator" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
|
||||
<template v-for="item in (items2 ?? [])">
|
||||
<div v-if="item.type === 'divider'" role="separator" tabindex="-1" :class="$style.divider"></div>
|
||||
<span v-else-if="item.type === 'label'" role="menuitem" tabindex="-1" :class="[$style.label, $style.item]">
|
||||
<span style="opacity: 0.7;">{{ item.text }}</span>
|
||||
</span>
|
||||
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||
<span v-else-if="item.type === 'pending'" role="menuitem" tabindex="0" :class="[$style.pending, $style.item]">
|
||||
<span><MkEllipsis/></span>
|
||||
</span>
|
||||
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<MkA
|
||||
v-else-if="item.type === 'link'"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item]"
|
||||
:to="item.to"
|
||||
@click.passive="close(true)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<div :class="$style.item_content">
|
||||
|
|
@ -28,20 +47,49 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</MkA>
|
||||
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<a
|
||||
v-else-if="item.type === 'a'"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item]"
|
||||
:href="item.href"
|
||||
:target="item.target"
|
||||
:rel="item.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
:download="item.download"
|
||||
@click.passive="close(true)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</a>
|
||||
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button
|
||||
v-else-if="item.type === 'user'"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, { [$style.active]: item.active }]"
|
||||
@click.prevent="item.active ? close(false) : clicked(item.action, $event)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
>
|
||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||
<div v-if="item.indicate" :class="$style.item_content">
|
||||
<span :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" class="_button" :class="[$style.item, $style.switch, { [$style.switchDisabled]: item.disabled } ]" @click="switchItem(item)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button
|
||||
v-else-if="item.type === 'switch'"
|
||||
role="menuitemcheckbox"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item]"
|
||||
:disabled="unref(item.disabled)"
|
||||
@click.prevent="switchItem(item)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkSwitchButton v-else :class="$style.switchButton" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
|
||||
<div :class="$style.item_content">
|
||||
|
|
@ -49,29 +97,61 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitchButton v-if="item.icon" :class="[$style.switchButton, $style.caret]" :checked="item.ref" :disabled="item.disabled" @toggle="switchItem(item)"/>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'radio'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showRadioOptions(item, $event)" @click="!preferClick ? null : showRadioOptions(item, $event)">
|
||||
<button
|
||||
v-else-if="item.type === 'radio'"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
|
||||
:disabled="unref(item.disabled)"
|
||||
@mouseenter.prevent="preferClick ? null : showRadioOptions(item, $event)"
|
||||
@keydown.enter.prevent="preferClick ? null : showRadioOptions(item, $event)"
|
||||
@click.prevent="!preferClick ? null : showRadioOptions(item, $event)"
|
||||
>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
|
||||
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'radioOption'" :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.radioActive]: item.active }]" @click="clicked(item.action, $event, false)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button
|
||||
v-else-if="item.type === 'radioOption'"
|
||||
role="menuitemradio"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, $style.radio, { [$style.active]: unref(item.active) }]"
|
||||
@click.prevent="unref(item.active) ? null : clicked(item.action, $event, false)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
>
|
||||
<div :class="$style.icon">
|
||||
<span :class="[$style.radio, { [$style.radioChecked]: item.active }]"></span>
|
||||
<span :class="[$style.radioIcon, { [$style.radioChecked]: unref(item.active) }]"></span>
|
||||
</div>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text">{{ item.text }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else-if="item.type === 'parent'" class="_button" role="menuitem" :tabindex="i" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="preferClick ? null : showChildren(item, $event)" @click="!preferClick ? null : showChildren(item, $event)">
|
||||
<button
|
||||
v-else-if="item.type === 'parent'"
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, $style.parent, { [$style.active]: childShowingItem === item }]"
|
||||
@mouseenter.prevent="preferClick ? null : showChildren(item, $event)"
|
||||
@keydown.enter.prevent="preferClick ? null : showChildren(item, $event)"
|
||||
@click.prevent="!preferClick ? null : showChildren(item, $event)"
|
||||
>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]" style="pointer-events: none;"></i>
|
||||
<div :class="$style.item_content">
|
||||
<span :class="$style.item_content_text" style="pointer-events: none;">{{ item.text }}</span>
|
||||
<span :class="$style.caret" style="pointer-events: none;"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||
</div>
|
||||
</button>
|
||||
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: getValue(item.active) }]" :disabled="getValue(item.active)" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||
<button
|
||||
v-else role="menuitem"
|
||||
tabindex="0"
|
||||
:class="['_button', $style.item, { [$style.danger]: item.danger, [$style.active]: unref(item.active) }]"
|
||||
@click.prevent="unref(item.active) ? close(false) : clicked(item.action, $event)"
|
||||
@mouseenter.passive="onItemMouseEnter"
|
||||
@mouseleave.passive="onItemMouseLeave"
|
||||
>
|
||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||
<div :class="$style.item_content">
|
||||
|
|
@ -80,24 +160,26 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</button>
|
||||
</template>
|
||||
<span v-if="items2 == null || items2.length === 0" :class="[$style.none, $style.item]">
|
||||
<span v-if="items2 == null || items2.length === 0" tabindex="-1" :class="[$style.none, $style.item]">
|
||||
<span>{{ i18n.ts.none }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="childMenu">
|
||||
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" showing @actioned="childActioned" @close="close(false)"/>
|
||||
<XChild ref="child" :items="childMenu" :targetElement="childTarget!" :rootElement="itemsEl!" @actioned="childActioned" @closed="closeChild"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ComputedRef, computed, defineAsyncComponent, isRef, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
||||
import { computed, defineAsyncComponent, inject, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, unref, watch } from 'vue';
|
||||
import MkSwitchButton from '@/components/MkSwitch.button.vue';
|
||||
import { MenuItem, InnerMenuItem, MenuPending, MenuAction, MenuSwitch, MenuRadio, MenuRadioOption, MenuParent } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { isTouchUsing } from '@/scripts/touch.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
import { isFocusable } from '@/scripts/focus.js';
|
||||
import { getNodeOrNull } from '@/scripts/get-dom-node-or-null.js';
|
||||
|
||||
const childrenCache = new WeakMap<MenuParent, MenuItem[]>();
|
||||
</script>
|
||||
|
|
@ -107,7 +189,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
|
|||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
viaKeyboard?: boolean;
|
||||
asDrawer?: boolean;
|
||||
align?: 'center' | string;
|
||||
width?: number;
|
||||
|
|
@ -119,17 +200,28 @@ const emit = defineEmits<{
|
|||
(ev: 'hide'): void;
|
||||
}>();
|
||||
|
||||
const itemsEl = shallowRef<HTMLDivElement>();
|
||||
const isNestingMenu = inject<boolean>('isNestingMenu', false);
|
||||
|
||||
const itemsEl = shallowRef<HTMLElement>();
|
||||
|
||||
const items2 = ref<InnerMenuItem[]>();
|
||||
|
||||
const child = shallowRef<InstanceType<typeof XChild>>();
|
||||
|
||||
const keymap = computed(() => ({
|
||||
'up|k|shift+tab': focusUp,
|
||||
'down|j|tab': focusDown,
|
||||
'esc': close,
|
||||
}));
|
||||
const keymap = {
|
||||
'up|k|shift+tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusUp(),
|
||||
},
|
||||
'down|j|tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusDown(),
|
||||
},
|
||||
'esc': {
|
||||
allowRepeat: true,
|
||||
callback: () => close(false),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
const childShowingItem = ref<MenuItem | null>();
|
||||
|
||||
|
|
@ -167,25 +259,19 @@ function childActioned() {
|
|||
close(true);
|
||||
}
|
||||
|
||||
const onGlobalMousedown = (event: MouseEvent) => {
|
||||
if (childTarget.value && (event.target === childTarget.value || childTarget.value.contains(event.target as Node))) return;
|
||||
if (child.value && child.value.checkHit(event)) return;
|
||||
closeChild();
|
||||
};
|
||||
|
||||
let childCloseTimer: null | number = null;
|
||||
|
||||
function onItemMouseEnter(item) {
|
||||
function onItemMouseEnter() {
|
||||
childCloseTimer = window.setTimeout(() => {
|
||||
closeChild();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function onItemMouseLeave(item) {
|
||||
function onItemMouseLeave() {
|
||||
if (childCloseTimer) window.clearTimeout(childCloseTimer);
|
||||
}
|
||||
|
||||
async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
|
||||
async function showRadioOptions(item: MenuRadio, ev: Event) {
|
||||
const children: MenuItem[] = Object.keys(item.options).map<MenuRadioOption>(key => {
|
||||
const value = item.options[key];
|
||||
return {
|
||||
|
|
@ -200,7 +286,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
|
|||
|
||||
if (props.asDrawer) {
|
||||
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
|
||||
emit('close');
|
||||
close(false);
|
||||
});
|
||||
emit('hide');
|
||||
} else {
|
||||
|
|
@ -210,7 +296,7 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
async function showChildren(item: MenuParent, ev: MouseEvent) {
|
||||
async function showChildren(item: MenuParent, ev: Event) {
|
||||
const children: MenuItem[] = await (async () => {
|
||||
if (childrenCache.has(item)) {
|
||||
return childrenCache.get(item)!;
|
||||
|
|
@ -227,7 +313,7 @@ async function showChildren(item: MenuParent, ev: MouseEvent) {
|
|||
|
||||
if (props.asDrawer) {
|
||||
os.popupMenu(children, ev.currentTarget ?? ev.target).finally(() => {
|
||||
emit('close');
|
||||
close(false);
|
||||
});
|
||||
emit('hide');
|
||||
} else {
|
||||
|
|
@ -246,15 +332,11 @@ function clicked(fn: MenuAction, ev: MouseEvent, doClose = true) {
|
|||
}
|
||||
|
||||
function close(actioned = false) {
|
||||
emit('close', actioned);
|
||||
}
|
||||
|
||||
function focusUp() {
|
||||
focusPrev(document.activeElement);
|
||||
}
|
||||
|
||||
function focusDown() {
|
||||
focusNext(document.activeElement);
|
||||
disposeHandlers();
|
||||
nextTick(() => {
|
||||
closeChild();
|
||||
emit('close', actioned);
|
||||
});
|
||||
}
|
||||
|
||||
function switchItem(item: MenuSwitch & { ref: any }) {
|
||||
|
|
@ -262,25 +344,75 @@ function switchItem(item: MenuSwitch & { ref: any }) {
|
|||
item.ref = !item.ref;
|
||||
}
|
||||
|
||||
function getValue<T>(item?: ComputedRef<T> | T) {
|
||||
return isRef(item) ? item.value : item;
|
||||
function focusUp() {
|
||||
if (disposed) return;
|
||||
if (!itemsEl.value?.contains(document.activeElement)) return;
|
||||
|
||||
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
|
||||
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
|
||||
const targetIndex = (activeIndex !== -1 && activeIndex !== 0) ? (activeIndex - 1) : (focusableElements.length - 1);
|
||||
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
|
||||
|
||||
targetElement.focus();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.viaKeyboard) {
|
||||
nextTick(() => {
|
||||
if (itemsEl.value) focusNext(itemsEl.value.children[0], true, false);
|
||||
});
|
||||
function focusDown() {
|
||||
if (disposed) return;
|
||||
if (!itemsEl.value?.contains(document.activeElement)) return;
|
||||
|
||||
const focusableElements = Array.from(itemsEl.value.children).filter(isFocusable);
|
||||
const activeIndex = focusableElements.findIndex(el => el === document.activeElement);
|
||||
const targetIndex = (activeIndex !== -1 && activeIndex !== (focusableElements.length - 1)) ? (activeIndex + 1) : 0;
|
||||
const targetElement = focusableElements.at(targetIndex) ?? itemsEl.value;
|
||||
|
||||
targetElement.focus();
|
||||
}
|
||||
|
||||
const onGlobalFocusin = (ev: FocusEvent) => {
|
||||
if (disposed) return;
|
||||
if (itemsEl.value?.parentElement?.contains(getNodeOrNull(ev.target))) return;
|
||||
nextTick(() => {
|
||||
if (itemsEl.value != null && isFocusable(itemsEl.value)) {
|
||||
itemsEl.value.focus({ preventScroll: true });
|
||||
nextTick(() => focusDown());
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onGlobalMousedown = (ev: MouseEvent) => {
|
||||
if (disposed) return;
|
||||
if (childTarget.value?.contains(getNodeOrNull(ev.target))) return;
|
||||
if (child.value?.checkHit(ev)) return;
|
||||
closeChild();
|
||||
};
|
||||
|
||||
const setupHandlers = () => {
|
||||
if (!isNestingMenu) {
|
||||
document.addEventListener('focusin', onGlobalFocusin, { passive: true });
|
||||
}
|
||||
|
||||
// TODO: アクティブな要素までスクロール
|
||||
//itemsEl.scrollTo();
|
||||
|
||||
document.addEventListener('mousedown', onGlobalMousedown, { passive: true });
|
||||
};
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const disposeHandlers = () => {
|
||||
disposed = true;
|
||||
if (!isNestingMenu) {
|
||||
document.removeEventListener('focusin', onGlobalFocusin);
|
||||
}
|
||||
document.removeEventListener('mousedown', onGlobalMousedown);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupHandlers();
|
||||
|
||||
if (!isNestingMenu) {
|
||||
nextTick(() => itemsEl.value?.focus({ preventScroll: true }));
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onGlobalMousedown);
|
||||
disposeHandlers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -293,6 +425,10 @@ onBeforeUnmount(() => {
|
|||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&.center {
|
||||
> .item {
|
||||
text-align: center;
|
||||
|
|
@ -310,7 +446,7 @@ onBeforeUnmount(() => {
|
|||
font-size: 1em;
|
||||
padding: 12px 24px;
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
width: calc(100% - 24px);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
|
@ -340,8 +476,10 @@ onBeforeUnmount(() => {
|
|||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-decoration: none !important;
|
||||
color: var(--menuFg, var(--fg));
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
@ -355,56 +493,56 @@ onBeforeUnmount(() => {
|
|||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
&:not(:disabled):hover {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&:before {
|
||||
background: var(--accentedBg);
|
||||
&:not(:hover):not(:active)::before {
|
||||
outline: var(--focus) solid 2px;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
&:hover,
|
||||
&:focus-visible:active,
|
||||
&:focus-visible.active {
|
||||
color: var(--menuHoverFg, var(--accent));
|
||||
|
||||
&::before {
|
||||
background-color: var(--menuHoverBg, var(--accentedBg));
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:focus-visible):active,
|
||||
&:not(:focus-visible).active {
|
||||
color: var(--menuActiveFg, var(--fgOnAccent));
|
||||
|
||||
&::before {
|
||||
background-color: var(--menuActiveBg, var(--accent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff2a2a;
|
||||
|
||||
&:hover {
|
||||
color: #fff;
|
||||
|
||||
&:before {
|
||||
background: #ff4242;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: #fff;
|
||||
|
||||
&:before {
|
||||
background: #d42e2e !important;
|
||||
}
|
||||
}
|
||||
--menuFg: #ff2a2a;
|
||||
--menuHoverFg: #fff;
|
||||
--menuHoverBg: #ff4242;
|
||||
--menuActiveFg: #fff;
|
||||
--menuActiveBg: #d42e2e;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
color: var(--fgOnAccent) !important;
|
||||
opacity: 1;
|
||||
|
||||
&:before {
|
||||
background: var(--accent) !important;
|
||||
}
|
||||
&.radio {
|
||||
--menuActiveFg: var(--accent);
|
||||
--menuActiveBg: var(--accentedBg);
|
||||
}
|
||||
|
||||
&.radioActive {
|
||||
color: var(--accent) !important;
|
||||
opacity: 1;
|
||||
|
||||
&:before {
|
||||
background-color: var(--accentedBg) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active):focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus) inset;
|
||||
&.parent {
|
||||
--menuActiveFg: var(--accent);
|
||||
--menuActiveBg: var(--accentedBg);
|
||||
}
|
||||
|
||||
&.label {
|
||||
|
|
@ -422,22 +560,6 @@ onBeforeUnmount(() => {
|
|||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&.parent {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: default;
|
||||
|
||||
&.childShowing {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
|
||||
&:before {
|
||||
background: var(--accentedBg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item_content {
|
||||
|
|
@ -456,18 +578,6 @@ onBeforeUnmount(() => {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.switch {
|
||||
position: relative;
|
||||
display: flex;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchDisabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.switchButton {
|
||||
margin-left: -2px;
|
||||
--height: 1.35em;
|
||||
|
|
@ -479,14 +589,6 @@ onBeforeUnmount(() => {
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.switchInput {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
line-height: 1;
|
||||
|
|
@ -515,12 +617,12 @@ onBeforeUnmount(() => {
|
|||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
.radio {
|
||||
.radioIcon {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: -.125em;
|
||||
vertical-align: -0.125em;
|
||||
border-radius: 50%;
|
||||
border: solid 2px var(--divider);
|
||||
background-color: var(--panel);
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.transition_modal_leaveTo]: transitionName === 'modal',
|
||||
[$style.transition_send_leaveTo]: transitionName === 'send',
|
||||
})"
|
||||
:duration="transitionDuration" appear @afterLeave="emit('closed')" @enter="emit('opening')" @afterEnter="onOpened"
|
||||
:duration="transitionDuration" appear @afterLeave="onClosed" @enter="emit('opening')" @afterEnter="onOpened"
|
||||
>
|
||||
<div v-show="manualShowing != null ? manualShowing : showing" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
|
||||
<div v-show="manualShowing != null ? manualShowing : showing" ref="modalRootEl" v-hotkey.global="keymap" :class="[$style.root, { [$style.drawer]: type === 'drawer', [$style.dialog]: type === 'dialog', [$style.popup]: type === 'popup' }]" :style="{ zIndex, pointerEvents: (manualShowing != null ? manualShowing : showing) ? 'auto' : 'none', '--transformOrigin': transformOrigin }">
|
||||
<div data-cy-bg :data-cy-transparent="isEnableBgTransparent" class="_modalBg" :class="[$style.bg, { [$style.bgTransparent]: isEnableBgTransparent }]" :style="{ zIndex }" @click="onBgClick" @mousedown="onBgClick" @contextmenu.prevent.stop="() => {}"></div>
|
||||
<div ref="content" :class="[$style.content, { [$style.fixed]: fixed }]" :style="{ zIndex }" @click.self="onBgClick">
|
||||
<slot :max-height="maxHeight" :type="type"></slot>
|
||||
|
|
@ -47,6 +47,9 @@ import * as os from '@/os.js';
|
|||
import { isTouchUsing } from '@/scripts/touch.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
import { focusTrap } from '@/scripts/focus-trap.js';
|
||||
import { focusParent } from '@/scripts/focus.js';
|
||||
|
||||
function getFixedContainer(el: Element | null): Element | null {
|
||||
if (el == null || el.tagName === 'BODY') return null;
|
||||
|
|
@ -68,6 +71,8 @@ const props = withDefaults(defineProps<{
|
|||
zPriority?: 'low' | 'middle' | 'high';
|
||||
noOverlap?: boolean;
|
||||
transparentBg?: boolean;
|
||||
hasInteractionWithOtherFocusTrappedEls?: boolean;
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}>(), {
|
||||
manualShowing: null,
|
||||
src: null,
|
||||
|
|
@ -76,6 +81,8 @@ const props = withDefaults(defineProps<{
|
|||
zPriority: 'low',
|
||||
noOverlap: true,
|
||||
transparentBg: false,
|
||||
hasInteractionWithOtherFocusTrappedEls: false,
|
||||
returnFocusTo: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -93,6 +100,7 @@ const maxHeight = ref<number>();
|
|||
const fixed = ref(false);
|
||||
const transformOrigin = ref('center');
|
||||
const showing = ref(true);
|
||||
const modalRootEl = shallowRef<HTMLElement>();
|
||||
const content = shallowRef<HTMLElement>();
|
||||
const zIndex = os.claimZIndex(props.zPriority);
|
||||
const useSendAnime = ref(false);
|
||||
|
|
@ -131,6 +139,7 @@ const transitionDuration = computed((() =>
|
|||
: 0
|
||||
));
|
||||
|
||||
let releaseFocusTrap: (() => void) | null = null;
|
||||
let contentClicking = false;
|
||||
|
||||
function close(opts: { useSendAnimation?: boolean } = {}) {
|
||||
|
|
@ -154,8 +163,11 @@ if (type.value === 'drawer') {
|
|||
}
|
||||
|
||||
const keymap = {
|
||||
'esc': () => emit('esc'),
|
||||
};
|
||||
'esc': {
|
||||
allowRepeat: true,
|
||||
callback: () => emit('esc'),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
const MARGIN = 16;
|
||||
const SCROLLBAR_THICKNESS = 16;
|
||||
|
|
@ -292,6 +304,10 @@ const onOpened = () => {
|
|||
}, { passive: true });
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
emit('closed');
|
||||
};
|
||||
|
||||
const alignObserver = new ResizeObserver((entries, observer) => {
|
||||
align();
|
||||
});
|
||||
|
|
@ -309,6 +325,20 @@ onMounted(() => {
|
|||
align();
|
||||
}, { immediate: true });
|
||||
|
||||
watch([showing, () => props.manualShowing], ([showing, manualShowing]) => {
|
||||
if (manualShowing === true || (manualShowing == null && showing === true)) {
|
||||
if (modalRootEl.value != null) {
|
||||
const { release } = focusTrap(modalRootEl.value, props.hasInteractionWithOtherFocusTrappedEls);
|
||||
|
||||
releaseFocusTrap = release;
|
||||
modalRootEl.value.focus();
|
||||
}
|
||||
} else {
|
||||
releaseFocusTrap?.();
|
||||
focusParent(props.returnFocusTo ?? props.src, true, false);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
nextTick(() => {
|
||||
alignObserver.observe(content.value!);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="$emit('closed')">
|
||||
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }" @keydown="onKeydown">
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
|
||||
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
|
||||
<div ref="headerEl" :class="$style.header">
|
||||
<button v-if="withOkButton" :class="$style.headerButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
|
||||
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
|
||||
<span :class="$style.title">
|
||||
<slot name="header"></slot>
|
||||
</span>
|
||||
<button v-if="!withOkButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="$emit('close')"><i class="ti ti-x"></i></button>
|
||||
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="ti ti-check"></i></button>
|
||||
<button v-if="!withOkButton && withCloseButton" :class="$style.headerButton" class="_button" data-cy-modal-window-close @click="emit('close')"><i class="ti ti-x"></i></button>
|
||||
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
|
||||
</div>
|
||||
<div :class="$style.body">
|
||||
<slot :width="bodyWidth" :height="bodyHeight"></slot>
|
||||
|
|
@ -27,11 +27,13 @@ import MkModal from './MkModal.vue';
|
|||
|
||||
const props = withDefaults(defineProps<{
|
||||
withOkButton: boolean;
|
||||
withCloseButton: boolean;
|
||||
okButtonDisabled: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}>(), {
|
||||
withOkButton: false,
|
||||
withCloseButton: true,
|
||||
okButtonDisabled: false,
|
||||
width: 400,
|
||||
height: 500,
|
||||
|
|
@ -42,6 +44,7 @@ const emit = defineEmits<{
|
|||
(event: 'close'): void;
|
||||
(event: 'closed'): void;
|
||||
(event: 'ok'): void;
|
||||
(event: 'esc'): void;
|
||||
}>();
|
||||
|
||||
const modal = shallowRef<InstanceType<typeof MkModal>>();
|
||||
|
|
@ -50,21 +53,13 @@ const headerEl = shallowRef<HTMLElement>();
|
|||
const bodyWidth = ref(0);
|
||||
const bodyHeight = ref(0);
|
||||
|
||||
const close = () => {
|
||||
function close() {
|
||||
modal.value?.close();
|
||||
};
|
||||
}
|
||||
|
||||
const onBgClick = () => {
|
||||
function onBgClick() {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const onKeydown = (evt) => {
|
||||
if (evt.which === 27) { // Esc
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
close();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver((entries, observer) => {
|
||||
if (rootEl.value == null || headerEl.value == null) return;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
:class="[$style.root, { [$style.showActionsOnlyHover]: defaultStore.state.showNoteActionsOnlyHover }]"
|
||||
:tabindex="!isDeleted ? '-1' : undefined"
|
||||
:tabindex="isDeleted ? '-1' : '0'"
|
||||
>
|
||||
<div v-if="appearNote.reply && inReplyToCollapsed" :class="$style.collapsedInReplyTo">
|
||||
<MkAvatar :class="$style.collapsedInReplyToAvatar" :user="appearNote.reply.user" link preview/>
|
||||
|
|
@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</I18n>
|
||||
<div :class="$style.renoteInfo">
|
||||
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @click="showRenoteMenu()">
|
||||
<button ref="renoteTime" :class="$style.renoteTime" class="_button" @mousedown.prevent="showRenoteMenu()">
|
||||
<i class="ti ti-dots" :class="$style.renoteMenu"></i>
|
||||
<MkTime :time="note.createdAt"/>
|
||||
</button>
|
||||
|
|
@ -92,7 +92,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
</div>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList :mediaList="appearNote.files" @click.stop/>
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll" @click.stop/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
|
|
@ -126,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
class="_button"
|
||||
:style="renoted ? 'color: var(--accent) !important;' : ''"
|
||||
@click.stop
|
||||
@mousedown="renoted ? undoRenote(appearNote) : boostVisibility()"
|
||||
@mousedown.prevent="renoted ? undoRenote(appearNote) : boostVisibility()"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -154,10 +154,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else class="ph-smiley ph-bold ph-lg"></i>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.reactionCount) }}</p>
|
||||
</button>
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown="clip()">
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" :class="$style.footerButton" class="_button" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown="showMenu()">
|
||||
<button ref="menuButton" :class="$style.footerButton" class="_button" @mousedown.prevent="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -204,8 +204,7 @@ import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
|||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
||||
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { checkWordMute } from '@/scripts/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import number from '@/filters/number.js';
|
||||
|
|
@ -231,7 +230,10 @@ import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
|||
import { shouldCollapsed } from '@/scripts/collapsed.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
|
||||
import { host } from '@/config.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -302,7 +304,7 @@ const quoteButton = shallowRef<HTMLElement>();
|
|||
const clipButton = shallowRef<HTMLElement>();
|
||||
const likeButton = shallowRef<HTMLElement>();
|
||||
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
|
||||
|
||||
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(defaultStore.state.uncollapseCW);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
|
|
@ -328,6 +330,11 @@ const defaultLike = computed(() => defaultStore.state.like ? defaultStore.state.
|
|||
const animated = computed(() => parsed.value ? checkAnimationFromMfm(parsed.value) : null);
|
||||
const allowAnim = ref(defaultStore.state.advancedMfm && defaultStore.state.animatedMfm ? true : false);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
|
||||
|
|
@ -348,15 +355,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
|
|||
let renoting = false;
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
'e|a|plus': () => react(true),
|
||||
'(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
|
||||
'up|k|shift+tab': focusBefore,
|
||||
'down|j|tab': focusAfter,
|
||||
'esc': blur,
|
||||
'm|o': () => showMenu(true),
|
||||
's': () => showContent.value !== showContent.value,
|
||||
};
|
||||
'r': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
reply();
|
||||
},
|
||||
'e|a|plus': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
react();
|
||||
},
|
||||
'q': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost);
|
||||
},
|
||||
'm': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
showMenu();
|
||||
},
|
||||
'c': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
if (!defaultStore.state.showClipButtonInNoteFooter) return;
|
||||
clip();
|
||||
},
|
||||
'o': () => {
|
||||
if (renoteCollapsed.value) return;
|
||||
galleryEl.value?.openGallery();
|
||||
},
|
||||
'v|enter': () => {
|
||||
if (renoteCollapsed.value) {
|
||||
renoteCollapsed.value = false;
|
||||
} else if (appearNote.value.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
} else if (isLong) {
|
||||
collapsed.value = !collapsed.value;
|
||||
}
|
||||
},
|
||||
'esc': {
|
||||
allowRepeat: true,
|
||||
callback: () => blur(),
|
||||
},
|
||||
'up|k|shift+tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusBefore(),
|
||||
},
|
||||
'down|j|tab': {
|
||||
allowRepeat: true,
|
||||
callback: () => focusAfter(),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
provide('react', (reaction: string) => {
|
||||
misskeyApi('notes/reactions/create', {
|
||||
|
|
@ -389,12 +434,14 @@ if (!props.mock) {
|
|||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
useTooltip(quoteButton, async (showing) => {
|
||||
|
|
@ -408,12 +455,14 @@ if (!props.mock) {
|
|||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
targetElement: quoteButton.value,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
if ($i) {
|
||||
|
|
@ -438,13 +487,15 @@ if (!props.mock) {
|
|||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkReactionsViewerDetails, {
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
targetElement: reactButton.value!,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -460,7 +511,7 @@ function boostVisibility() {
|
|||
}
|
||||
|
||||
function renote(visibility: Visibility, localOnly: boolean = false) {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
|
||||
renoting = true;
|
||||
|
|
@ -471,7 +522,9 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.mock) {
|
||||
|
|
@ -489,7 +542,9 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.mock) {
|
||||
|
|
@ -506,7 +561,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
}
|
||||
|
||||
function quote() {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
if (props.mock) {
|
||||
return;
|
||||
|
|
@ -529,7 +584,9 @@ function quote() {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
os.toast(i18n.ts.quoted);
|
||||
|
|
@ -551,7 +608,9 @@ function quote() {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
os.toast(i18n.ts.quoted);
|
||||
|
|
@ -560,22 +619,21 @@ function quote() {
|
|||
}
|
||||
}
|
||||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
function reply(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
channel: appearNote.value.channel,
|
||||
animation: !viaKeyboard,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
function like(): void {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
sound.playMisskeySfx('reaction');
|
||||
if (props.mock) {
|
||||
|
|
@ -590,12 +648,14 @@ function like(): void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
|
@ -613,7 +673,9 @@ function react(viaKeyboard = false): void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
|
|
@ -667,7 +729,9 @@ function undoRenote(note) : void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -706,15 +770,13 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
}
|
||||
}
|
||||
|
||||
function showMenu(viaKeyboard = false): void {
|
||||
function showMenu(): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted, currentClip: currentClip?.value });
|
||||
os.popupMenu(menu, menuButton.value, {
|
||||
viaKeyboard,
|
||||
}).then(focus).finally(cleanup);
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function menuVersions(viaKeyboard = false): Promise<void> {
|
||||
|
|
@ -724,7 +786,7 @@ async function menuVersions(viaKeyboard = false): Promise<void> {
|
|||
}).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip() {
|
||||
async function clip(): Promise<void> {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -732,7 +794,7 @@ async function clip() {
|
|||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted, currentClip: currentClip?.value }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(viaKeyboard = false): void {
|
||||
function showRenoteMenu(): void {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -752,23 +814,19 @@ function showRenoteMenu(viaKeyboard = false): void {
|
|||
}
|
||||
|
||||
if (isMyRenote) {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
os.popupMenu([
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getUnrenote(),
|
||||
], renoteTime.value, {
|
||||
viaKeyboard: viaKeyboard,
|
||||
});
|
||||
], renoteTime.value);
|
||||
} else {
|
||||
os.popupMenu([
|
||||
getCopyNoteLinkMenu(note.value, i18n.ts.copyLinkRenote),
|
||||
{ type: 'divider' },
|
||||
getAbuseNoteMenu(note.value, i18n.ts.reportAbuseRenote),
|
||||
($i?.isModerator || $i?.isAdmin) ? getUnrenote() : undefined,
|
||||
], renoteTime.value, {
|
||||
viaKeyboard: viaKeyboard,
|
||||
});
|
||||
], renoteTime.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -793,11 +851,11 @@ function blur() {
|
|||
}
|
||||
|
||||
function focusBefore() {
|
||||
focusPrev(rootEl.value ?? null);
|
||||
focusPrev(rootEl.value);
|
||||
}
|
||||
|
||||
function focusAfter() {
|
||||
focusNext(rootEl.value ?? null);
|
||||
focusNext(rootEl.value);
|
||||
}
|
||||
|
||||
function readPromo() {
|
||||
|
|
@ -835,7 +893,7 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
|
|
@ -848,7 +906,7 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
margin: auto;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% - 8px);
|
||||
border: dashed 1px var(--focus);
|
||||
border: dashed 2px var(--focus);
|
||||
border-radius: var(--radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="rootEl"
|
||||
v-hotkey="keymap"
|
||||
:class="$style.root"
|
||||
:tabindex="isDeleted ? '-1' : '0'"
|
||||
>
|
||||
<div v-if="appearNote.reply && appearNote.reply.replyId">
|
||||
<div v-if="!conversationLoaded" style="padding: 16px">
|
||||
|
|
@ -31,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</I18n>
|
||||
</span>
|
||||
<div :class="$style.renoteInfo">
|
||||
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @click="showRenoteMenu()">
|
||||
<button ref="renoteTime" class="_button" :class="$style.renoteTime" @mousedown.prevent="showRenoteMenu()">
|
||||
<i v-if="isMyRenote" class="ti ti-dots" style="margin-right: 4px;"></i>
|
||||
<MkTime :time="note.createdAt"/>
|
||||
</button>
|
||||
|
|
@ -97,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton v-if="!allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-play ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.play }}</MkButton>
|
||||
<MkButton v-else-if="!defaultStore.state.animatedMfm && allowAnim && animated" :class="$style.playMFMButton" :small="true" @click="animatedMFM()" @click.stop><i class="ph-stop ph-bold ph-lg "></i> {{ i18n.ts._animatedMFM.stop }}</MkButton>
|
||||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList :mediaList="appearNote.files"/>
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
|
|
@ -127,7 +128,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
:style="renoted ? 'color: var(--accent) !important;' : ''"
|
||||
@mousedown="renoted ? undoRenote() : boostVisibility()"
|
||||
@mousedown.prevent="renoted ? undoRenote() : boostVisibility()"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -154,10 +155,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else class="ph-smiley ph-bold ph-lg"></i>
|
||||
<p v-if="(appearNote.reactionAcceptance === 'likeOnly' || defaultStore.state.showReactionsCount) && appearNote.reactionCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.reactionCount) }}</p>
|
||||
</button>
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown="clip()">
|
||||
<button v-if="defaultStore.state.showClipButtonInNoteFooter" ref="clipButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="clip()">
|
||||
<i class="ti ti-paperclip"></i>
|
||||
</button>
|
||||
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown="showMenu()">
|
||||
<button ref="menuButton" class="_button" :class="$style.noteFooterButton" @mousedown.prevent="showMenu()">
|
||||
<i class="ti ti-dots"></i>
|
||||
</button>
|
||||
</footer>
|
||||
|
|
@ -236,7 +237,7 @@ import MkPoll from '@/components/MkPoll.vue';
|
|||
import MkUsersTooltip from '@/components/MkUsersTooltip.vue';
|
||||
import MkUrlPreview from '@/components/MkUrlPreview.vue';
|
||||
import MkInstanceTicker from '@/components/MkInstanceTicker.vue';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { pleaseLogin, type OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { checkWordMute } from '@/scripts/check-word-mute.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
|
|
@ -249,6 +250,7 @@ import { reactionPicker } from '@/scripts/reaction-picker.js';
|
|||
import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host } from '@/config.js';
|
||||
import { getNoteClipMenu, getNoteMenu } from '@/scripts/get-note-menu.js';
|
||||
import { getNoteVersionsMenu } from '@/scripts/get-note-versions-menu.js';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture.js';
|
||||
|
|
@ -264,6 +266,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
|||
import MkButton from '@/components/MkButton.vue';
|
||||
import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -315,6 +318,7 @@ const quoteButton = shallowRef<HTMLElement>();
|
|||
const clipButton = shallowRef<HTMLElement>();
|
||||
const likeButton = shallowRef<HTMLElement>();
|
||||
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
|
||||
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(defaultStore.state.uncollapseCW);
|
||||
const isDeleted = ref(false);
|
||||
|
|
@ -349,14 +353,31 @@ if ($i) {
|
|||
|
||||
let renoting = false;
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
'e|a|plus': () => react(true),
|
||||
'(q)': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
|
||||
'esc': blur,
|
||||
'm|o': () => showMenu(true),
|
||||
's': () => showContent.value !== showContent.value,
|
||||
};
|
||||
'r': () => reply(),
|
||||
'e|a|plus': () => react(),
|
||||
'q': () => { if (canRenote.value && !renoted.value && !renoting) renote(defaultStore.state.visibilityOnBoost); },
|
||||
'm': () => showMenu(),
|
||||
'c': () => {
|
||||
if (!defaultStore.state.showClipButtonInNoteFooter) return;
|
||||
clip();
|
||||
},
|
||||
'o': () => galleryEl.value?.openGallery(),
|
||||
'v|enter': () => {
|
||||
if (appearNote.value.cw != null) {
|
||||
showContent.value = !showContent.value;
|
||||
}
|
||||
},
|
||||
'esc': {
|
||||
allowRepeat: true,
|
||||
callback: () => blur(),
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
|
||||
provide('react', (reaction: string) => {
|
||||
misskeyApi('notes/reactions/create', {
|
||||
|
|
@ -416,12 +437,14 @@ useTooltip(renoteButton, async (showing) => {
|
|||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
targetElement: renoteButton.value,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
useTooltip(quoteButton, async (showing) => {
|
||||
|
|
@ -435,12 +458,14 @@ useTooltip(quoteButton, async (showing) => {
|
|||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkUsersTooltip, {
|
||||
const { dispose } = os.popup(MkUsersTooltip, {
|
||||
showing,
|
||||
users,
|
||||
count: appearNote.value.renoteCount,
|
||||
targetElement: quoteButton.value,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
|
||||
function boostVisibility() {
|
||||
|
|
@ -465,18 +490,20 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
|||
|
||||
if (users.length < 1) return;
|
||||
|
||||
os.popup(MkReactionsViewerDetails, {
|
||||
const { dispose } = os.popup(MkReactionsViewerDetails, {
|
||||
showing,
|
||||
reaction: '❤️',
|
||||
users,
|
||||
count: appearNote.value.reactionCount,
|
||||
targetElement: reactButton.value!,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renote(visibility: Visibility, localOnly: boolean = false) {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
|
||||
renoting = true;
|
||||
|
|
@ -487,7 +514,9 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
misskeyApi('notes/create', {
|
||||
|
|
@ -503,7 +532,9 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
misskeyApi('notes/create', {
|
||||
|
|
@ -518,7 +549,7 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
}
|
||||
|
||||
function quote() {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
|
||||
if (appearNote.value.channel) {
|
||||
|
|
@ -538,7 +569,9 @@ function quote() {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
os.toast(i18n.ts.quoted);
|
||||
|
|
@ -560,7 +593,9 @@ function quote() {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
os.toast(i18n.ts.quoted);
|
||||
|
|
@ -569,20 +604,19 @@ function quote() {
|
|||
}
|
||||
}
|
||||
|
||||
function reply(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
function reply(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
os.post({
|
||||
reply: appearNote.value,
|
||||
channel: appearNote.value.channel,
|
||||
animation: !viaKeyboard,
|
||||
}).then(() => {
|
||||
focus();
|
||||
});
|
||||
}
|
||||
|
||||
function react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
function react(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
|
@ -591,12 +625,14 @@ function react(viaKeyboard = false): void {
|
|||
noteId: appearNote.value.id,
|
||||
override: defaultLike.value,
|
||||
});
|
||||
const el = reactButton.value as HTMLElement | null | undefined;
|
||||
const el = reactButton.value;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
|
|
@ -617,7 +653,7 @@ function react(viaKeyboard = false): void {
|
|||
}
|
||||
|
||||
function like(): void {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
sound.playMisskeySfx('reaction');
|
||||
misskeyApi('notes/like', {
|
||||
|
|
@ -629,7 +665,9 @@ function like(): void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -654,7 +692,9 @@ function undoRenote() : void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -687,27 +727,23 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
}
|
||||
}
|
||||
|
||||
function showMenu(viaKeyboard = false): void {
|
||||
function showMenu(): void {
|
||||
const { menu, cleanup } = getNoteMenu({ note: note.value, translating, translation, isDeleted });
|
||||
os.popupMenu(menu, menuButton.value, {
|
||||
viaKeyboard,
|
||||
}).then(focus).finally(cleanup);
|
||||
os.popupMenu(menu, menuButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function menuVersions(viaKeyboard = false): Promise<void> {
|
||||
async function menuVersions(): Promise<void> {
|
||||
const { menu, cleanup } = await getNoteVersionsMenu({ note: note.value, menuVersionsButton });
|
||||
os.popupMenu(menu, menuVersionsButton.value, {
|
||||
viaKeyboard,
|
||||
}).then(focus).finally(cleanup);
|
||||
os.popupMenu(menu, menuVersionsButton.value).then(focus).finally(cleanup);
|
||||
}
|
||||
|
||||
async function clip() {
|
||||
async function clip(): Promise<void> {
|
||||
os.popupMenu(await getNoteClipMenu({ note: note.value, isDeleted }), clipButton.value).then(focus);
|
||||
}
|
||||
|
||||
function showRenoteMenu(viaKeyboard = false): void {
|
||||
function showRenoteMenu(): void {
|
||||
if (!isMyRenote) return;
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.unrenote,
|
||||
icon: 'ti ti-trash',
|
||||
|
|
@ -718,9 +754,7 @@ function showRenoteMenu(viaKeyboard = false): void {
|
|||
});
|
||||
isDeleted.value = true;
|
||||
},
|
||||
}], renoteTime.value, {
|
||||
viaKeyboard: viaKeyboard,
|
||||
});
|
||||
}], renoteTime.value);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
|
|
@ -794,6 +828,28 @@ function animatedMFM() {
|
|||
transition: box-shadow 0.1s ease;
|
||||
overflow: clip;
|
||||
contain: content;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
width: calc(100% - 8px);
|
||||
height: calc(100% - 8px);
|
||||
border: dashed 2px var(--focus);
|
||||
border-radius: var(--radius);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkAvatar :class="$style.avatar" :user="user" link preview/>
|
||||
<MkAvatar :class="$style.avatar" :user="user"/>
|
||||
<div :class="$style.main">
|
||||
<div :class="$style.header">
|
||||
<MkUserName :user="user" :nowrap="true"/>
|
||||
|
|
|
|||
|
|
@ -207,7 +207,9 @@ function react(viaKeyboard = false): void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
blur();
|
||||
|
|
@ -238,7 +240,9 @@ function like(): void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -263,7 +267,9 @@ function undoRenote() : void {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -291,7 +297,9 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
misskeyApi('notes/create', {
|
||||
|
|
@ -307,7 +315,9 @@ function renote(visibility: Visibility, localOnly: boolean = false) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
misskeyApi('notes/create', {
|
||||
|
|
@ -342,7 +352,9 @@ function quote() {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
os.toast(i18n.ts.quoted);
|
||||
|
|
@ -364,7 +376,9 @@ function quote() {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
os.toast(i18n.ts.quoted);
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.head">
|
||||
<MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && notification.note" :class="$style.icon" :user="notification.note.user" link preview/>
|
||||
<MkAvatar v-if="['pollEnded', 'note', 'edited'].includes(notification.type) && 'note' in notification" :class="$style.icon" :user="notification.note.user" link preview/>
|
||||
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned'].includes(notification.type)" :class="$style.icon" :user="$i" link preview/>
|
||||
<div v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ph-smiley ph-bold ph-lg" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'renote:grouped'" :class="[$style.icon, $style.icon_renoteGroup]"><i class="ti ti-repeat" style="line-height: 1;"></i></div>
|
||||
<img v-else-if="notification.type === 'test'" :class="$style.icon" :src="infoImageUrl"/>
|
||||
<MkAvatar v-else-if="notification.user" :class="$style.icon" :user="notification.user" link preview/>
|
||||
<img v-else-if="notification.icon" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||
<MkAvatar v-else-if="'user' in notification" :class="$style.icon" :user="notification.user" link preview/>
|
||||
<img v-else-if="'icon' in notification" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||
<div
|
||||
:class="[$style.subIcon, {
|
||||
[$style.t_follow]: notification.type === 'follow',
|
||||
|
|
@ -174,13 +174,13 @@ const props = withDefaults(defineProps<{
|
|||
const followRequestDone = ref(false);
|
||||
|
||||
const acceptFollowRequest = () => {
|
||||
if (props.notification.user == null) return;
|
||||
if (!('user' in props.notification)) return;
|
||||
followRequestDone.value = true;
|
||||
misskeyApi('following/requests/accept', { userId: props.notification.user.id });
|
||||
};
|
||||
|
||||
const rejectFollowRequest = () => {
|
||||
if (props.notification.user == null) return;
|
||||
if (!('user' in props.notification)) return;
|
||||
followRequestDone.value = true;
|
||||
misskeyApi('following/requests/reject', { userId: props.notification.user.id });
|
||||
};
|
||||
|
|
@ -353,7 +353,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
margin-right: 4px;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
position: absolute;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj" tabindex="-1">
|
||||
<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj">
|
||||
<div v-if="page.eyeCatchingImage" class="thumbnail">
|
||||
<MediaImage
|
||||
:image="page.eyeCatchingImage"
|
||||
|
|
@ -50,12 +50,29 @@ const props = defineProps<{
|
|||
<style lang="scss" scoped>
|
||||
.vhpxefrj {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
box-shadow: inset 0 0 0 2px var(--focus);
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
& + article {
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import { computed, onMounted, onUnmounted, provide, ref, shallowRef } from 'vue'
|
|||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { popout as _popout } from '@/scripts/popout.js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { url } from '@/config.js';
|
||||
import { useScrollPositionManager } from '@/nirax.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ import { pleaseLogin } from '@/scripts/please-login.js';
|
|||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host } from '@/config.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
|
|
@ -62,6 +64,11 @@ const timer = computed(() => i18n.tsx._poll[
|
|||
|
||||
const showResult = ref(props.readOnly || isVoted.value);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${props.noteId}`,
|
||||
}));
|
||||
|
||||
// 期限付きアンケート
|
||||
if (props.poll.expiresAt) {
|
||||
const tick = () => {
|
||||
|
|
@ -78,7 +85,7 @@ if (props.poll.expiresAt) {
|
|||
}
|
||||
|
||||
const vote = async (id) => {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
|
||||
if (props.readOnly || closed.value || isVoted.value) return;
|
||||
if (!props.poll.multiple) {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkInput>
|
||||
</section>
|
||||
<section v-else-if="expiration === 'after'">
|
||||
<MkInput v-model="after" small type="number" class="input">
|
||||
<MkInput v-model="after" small type="number" min="1" class="input">
|
||||
<template #label>{{ i18n.ts._poll.duration }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="unit" small>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" @click="click" @close="onModalClose" @closed="onModalClosed">
|
||||
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :manualShowing="manualShowing" :zPriority="'high'" :src="src" :transparentBg="true" :returnFocusTo="returnFocusTo" @click="click" @close="onModalClose" @closed="onModalClosed">
|
||||
<MkMenu :items="items" :align="align" :width="width" :max-height="maxHeight" :asDrawer="type === 'drawer'" :returnFocusTo="returnFocusTo" :class="{ [$style.drawer]: type === 'drawer' }" @close="onMenuClose" @hide="hide"/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
|
|
@ -19,8 +19,8 @@ defineProps<{
|
|||
items: MenuItem[];
|
||||
align?: 'center' | string;
|
||||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
src?: any;
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@ const canPost = computed((): boolean => {
|
|||
1 <= files.value.length ||
|
||||
poll.value != null ||
|
||||
props.renote != null ||
|
||||
(props.reply != null && quoteId.value != null)
|
||||
quoteId.value != null
|
||||
) &&
|
||||
(textLength.value <= maxTextLength.value) &&
|
||||
(!poll.value || poll.value.choices.length >= 2);
|
||||
|
|
@ -369,10 +369,17 @@ function watchForDraft() {
|
|||
watch(files, () => saveDraft(), { deep: true });
|
||||
watch(visibility, () => saveDraft());
|
||||
watch(localOnly, () => saveDraft());
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
}
|
||||
|
||||
function MFMWindow() {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/SkMfmWindow.vue')), {}, {}, 'closed');
|
||||
const { dispose } = os.popup(
|
||||
defineAsyncComponent(() => import('@/components/SkMfmWindow.vue')),
|
||||
{},
|
||||
{
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
|
|
@ -469,7 +476,7 @@ function setVisibility() {
|
|||
return;
|
||||
}
|
||||
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkVisibilityPicker.vue')), {
|
||||
currentVisibility: visibility.value,
|
||||
isSilenced: $i.isSilenced,
|
||||
localOnly: localOnly.value,
|
||||
|
|
@ -482,7 +489,8 @@ function setVisibility() {
|
|||
defaultStore.set('visibility', visibility.value);
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleLocalOnly() {
|
||||
|
|
@ -575,6 +583,7 @@ function clear() {
|
|||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
|
||||
|
||||
if (ev.key === 'Escape') emit('esc');
|
||||
}
|
||||
|
||||
|
|
@ -630,8 +639,8 @@ async function onPaste(ev: ClipboardEvent) {
|
|||
return;
|
||||
}
|
||||
|
||||
const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, "0");
|
||||
const file = new File([paste], `${fileName}.txt`, { type: "text/plain" });
|
||||
const fileName = formatTimeString(new Date(), defaultStore.state.pastedFileName).replace(/{{number}}/g, '0');
|
||||
const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
|
||||
upload(file, `${fileName}.txt`);
|
||||
});
|
||||
}
|
||||
|
|
@ -707,6 +716,8 @@ function saveDraft() {
|
|||
files: files.value,
|
||||
poll: poll.value,
|
||||
visibleUserIds: visibility.value === 'specified' ? visibleUsers.value.map(x => x.id) : undefined,
|
||||
quoteId: quoteId.value,
|
||||
reactionAcceptance: reactionAcceptance.value,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -737,7 +748,9 @@ async function post(ev?: MouseEvent) {
|
|||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkRippleEffect, { x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -930,10 +943,23 @@ async function insertEmoji(ev: MouseEvent) {
|
|||
textAreaReadOnly.value = true;
|
||||
const target = ev.currentTarget ?? ev.target;
|
||||
if (target == null) return;
|
||||
|
||||
// emojiPickerはダイアログが閉じずにtextareaとやりとりするので、
|
||||
// focustrapをかけているとinsertTextAtCursorが効かない
|
||||
// そのため、投稿フォームのテキストに直接注入する
|
||||
// See: https://github.com/misskey-dev/misskey/pull/14282
|
||||
// https://github.com/misskey-dev/misskey/issues/14274
|
||||
|
||||
let pos = textareaEl.value?.selectionStart ?? 0;
|
||||
let posEnd = textareaEl.value?.selectionEnd ?? text.value.length;
|
||||
emojiPicker.show(
|
||||
target as HTMLElement,
|
||||
emoji => {
|
||||
insertTextAtCursor(textareaEl.value, emoji);
|
||||
const textBefore = text.value.substring(0, pos);
|
||||
const textAfter = text.value.substring(posEnd);
|
||||
text.value = textBefore + emoji + textAfter;
|
||||
pos += emoji.length;
|
||||
posEnd += emoji.length;
|
||||
},
|
||||
() => {
|
||||
textAreaReadOnly.value = false;
|
||||
|
|
@ -1026,6 +1052,8 @@ onMounted(() => {
|
|||
users.forEach(u => pushVisibleUser(u));
|
||||
});
|
||||
}
|
||||
quoteId.value = draft.data.quoteId;
|
||||
reactionAcceptance.value = draft.data.reactionAcceptance;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1033,9 +1061,11 @@ onMounted(() => {
|
|||
if (props.initialNote) {
|
||||
const init = props.initialNote;
|
||||
text.value = init.text ? init.text : '';
|
||||
files.value = init.files ?? [];
|
||||
cw.value = init.cw ?? null;
|
||||
useCw.value = init.cw != null;
|
||||
cw.value = init.cw ?? null;
|
||||
visibility.value = init.visibility;
|
||||
localOnly.value = init.localOnly ?? false;
|
||||
files.value = init.files ?? [];
|
||||
if (init.poll) {
|
||||
poll.value = {
|
||||
choices: init.poll.choices.map(x => x.text),
|
||||
|
|
@ -1044,9 +1074,13 @@ onMounted(() => {
|
|||
expiredAfter: null,
|
||||
};
|
||||
}
|
||||
visibility.value = init.visibility;
|
||||
localOnly.value = init.localOnly ?? false;
|
||||
if (init.visibleUserIds) {
|
||||
misskeyApi('users/show', { userIds: init.visibleUserIds }).then(users => {
|
||||
users.forEach(u => pushVisibleUser(u));
|
||||
});
|
||||
}
|
||||
quoteId.value = init.renote ? init.renote.id : null;
|
||||
reactionAcceptance.value = init.reactionAcceptance;
|
||||
}
|
||||
|
||||
nextTick(() => watchForDraft());
|
||||
|
|
@ -1119,6 +1153,15 @@ defineExpose({
|
|||
margin: 12px 12px 12px 6px;
|
||||
vertical-align: bottom;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
.submitInner {
|
||||
outline: 2px solid var(--fgOnAccent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ async function rename(file) {
|
|||
async function describe(file) {
|
||||
if (mock) return;
|
||||
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
|
||||
default: file.comment !== null ? file.comment : '',
|
||||
file: file,
|
||||
}, {
|
||||
|
|
@ -121,7 +121,8 @@ async function describe(file) {
|
|||
file.comment = comment;
|
||||
});
|
||||
},
|
||||
}, 'closed');
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
async function crop(file: Misskey.entities.DriveFile): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()">
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="modal?.close()" @closed="onModalClosed()" @esc="modal?.close()">
|
||||
<MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal?.close()" @esc="modal?.close()"/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
|
|
|||
150
packages/frontend/src/components/MkPreview.vue
Normal file
150
packages/frontend/src/components/MkPreview.vue
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.preview">
|
||||
<div :class="$style.preview__content1">
|
||||
<MkInput v-model="text">
|
||||
<template #label>Text</template>
|
||||
</MkInput>
|
||||
<MkSwitch v-model="flag" :class="$style.preview__content1__switch_button">
|
||||
<span>Switch is now {{ flag ? 'on' : 'off' }}</span>
|
||||
</MkSwitch>
|
||||
<div :class="$style.preview__content1__input">
|
||||
<MkRadio v-model="radio" value="misskey">Misskey</MkRadio>
|
||||
<MkRadio v-model="radio" value="mastodon">Mastodon</MkRadio>
|
||||
<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
|
||||
</div>
|
||||
<div :class="$style.preview__content1__button">
|
||||
<MkButton inline>This is</MkButton>
|
||||
<MkButton inline primary>the button</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.preview__content2" style="pointer-events: none;">
|
||||
<Mfm :text="mfm"/>
|
||||
</div>
|
||||
<div :class="$style.preview__content3">
|
||||
<MkButton inline primary @click="openMenu">Open menu</MkButton>
|
||||
<MkButton inline primary @click="openDialog">Open dialog</MkButton>
|
||||
<MkButton inline primary @click="openForm">Open form</MkButton>
|
||||
<MkButton inline primary @click="openDrive">Open drive</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkRadio from '@/components/MkRadio.vue';
|
||||
import * as os from '@/os.js';
|
||||
import * as config from '@/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
const text = ref('');
|
||||
const flag = ref(true);
|
||||
const radio = ref('misskey');
|
||||
const mfm = ref(`Hello world! This is an @example mention. BTW you are @${$i ? $i.username : 'guest'}.\nAlso, here is ${config.url} and [example link](${config.url}). for more details, see https://example.com.\nAs you know #misskey is open-source software.`);
|
||||
|
||||
const openDialog = async () => {
|
||||
await os.alert({
|
||||
type: 'warning',
|
||||
title: 'Oh my Aichan',
|
||||
text: 'Lorem ipsum dolor sit amet, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.',
|
||||
});
|
||||
};
|
||||
|
||||
const openForm = async () => {
|
||||
await os.form('Example form', {
|
||||
foo: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
label: 'This is a boolean property',
|
||||
},
|
||||
bar: {
|
||||
type: 'number',
|
||||
default: 300,
|
||||
label: 'This is a number property',
|
||||
},
|
||||
baz: {
|
||||
type: 'string',
|
||||
default: 'Misskey makes you happy.',
|
||||
label: 'This is a string property',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openDrive = async () => {
|
||||
await os.selectDriveFile(false);
|
||||
};
|
||||
|
||||
const selectUser = async () => {
|
||||
await os.selectUser();
|
||||
};
|
||||
|
||||
const openMenu = async (ev: Event) => {
|
||||
os.popupMenu([{
|
||||
type: 'label',
|
||||
text: 'Fruits',
|
||||
}, {
|
||||
text: 'Create some apples',
|
||||
action: () => {},
|
||||
}, {
|
||||
text: 'Read some oranges',
|
||||
action: () => {},
|
||||
}, {
|
||||
text: 'Update some melons',
|
||||
action: () => {},
|
||||
}, {
|
||||
text: 'Delete some bananas',
|
||||
danger: true,
|
||||
action: () => {},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.preview {
|
||||
padding: 16px;
|
||||
|
||||
&__content1 {
|
||||
|
||||
&__switch_button {
|
||||
padding: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
&__input {
|
||||
padding: 8px 0 8px 0;
|
||||
|
||||
div {
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
padding: 4px 0 8px 0;
|
||||
|
||||
button {
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__content2 {
|
||||
padding: 8px 0 8px 0;
|
||||
}
|
||||
|
||||
&__content3 {
|
||||
padding: 8px 0 8px 0;
|
||||
|
||||
button {
|
||||
margin: 0 8px 8px 0;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:class="[$style.root, { [$style.disabled]: disabled, [$style.checked]: checked }]"
|
||||
:aria-checked="checked"
|
||||
:aria-disabled="disabled"
|
||||
role="checkbox"
|
||||
@click="toggle"
|
||||
>
|
||||
<input
|
||||
|
|
@ -69,6 +70,11 @@ function toggle(): void {
|
|||
border-color: var(--inputBorderHover) !important;
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--focus);
|
||||
}
|
||||
|
||||
&.checked {
|
||||
background-color: var(--accentedBg) !important;
|
||||
border-color: var(--accentedBg) !important;
|
||||
|
|
@ -78,7 +84,7 @@ function toggle(): void {
|
|||
> .button {
|
||||
border-color: var(--accent);
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
background-color: var(--accent);
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
|
|
@ -104,7 +110,7 @@ function toggle(): void {
|
|||
border-radius: var(--radius-full);
|
||||
transition: inherit;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ export default defineComponent({
|
|||
// なぜかFragmentになることがあるため
|
||||
if (options.length === 1 && options[0].props == null) options = options[0].children as VNode[];
|
||||
|
||||
// vnodeのうちv-if=falseなものを除外する(trueになるものはoptionなど他typeになる)
|
||||
options = options.filter(vnode => !(typeof vnode.type === 'symbol' && vnode.type.description === 'v-cmt' && vnode.children === 'v-if'));
|
||||
|
||||
return () => h('div', {
|
||||
class: 'novjtcto',
|
||||
}, [
|
||||
|
|
@ -40,6 +43,7 @@ export default defineComponent({
|
|||
}, options.map(option => h(MkRadio, {
|
||||
key: option.key as string,
|
||||
value: option.props?.value,
|
||||
disabled: option.props?.disabled,
|
||||
modelValue: value.value,
|
||||
'onUpdate:modelValue': _v => value.value = _v,
|
||||
}, () => option.children)),
|
||||
|
|
|
|||
|
|
@ -101,17 +101,19 @@ const steps = computed(() => {
|
|||
}
|
||||
});
|
||||
|
||||
const onMousedown = (ev: MouseEvent | TouchEvent) => {
|
||||
function onMousedown(ev: MouseEvent | TouchEvent) {
|
||||
ev.preventDefault();
|
||||
|
||||
const tooltipShowing = ref(true);
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkTooltip.vue')), {
|
||||
showing: tooltipShowing,
|
||||
text: computed(() => {
|
||||
return props.textConverter(finalValue.value);
|
||||
}),
|
||||
targetElement: thumbEl,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }'));
|
||||
|
|
@ -152,7 +154,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
|
|||
window.addEventListener('touchmove', onDrag);
|
||||
window.addEventListener('mouseup', onMouseup, { once: true });
|
||||
window.addEventListener('touchend', onMouseup, { once: true });
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
|||
|
|
@ -24,11 +24,13 @@ const elRef = shallowRef();
|
|||
|
||||
if (props.withTooltip) {
|
||||
useTooltip(elRef, (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkReactionTooltip.vue')), {
|
||||
showing,
|
||||
reaction: props.reaction.replace(/^:(\w+):$/, ':$1@.:'),
|
||||
targetElement: elRef.value.$el,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ function getReactionName(reaction: string): string {
|
|||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
line-height: 24px;
|
||||
padding-top: 4px;
|
||||
white-space: nowrap;
|
||||
|
|
|
|||
|
|
@ -114,10 +114,12 @@ async function menu(ev) {
|
|||
text: i18n.ts.info,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
os.popup(MkCustomEmojiDetailedDialog, {
|
||||
const { dispose } = os.popup(MkCustomEmojiDetailedDialog, {
|
||||
emoji: await misskeyApiGet('emoji', {
|
||||
name: props.reaction.replace(/:/g, '').replace(/@\./, ''),
|
||||
}),
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
},
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
|
|
@ -129,7 +131,9 @@ function anime() {
|
|||
const rect = buttonEl.value.getBoundingClientRect();
|
||||
const x = rect.left + 16;
|
||||
const y = rect.top + (buttonEl.value.offsetHeight / 2);
|
||||
os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {}, 'end');
|
||||
const { dispose } = os.popup(MkReactionEffect, { reaction: props.reaction, x, y }, {
|
||||
end: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => props.count, (newCount, oldCount) => {
|
||||
|
|
@ -151,13 +155,15 @@ if (!mock) {
|
|||
|
||||
const users = reactions.map(x => x.user);
|
||||
|
||||
os.popup(XDetails, {
|
||||
const { dispose } = os.popup(XDetails, {
|
||||
showing,
|
||||
reaction: props.reaction,
|
||||
users,
|
||||
count: props.count,
|
||||
targetElement: buttonEl.value,
|
||||
}, {}, 'closed');
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -6,20 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div>
|
||||
<div :class="$style.label" @click="focus"><slot name="label"></slot></div>
|
||||
<div ref="container" :class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused }]" @mousedown.prevent="show">
|
||||
<div
|
||||
ref="container"
|
||||
tabindex="0"
|
||||
:class="[$style.input, { [$style.inline]: inline, [$style.disabled]: disabled, [$style.focused]: focused || opening }]"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@mousedown.prevent="show"
|
||||
@keydown.space.enter="show"
|
||||
>
|
||||
<div ref="prefixEl" :class="$style.prefix"><slot name="prefix"></slot></div>
|
||||
<select
|
||||
ref="inputEl"
|
||||
v-model="v"
|
||||
v-adaptive-border
|
||||
tabindex="-1"
|
||||
:class="$style.inputCore"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:readonly="readonly"
|
||||
:placeholder="placeholder"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
@input="onInput"
|
||||
@mousedown.prevent="() => {}"
|
||||
@keydown.prevent="() => {}"
|
||||
>
|
||||
<slot></slot>
|
||||
</select>
|
||||
|
|
@ -75,7 +84,7 @@ const height =
|
|||
props.large ? 39 :
|
||||
36;
|
||||
|
||||
const focus = () => inputEl.value?.focus();
|
||||
const focus = () => container.value?.focus();
|
||||
const onInput = (ev) => {
|
||||
changed.value = true;
|
||||
};
|
||||
|
|
@ -126,7 +135,9 @@ onMounted(() => {
|
|||
});
|
||||
|
||||
function show() {
|
||||
focused.value = true;
|
||||
if (opening.value) return;
|
||||
focus();
|
||||
|
||||
opening.value = true;
|
||||
|
||||
const menu: MenuItem[] = [];
|
||||
|
|
@ -173,8 +184,6 @@ function show() {
|
|||
onClosing: () => {
|
||||
opening.value = false;
|
||||
},
|
||||
}).then(() => {
|
||||
focused.value = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
@ -225,6 +234,10 @@ function show() {
|
|||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> .inputCore {
|
||||
border-color: var(--inputBorderHover) !important;
|
||||
|
|
|
|||
|
|
@ -6,10 +6,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<form :class="{ signing, totpLogin }" @submit.prevent="onSubmit">
|
||||
<div class="_gaps_m">
|
||||
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${ user.avatarUrl }')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
|
||||
<div v-show="withAvatar" :class="$style.avatar" :style="{ backgroundImage: user ? `url('${user.avatarUrl}')` : undefined, marginBottom: message ? '1.5em' : undefined }"></div>
|
||||
<MkInfo v-if="message">
|
||||
{{ message }}
|
||||
</MkInfo>
|
||||
<div v-if="openOnRemote" class="_gaps_m">
|
||||
<div class="_gaps_s">
|
||||
<MkButton type="button" rounded primary style="margin: 0 auto;" @click="openRemote(openOnRemote)">
|
||||
{{ i18n.ts.continueOnRemote }} <i class="ti ti-external-link"></i>
|
||||
</MkButton>
|
||||
<button type="button" class="_button" :class="$style.instanceManualSelectButton" @click="specifyHostAndOpenRemote(openOnRemote)">
|
||||
{{ i18n.ts.specifyServerHost }}
|
||||
</button>
|
||||
</div>
|
||||
<div :class="$style.orHr">
|
||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!totpLogin" class="normal-signin _gaps_m">
|
||||
<MkInput v-model="username" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autocomplete="username webauthn" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange">
|
||||
<template #prefix>@</template>
|
||||
|
|
@ -28,8 +41,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
{{ i18n.ts.retry }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<div v-if="user && user.securityKeys" class="or-hr">
|
||||
<p class="or-msg">{{ i18n.ts.or }}</p>
|
||||
<div v-if="user && user.securityKeys" :class="$style.orHr">
|
||||
<p :class="$style.orMsg">{{ i18n.ts.or }}</p>
|
||||
</div>
|
||||
<div class="twofa-group totp-group _gaps">
|
||||
<MkInput v-if="user && user.usePasswordLessLogin" v-model="password" type="password" autocomplete="current-password" :withPasswordToggle="true" required>
|
||||
|
|
@ -53,6 +66,7 @@ import { defineAsyncComponent, ref } from 'vue';
|
|||
import { toUnicode } from 'punycode/';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { supported as webAuthnSupported, get as webAuthnRequest, parseRequestOptionsFromJSON } from '@github/webauthn-json/browser-ponyfill';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
|
@ -60,6 +74,7 @@ import MkInfo from '@/components/MkInfo.vue';
|
|||
import { host as configHost } from '@/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { query, extractDomain } from '@/scripts/url.js';
|
||||
import { login } from '@/account.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
|
|
@ -72,28 +87,22 @@ const host = ref(toUnicode(configHost));
|
|||
const totpLogin = ref(false);
|
||||
const isBackupCode = ref(false);
|
||||
const queryingKey = ref(false);
|
||||
const credentialRequest = ref<CredentialRequestOptions | null>(null);
|
||||
let credentialRequest: CredentialRequestOptions | null = null;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'login', v: any): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps({
|
||||
withAvatar: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
autoSet: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
const props = withDefaults(defineProps<{
|
||||
withAvatar?: boolean;
|
||||
autoSet?: boolean;
|
||||
message?: string,
|
||||
openOnRemote?: OpenOnRemoteOptions,
|
||||
}>(), {
|
||||
withAvatar: true,
|
||||
autoSet: false,
|
||||
message: '',
|
||||
openOnRemote: undefined,
|
||||
});
|
||||
|
||||
function onUsernameChange(): void {
|
||||
|
|
@ -113,14 +122,14 @@ function onLogin(res: any): Promise<void> | void {
|
|||
}
|
||||
|
||||
async function queryKey(): Promise<void> {
|
||||
if (credentialRequest.value == null) return;
|
||||
if (credentialRequest == null) return;
|
||||
queryingKey.value = true;
|
||||
await webAuthnRequest(credentialRequest.value)
|
||||
await webAuthnRequest(credentialRequest)
|
||||
.catch(() => {
|
||||
queryingKey.value = false;
|
||||
return Promise.reject(null);
|
||||
}).then(credential => {
|
||||
credentialRequest.value = null;
|
||||
credentialRequest = null;
|
||||
queryingKey.value = false;
|
||||
signing.value = true;
|
||||
return misskeyApi('signin', {
|
||||
|
|
@ -151,7 +160,7 @@ function onSubmit(): void {
|
|||
}).then(res => {
|
||||
totpLogin.value = true;
|
||||
signing.value = false;
|
||||
credentialRequest.value = parseRequestOptionsFromJSON({
|
||||
credentialRequest = parseRequestOptionsFromJSON({
|
||||
publicKey: res,
|
||||
});
|
||||
})
|
||||
|
|
@ -218,8 +227,65 @@ function loginFailed(err: any): void {
|
|||
}
|
||||
|
||||
function resetPassword(): void {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
|
||||
}, 'closed');
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkForgotPassword.vue')), {}, {
|
||||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
|
||||
function openRemote(options: OpenOnRemoteOptions, targetHost?: string): void {
|
||||
switch (options.type) {
|
||||
case 'web':
|
||||
case 'lookup': {
|
||||
let _path: string;
|
||||
|
||||
if (options.type === 'lookup') {
|
||||
// TODO: v2024.7.0以降が浸透してきたら正式なURLに変更する▼
|
||||
// _path = `/lookup?uri=${encodeURIComponent(_path)}`;
|
||||
_path = `/authorize-follow?acct=${encodeURIComponent(options.url)}`;
|
||||
} else {
|
||||
_path = options.path;
|
||||
}
|
||||
|
||||
if (targetHost) {
|
||||
window.open(`https://${targetHost}${_path}`, '_blank', 'noopener');
|
||||
} else {
|
||||
window.open(`https://misskey-hub.net/mi-web/?path=${encodeURIComponent(_path)}`, '_blank', 'noopener');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
const params = query(options.params);
|
||||
if (targetHost) {
|
||||
window.open(`https://${targetHost}/share?${params}`, '_blank', 'noopener');
|
||||
} else {
|
||||
window.open(`https://misskey-hub.net/share/?${params}`, '_blank', 'noopener');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function specifyHostAndOpenRemote(options: OpenOnRemoteOptions): Promise<void> {
|
||||
const { canceled, result: hostTemp } = await os.inputText({
|
||||
title: i18n.ts.inputHostName,
|
||||
placeholder: 'misskey.example.com',
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
let targetHost: string | null = hostTemp;
|
||||
|
||||
// ドメイン部分だけを取り出す
|
||||
targetHost = extractDomain(targetHost);
|
||||
if (targetHost == null) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.invalidValue,
|
||||
text: i18n.ts.tryAgain,
|
||||
});
|
||||
return;
|
||||
}
|
||||
openRemote(options, targetHost);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -233,4 +299,36 @@ function resetPassword(): void {
|
|||
background-size: cover;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.instanceManualSelectButton {
|
||||
display: block;
|
||||
text-align: center;
|
||||
opacity: .7;
|
||||
font-size: .8em;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.orHr {
|
||||
position: relative;
|
||||
margin: .4em auto;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
.orMsg {
|
||||
position: absolute;
|
||||
top: -.6em;
|
||||
display: inline-block;
|
||||
padding: 0 1em;
|
||||
background: var(--panel);
|
||||
font-size: 0.8em;
|
||||
color: var(--fgOnPanel);
|
||||
margin: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -6,21 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="370"
|
||||
:height="400"
|
||||
:width="400"
|
||||
:height="430"
|
||||
@close="onClose"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.login }}</template>
|
||||
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<MkSignin :autoSet="autoSet" :message="message" @login="onLogin"/>
|
||||
<MkSignin :autoSet="autoSet" :message="message" :openOnRemote="openOnRemote" @login="onLogin"/>
|
||||
</MkSpacer>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { shallowRef } from 'vue';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import MkSignin from '@/components/MkSignin.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -28,9 +29,11 @@ import { i18n } from '@/i18n.js';
|
|||
withDefaults(defineProps<{
|
||||
autoSet?: boolean;
|
||||
message?: string,
|
||||
openOnRemote?: OpenOnRemoteOptions,
|
||||
}>(), {
|
||||
autoSet: false,
|
||||
message: '',
|
||||
openOnRemote: undefined,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue