Merge branch 'develop' into feature/channel_muting
# Conflicts: # CHANGELOG.md # packages/backend/src/core/CoreModule.ts # packages/backend/src/server/api/endpoints/channels/timeline.ts # packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts # packages/backend/src/server/api/endpoints/notes/local-timeline.ts # packages/backend/src/server/api/endpoints/notes/timeline.ts # packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts # packages/backend/src/server/api/endpoints/users/notes.ts # packages/backend/src/server/api/stream/Connection.ts # packages/backend/src/server/api/stream/channel.ts # packages/backend/src/server/api/stream/channels/channel.ts # packages/backend/src/server/api/stream/channels/home-timeline.ts # packages/backend/src/server/api/stream/channels/hybrid-timeline.ts # packages/backend/test/e2e/timelines.ts
This commit is contained in:
commit
5595e8fbd1
847 changed files with 35217 additions and 13941 deletions
|
|
@ -53,7 +53,6 @@ await fs.readFile(
|
|||
'../../assets/**',
|
||||
'../../fluent-emojis/**',
|
||||
'../../locales/ja-JP.yml',
|
||||
'../../misskey-assets/**',
|
||||
'assets/**',
|
||||
'public/**',
|
||||
'../../pnpm-lock.yaml',
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
|
||||
import type { entities } from 'misskey-js'
|
||||
|
||||
export function abuseUserReport() {
|
||||
|
|
@ -47,18 +48,7 @@ export function clip(id = 'someclipid', name = 'Some Clip'): entities.Clip {
|
|||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
lastClippedAt: null,
|
||||
userId: 'someuserid',
|
||||
user: {
|
||||
id: 'someuserid',
|
||||
name: 'Misskey User',
|
||||
username: 'miskist',
|
||||
host: 'misskey-hub.net',
|
||||
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
|
||||
avatarDecorations: [],
|
||||
emojis: {},
|
||||
badgeRoles: [],
|
||||
onlineStatus: 'unknown',
|
||||
},
|
||||
user: userLite(),
|
||||
notesCount: undefined,
|
||||
name,
|
||||
description: 'Some clip description',
|
||||
|
|
@ -125,6 +115,49 @@ export function file(isSensitive = false) {
|
|||
};
|
||||
}
|
||||
|
||||
const script = `/// @ ${AISCRIPT_VERSION}
|
||||
|
||||
var name = ""
|
||||
|
||||
Ui:render([
|
||||
Ui:C:textInput({
|
||||
label: "Your name"
|
||||
onInput: @(v) { name = v }
|
||||
})
|
||||
Ui:C:button({
|
||||
text: "Hello"
|
||||
onClick: @() {
|
||||
Mk:dialog(null, \`Hello, {name}!\`)
|
||||
}
|
||||
})
|
||||
])
|
||||
`;
|
||||
|
||||
export function flash(): entities.Flash {
|
||||
return {
|
||||
id: 'someflashid',
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
updatedAt: '2016-12-28T22:49:51.000Z',
|
||||
userId: 'someuserid',
|
||||
user: userLite(),
|
||||
title: 'Some Play title',
|
||||
summary: 'Some Play summary',
|
||||
script,
|
||||
visibility: 'public',
|
||||
likedCount: 0,
|
||||
isLiked: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function folder(id = 'somefolderid', name = 'Some Folder', parentId: string | null = null): entities.DriveFolder {
|
||||
return {
|
||||
id,
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
name,
|
||||
parentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function federationInstance(): entities.FederationInstance {
|
||||
return {
|
||||
id: 'someinstanceid',
|
||||
|
|
@ -154,7 +187,27 @@ export function federationInstance(): entities.FederationInstance {
|
|||
};
|
||||
}
|
||||
|
||||
export function userDetailed(id = 'someuserid', username = 'miskist', host = 'misskey-hub.net', name = 'Misskey User'): entities.UserDetailed {
|
||||
export function note(id = 'somenoteid'): entities.Note {
|
||||
return {
|
||||
id,
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
deletedAt: null,
|
||||
text: 'some note',
|
||||
cw: null,
|
||||
userId: 'someuserid',
|
||||
user: userLite(),
|
||||
visibility: 'public',
|
||||
reactionAcceptance: 'nonSensitiveOnly',
|
||||
reactionEmojis: {},
|
||||
reactions: {},
|
||||
myReaction: null,
|
||||
reactionCount: 0,
|
||||
renoteCount: 0,
|
||||
repliesCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function userLite(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserLite {
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
|
|
@ -165,6 +218,12 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
|||
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
|
||||
avatarDecorations: [],
|
||||
emojis: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function userDetailed(id = 'someuserid', username = 'miskist', host: entities.UserDetailed['host'] = 'misskey-hub.net', name: entities.UserDetailed['name'] = 'Misskey User'): entities.UserDetailed {
|
||||
return {
|
||||
...userLite(id, username, host, name),
|
||||
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
|
||||
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||
birthday: '2014-06-20',
|
||||
|
|
@ -215,7 +274,7 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
|||
movedTo: null,
|
||||
alsoKnownAs: null,
|
||||
notify: 'none',
|
||||
memo: null
|
||||
memo: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -397,15 +397,28 @@ function toStories(component: string): Promise<string> {
|
|||
const globs = await Promise.all([
|
||||
glob('src/components/global/Mk*.vue'),
|
||||
glob('src/components/global/RouterView.vue'),
|
||||
glob('src/components/Mk[A-C]*.vue'),
|
||||
glob('src/components/MkDigitalClock.vue'),
|
||||
glob('src/components/MkAbuseReportWindow.vue'),
|
||||
glob('src/components/MkAccountMoved.vue'),
|
||||
glob('src/components/MkAchievements.vue'),
|
||||
glob('src/components/MkAnalogClock.vue'),
|
||||
glob('src/components/MkAnimBg.vue'),
|
||||
glob('src/components/MkAnnouncementDialog.vue'),
|
||||
glob('src/components/MkAntennaEditor.vue'),
|
||||
glob('src/components/MkAntennaEditorDialog.vue'),
|
||||
glob('src/components/MkAsUi.vue'),
|
||||
glob('src/components/MkAutocomplete.vue'),
|
||||
glob('src/components/MkAvatars.vue'),
|
||||
glob('src/components/Mk[B-E]*.vue'),
|
||||
glob('src/components/MkFlashPreview.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
glob('src/components/MkSignupServerRules.vue'),
|
||||
glob('src/components/MkUserSetupDialog.vue'),
|
||||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/components/MkInstanceCardMini.vue'),
|
||||
glob('src/components/MkInviteCode.vue'),
|
||||
glob('src/pages/admin/overview.ap-requests.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
glob('src/pages/search.vue'),
|
||||
]);
|
||||
const components = globs.flat();
|
||||
await Promise.all(components.map(async (component) => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const keys = [
|
|||
'd-u0',
|
||||
]
|
||||
|
||||
await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
|
||||
await Promise.all(keys.map((key) => readFile(new URL(`../../frontend-shared/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
|
||||
writeFile(
|
||||
new URL('./themes.ts', import.meta.url),
|
||||
`export default ${JSON.stringify(
|
||||
|
|
|
|||
2
packages/frontend/@types/theme.d.ts
vendored
2
packages/frontend/@types/theme.d.ts
vendored
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
declare module '@/themes/*.json5' {
|
||||
declare module '@@/themes/*.json5' {
|
||||
import { Theme } from '@/scripts/theme.js';
|
||||
|
||||
const theme: Theme;
|
||||
|
|
|
|||
|
|
@ -17,35 +17,35 @@
|
|||
"lint": "pnpm typecheck && pnpm eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "15.0.3",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@github/webauthn-json": "2.1.1",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||
"@misskey-dev/browser-image-resizer": "2024.1.0",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "5.0.7",
|
||||
"@rollup/pluginutils": "5.1.0",
|
||||
"@syuilo/aiscript": "0.18.0",
|
||||
"@rollup/pluginutils": "5.1.2",
|
||||
"@syuilo/aiscript": "0.19.0",
|
||||
"@tabler/icons-webfont": "3.3.0",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.0.5",
|
||||
"@vue/compiler-sfc": "3.4.31",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.9",
|
||||
"astring": "1.8.6",
|
||||
"@vitejs/plugin-vue": "5.1.4",
|
||||
"@vue/compiler-sfc": "3.5.11",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"chart.js": "4.4.3",
|
||||
"chart.js": "4.4.4",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "11.5.4",
|
||||
"compare-versions": "6.1.0",
|
||||
"cropperjs": "2.0.0-beta.5",
|
||||
"chromatic": "11.11.0",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.0.0-rc.2",
|
||||
"date-fns": "2.30.0",
|
||||
"escape-regexp": "0.0.1",
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"frontend-shared": "workspace:*",
|
||||
"idb-keyval": "6.2.1",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
|
|
@ -57,85 +57,85 @@
|
|||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode": "2.3.1",
|
||||
"rollup": "4.18.0",
|
||||
"sanitize-html": "2.13.0",
|
||||
"sass": "1.77.6",
|
||||
"shiki": "1.10.0",
|
||||
"rollup": "4.22.5",
|
||||
"sanitize-html": "2.13.1",
|
||||
"sass": "1.79.3",
|
||||
"shiki": "1.21.0",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.165.0",
|
||||
"three": "0.169.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.10",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.5.3",
|
||||
"typescript": "5.6.2",
|
||||
"uuid": "10.0.0",
|
||||
"v-code-diff": "1.12.0",
|
||||
"vite": "5.3.2",
|
||||
"vue": "3.4.31",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "5.4.8",
|
||||
"vue": "3.5.11",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.1.0",
|
||||
"@storybook/addon-actions": "8.1.11",
|
||||
"@storybook/addon-essentials": "8.1.11",
|
||||
"@storybook/addon-interactions": "8.1.11",
|
||||
"@storybook/addon-links": "8.1.11",
|
||||
"@storybook/addon-mdx-gfm": "8.1.11",
|
||||
"@storybook/addon-storysource": "8.1.11",
|
||||
"@storybook/blocks": "8.1.11",
|
||||
"@storybook/components": "8.1.11",
|
||||
"@storybook/core-events": "8.1.11",
|
||||
"@storybook/manager-api": "8.1.11",
|
||||
"@storybook/preview-api": "8.1.11",
|
||||
"@storybook/react": "8.1.11",
|
||||
"@storybook/react-vite": "8.1.11",
|
||||
"@storybook/test": "8.1.11",
|
||||
"@storybook/theming": "8.1.11",
|
||||
"@storybook/types": "8.1.11",
|
||||
"@storybook/vue3": "8.1.11",
|
||||
"@storybook/vue3-vite": "8.1.11",
|
||||
"@storybook/addon-actions": "8.3.4",
|
||||
"@storybook/addon-essentials": "8.3.4",
|
||||
"@storybook/addon-interactions": "8.3.4",
|
||||
"@storybook/addon-links": "8.3.4",
|
||||
"@storybook/addon-mdx-gfm": "8.3.4",
|
||||
"@storybook/addon-storysource": "8.3.4",
|
||||
"@storybook/blocks": "8.3.4",
|
||||
"@storybook/components": "8.3.4",
|
||||
"@storybook/core-events": "8.3.4",
|
||||
"@storybook/manager-api": "8.3.4",
|
||||
"@storybook/preview-api": "8.3.4",
|
||||
"@storybook/react": "8.3.4",
|
||||
"@storybook/react-vite": "8.3.4",
|
||||
"@storybook/test": "8.3.4",
|
||||
"@storybook/theming": "8.3.4",
|
||||
"@storybook/types": "8.3.4",
|
||||
"@storybook/vue3": "8.3.4",
|
||||
"@storybook/vue3-vite": "8.3.4",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/escape-regexp": "0.0.3",
|
||||
"@types/estree": "1.0.5",
|
||||
"@types/matter-js": "0.19.6",
|
||||
"@types/canvas-confetti": "^1.6.4",
|
||||
"@types/estree": "1.0.6",
|
||||
"@types/matter-js": "0.19.7",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "20.14.9",
|
||||
"@types/node": "20.14.12",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@types/ws": "8.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "7.15.0",
|
||||
"@typescript-eslint/parser": "7.15.0",
|
||||
"@types/ws": "8.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "7.17.0",
|
||||
"@typescript-eslint/parser": "7.17.0",
|
||||
"@vitest/coverage-v8": "1.6.0",
|
||||
"@vue/runtime-core": "3.4.31",
|
||||
"acorn": "8.12.0",
|
||||
"@vue/runtime-core": "3.5.11",
|
||||
"acorn": "8.12.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "13.13.0",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-vue": "9.26.0",
|
||||
"cypress": "13.15.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "9.28.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"happy-dom": "10.0.3",
|
||||
"intersection-observer": "0.12.2",
|
||||
"micromatch": "4.0.7",
|
||||
"msw": "2.3.1",
|
||||
"msw-storybook-addon": "2.0.2",
|
||||
"nodemon": "3.1.4",
|
||||
"prettier": "3.3.2",
|
||||
"micromatch": "4.0.8",
|
||||
"msw": "2.4.9",
|
||||
"msw-storybook-addon": "2.0.3",
|
||||
"nodemon": "3.1.7",
|
||||
"prettier": "3.3.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.4",
|
||||
"storybook": "8.1.11",
|
||||
"start-server-and-test": "2.0.8",
|
||||
"storybook": "8.3.4",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "1.6.0",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-component-type-helpers": "2.0.24",
|
||||
"vue-component-type-helpers": "2.1.6",
|
||||
"vue-eslint-parser": "9.4.3",
|
||||
"vue-tsc": "2.0.24"
|
||||
"vue-tsc": "2.1.6"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
// https://vitejs.dev/config/build-options.html#build-modulepreload
|
||||
import 'vite/modulepreload-polyfill';
|
||||
|
||||
import '@tabler/icons-webfont/dist/tabler-icons.scss';
|
||||
|
||||
import '@/style.scss';
|
||||
import { mainBoot } from '@/boot/main-boot.js';
|
||||
import { subBoot } from '@/boot/sub-boot.js';
|
||||
|
|
|
|||
|
|
@ -3,11 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
// devモードで起動される際(index.htmlを使うとき)はrouterが暴発してしまってうまく読み込めない。
|
||||
// よって、devモードとして起動されるときはビルド時に組み込む形としておく。
|
||||
// (pnpm start時はpugファイルの中で静的リソースとして読み込むようになっており、この問題は起こっていない)
|
||||
import '@tabler/icons-webfont/dist/tabler-icons.scss';
|
||||
|
||||
await main();
|
||||
|
||||
import('@/_boot_.js');
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import * as Misskey from 'misskey-js';
|
|||
import { showSuspendedDialog } from '@/scripts/show-suspended-dialog.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { MenuButton } from '@/types/menu.js';
|
||||
import type { MenuItem, MenuButton } from '@/types/menu.js';
|
||||
import { del, get, set } from '@/scripts/idb-proxy.js';
|
||||
import { apiUrl } from '@/config.js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import { waiting, popup, popupMenu, success, alert } from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload.js';
|
||||
|
|
@ -288,14 +288,26 @@ export async function openAccountMenu(opts: {
|
|||
});
|
||||
}));
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
popupMenu([...[{
|
||||
type: 'link' as const,
|
||||
menuItems.push({
|
||||
type: 'link',
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${ $i.username }`,
|
||||
to: `/@${$i.username}`,
|
||||
avatar: $i,
|
||||
}, { type: 'divider' as const }, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
type: 'parent' as const,
|
||||
}, {
|
||||
type: 'divider',
|
||||
});
|
||||
|
||||
if (opts.includeCurrentAccount) {
|
||||
menuItems.push(createItem($i));
|
||||
}
|
||||
|
||||
menuItems.push(...accountItemPromises);
|
||||
|
||||
menuItems.push({
|
||||
type: 'parent',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
children: [{
|
||||
|
|
@ -306,18 +318,22 @@ export async function openAccountMenu(opts: {
|
|||
action: () => { createAccount(); },
|
||||
}],
|
||||
}, {
|
||||
type: 'link' as const,
|
||||
type: 'link',
|
||||
icon: 'ti ti-users',
|
||||
text: i18n.ts.manageAccounts,
|
||||
to: '/settings/accounts',
|
||||
}]], ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
} else {
|
||||
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
if (opts.includeCurrentAccount) {
|
||||
menuItems.push(createItem($i));
|
||||
}
|
||||
|
||||
menuItems.push(...accountItemPromises);
|
||||
}
|
||||
|
||||
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
}
|
||||
|
||||
if (_DEV_) {
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
|
||||
import { computed, watch, version as vueVersion, App } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { version, lang, updateLocale, locale } from '@@/js/config.js';
|
||||
import widgets from '@/widgets/index.js';
|
||||
import directives from '@/directives/index.js';
|
||||
import components from '@/components/index.js';
|
||||
import { version, lang, updateLocale, locale } from '@/config.js';
|
||||
import { applyTheme } from '@/scripts/theme.js';
|
||||
import { isDeviceDarkmode } from '@/scripts/is-device-darkmode.js';
|
||||
import { updateI18n } from '@/i18n.js';
|
||||
|
|
@ -22,7 +22,8 @@ import { getAccountFromId } from '@/scripts/get-account-from-id.js';
|
|||
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { setupRouter } from '@/router/definition.js';
|
||||
import { setupRouter } from '@/router/main.js';
|
||||
import { createMainRouter } from '@/router/definition.js';
|
||||
|
||||
export async function common(createVue: () => App<Element>) {
|
||||
console.info(`Misskey v${version}`);
|
||||
|
|
@ -145,10 +146,9 @@ export async function common(createVue: () => App<Element>) {
|
|||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
watch(defaultStore.reactiveState.darkMode, (darkMode) => {
|
||||
applyTheme(darkMode ? ColdDeviceStorage.get('darkTheme') : ColdDeviceStorage.get('lightTheme'));
|
||||
document.documentElement.dataset.colorMode = darkMode ? 'dark' : 'light';
|
||||
}, { immediate: miLocalStorage.getItem('theme') == null });
|
||||
|
||||
document.documentElement.dataset.colorMode = defaultStore.state.darkMode ? 'dark' : 'light';
|
||||
document.documentElement.dataset.colorScheme = defaultStore.state.darkMode ? 'dark' : 'light';
|
||||
|
||||
const darkTheme = computed(ColdDeviceStorage.makeGetterSetter('darkTheme'));
|
||||
const lightTheme = computed(ColdDeviceStorage.makeGetterSetter('lightTheme'));
|
||||
|
|
@ -239,7 +239,7 @@ export async function common(createVue: () => App<Element>) {
|
|||
|
||||
const app = createVue();
|
||||
|
||||
setupRouter(app);
|
||||
setupRouter(app, createMainRouter);
|
||||
|
||||
if (_DEV_) {
|
||||
app.config.performance = true;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
|
||||
import { createApp, defineAsyncComponent, markRaw } from 'vue';
|
||||
import { common } from './common.js';
|
||||
import { ui } from '@/config.js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import { ui } from '@@/js/config.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { alert, confirm, popup, post, toast } from '@/os.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
|
|
@ -13,7 +14,6 @@ import * as sound from '@/scripts/sound.js';
|
|||
import { $i, signout, updateAccount } from '@/account.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { ColdDeviceStorage, defaultStore } from '@/store.js';
|
||||
import { makeHotkey } from '@/scripts/hotkey.js';
|
||||
import { reactionPicker } from '@/scripts/reaction-picker.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements.js';
|
||||
|
|
@ -21,6 +21,8 @@ import { initializeSw } from '@/scripts/initialize-sw.js';
|
|||
import { deckStore } from '@/ui/deck/deck-store.js';
|
||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
|
||||
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
|
|
@ -61,6 +63,18 @@ export async function mainBoot() {
|
|||
}
|
||||
});
|
||||
|
||||
stream.on('emojiAdded', emojiData => {
|
||||
addCustomEmoji(emojiData.emoji);
|
||||
});
|
||||
|
||||
stream.on('emojiUpdated', emojiData => {
|
||||
updateCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
stream.on('emojiDeleted', emojiData => {
|
||||
removeCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) {
|
||||
import('@/plugin.js').then(async ({ install }) => {
|
||||
// Workaround for https://bugs.webkit.org/show_bug.cgi?id=242740
|
||||
|
|
@ -69,14 +83,6 @@ export async function mainBoot() {
|
|||
});
|
||||
}
|
||||
|
||||
const hotkeys = {
|
||||
'd': (): void => {
|
||||
defaultStore.set('darkMode', !defaultStore.state.darkMode);
|
||||
},
|
||||
's': (): void => {
|
||||
mainRouter.push('/search');
|
||||
},
|
||||
};
|
||||
try {
|
||||
if (defaultStore.state.enableSeasonalScreenEffect) {
|
||||
const month = new Date().getMonth() + 1;
|
||||
|
|
@ -105,9 +111,6 @@ export async function mainBoot() {
|
|||
}
|
||||
|
||||
if ($i) {
|
||||
// only add post shortcuts if logged in
|
||||
hotkeys['p|n'] = post;
|
||||
|
||||
defaultStore.loaded.then(() => {
|
||||
if (defaultStore.state.accountSetupWizard !== -1) {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkUserSetupDialog.vue')), {}, {
|
||||
|
|
@ -124,7 +127,7 @@ export async function mainBoot() {
|
|||
});
|
||||
}
|
||||
|
||||
stream.on('announcementCreated', (ev) => {
|
||||
function onAnnouncementCreated (ev: { announcement: Misskey.entities.Announcement }) {
|
||||
const announcement = ev.announcement;
|
||||
if (announcement.display === 'dialog') {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkAnnouncementDialog.vue')), {
|
||||
|
|
@ -133,7 +136,9 @@ export async function mainBoot() {
|
|||
closed: () => dispose(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stream.on('announcementCreated', onAnnouncementCreated);
|
||||
|
||||
if ($i.isDeleted) {
|
||||
alert({
|
||||
|
|
@ -239,17 +244,18 @@ export async function mainBoot() {
|
|||
claimAchievement('client60min');
|
||||
}, 1000 * 60 * 60);
|
||||
|
||||
const lastUsed = miLocalStorage.getItem('lastUsed');
|
||||
if (lastUsed) {
|
||||
const lastUsedDate = parseInt(lastUsed, 10);
|
||||
// 二時間以上前なら
|
||||
if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
|
||||
toast(i18n.tsx.welcomeBackWithName({
|
||||
name: $i.name || $i.username,
|
||||
}));
|
||||
}
|
||||
}
|
||||
miLocalStorage.setItem('lastUsed', Date.now().toString());
|
||||
// 邪魔
|
||||
//const lastUsed = miLocalStorage.getItem('lastUsed');
|
||||
//if (lastUsed) {
|
||||
// const lastUsedDate = parseInt(lastUsed, 10);
|
||||
// // 二時間以上前なら
|
||||
// if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) {
|
||||
// toast(i18n.tsx.welcomeBackWithName({
|
||||
// name: $i.name || $i.username,
|
||||
// }));
|
||||
// }
|
||||
//}
|
||||
//miLocalStorage.setItem('lastUsed', Date.now().toString());
|
||||
|
||||
const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt');
|
||||
const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo');
|
||||
|
|
@ -326,6 +332,9 @@ export async function mainBoot() {
|
|||
updateAccount({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// 個人宛てお知らせが発行されたとき
|
||||
main.on('announcementCreated', onAnnouncementCreated);
|
||||
|
||||
// トークンが再生成されたとき
|
||||
// このままではMisskeyが利用できないので強制的にサインアウトさせる
|
||||
main.on('myTokenRegenerated', () => {
|
||||
|
|
@ -334,7 +343,19 @@ export async function mainBoot() {
|
|||
}
|
||||
|
||||
// shortcut
|
||||
document.addEventListener('keydown', makeHotkey(hotkeys));
|
||||
const keymap = {
|
||||
'p|n': () => {
|
||||
if ($i == null) return;
|
||||
post();
|
||||
},
|
||||
'd': () => {
|
||||
defaultStore.set('darkMode', !defaultStore.state.darkMode);
|
||||
},
|
||||
's': () => {
|
||||
mainRouter.push('/search');
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
|
||||
|
||||
initializeSw();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
|
||||
import { createApp, defineAsyncComponent } from 'vue';
|
||||
import { common } from './common.js';
|
||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
|
||||
export async function subBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
defineAsyncComponent(() => import('@/ui/minimum.vue')),
|
||||
));
|
||||
|
||||
emojiPicker.init();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,64 +4,99 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div class="bcekxzvu _margin _panel">
|
||||
<div class="target">
|
||||
<MkA v-user-preview="report.targetUserId" class="info" :to="`/admin/user/${report.targetUserId}`" :behavior="'window'">
|
||||
<MkAvatar class="avatar" :user="report.targetUser" indicator/>
|
||||
<div class="names">
|
||||
<MkUserName class="name" :user="report.targetUser"/>
|
||||
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
|
||||
</div>
|
||||
</MkA>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.registeredDate }}</template>
|
||||
<template #value>{{ dateString(report.targetUser.createdAt) }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<div>
|
||||
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
|
||||
<MkFolder>
|
||||
<template #icon>
|
||||
<i v-if="report.resolved" class="ti ti-check" style="color: var(--success)"></i>
|
||||
<i v-else class="ti ti-exclamation-circle" style="color: var(--warn)"></i>
|
||||
</template>
|
||||
<template #label><MkAcct :user="report.targetUser"/> (by <MkAcct :user="report.reporter"/>)</template>
|
||||
<template #caption>{{ report.comment }}</template>
|
||||
<template #suffix><MkTime :time="report.createdAt"/></template>
|
||||
<template v-if="!report.resolved" #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
|
||||
<template v-if="report.targetUser.host == null || report.resolved">
|
||||
<MkButton primary @click="resolveAndForward">{{ i18n.ts.forwardReport }}</MkButton>
|
||||
<div v-tooltip:dialog="i18n.ts.forwardReportIsAnonymous" class="_button _help"><i class="ti ti-help-circle"></i></div>
|
||||
</template>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>{{ i18n.ts.reporter }}: <MkA :to="`/admin/user/${report.reporter.id}`" class="_link" :behavior="'window'">@{{ report.reporter.username }}</MkA></div>
|
||||
</template>
|
||||
|
||||
<div :class="$style.root" class="_gaps_s">
|
||||
<MkFolder :withSpacer="false">
|
||||
<template #icon><MkAvatar :user="report.targetUser" style="width: 18px; height: 18px;"/></template>
|
||||
<template #label>Target: <MkAcct :user="report.targetUser"/></template>
|
||||
<template #suffix>#{{ report.targetUserId.toUpperCase() }}</template>
|
||||
|
||||
<div style="container-type: inline-size;">
|
||||
<RouterView :router="targetRouter"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #icon><i class="ti ti-message-2"></i></template>
|
||||
<template #label>{{ i18n.ts.details }}</template>
|
||||
<div>
|
||||
<Mfm :text="report.comment" :linkNavigationBehavior="'window'"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :withSpacer="false">
|
||||
<template #icon><MkAvatar :user="report.reporter" style="width: 18px; height: 18px;"/></template>
|
||||
<template #label>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></template>
|
||||
<template #suffix>#{{ report.reporterId.toUpperCase() }}</template>
|
||||
|
||||
<div style="container-type: inline-size;">
|
||||
<RouterView :router="reporterRouter"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<div v-if="report.assignee">
|
||||
{{ i18n.ts.moderator }}:
|
||||
<MkAcct :user="report.assignee"/>
|
||||
</div>
|
||||
<div><MkTime :time="report.createdAt"/></div>
|
||||
<div class="action">
|
||||
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
|
||||
{{ i18n.ts.forwardReport }}
|
||||
<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
|
||||
</MkSwitch>
|
||||
<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { provide, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import { useRouterFactory } from '@/router/supplier';
|
||||
|
||||
const props = defineProps<{
|
||||
report: any;
|
||||
report: Misskey.entities.AdminAbuseUserReportsResponse[number];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'resolved', reportId: string): void;
|
||||
}>();
|
||||
|
||||
const forward = ref(props.report.forwarded);
|
||||
const routerFactory = useRouterFactory();
|
||||
const targetRouter = routerFactory(`/admin/user/${props.report.targetUserId}`);
|
||||
targetRouter.init();
|
||||
const reporterRouter = routerFactory(`/admin/user/${props.report.reporterId}`);
|
||||
reporterRouter.init();
|
||||
|
||||
function resolve() {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
forward: forward.value,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit('resolved', props.report.id);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAndForward() {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
forward: true,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit('resolved', props.report.id);
|
||||
|
|
@ -69,47 +104,7 @@ function resolve() {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bcekxzvu {
|
||||
display: flex;
|
||||
|
||||
> .target {
|
||||
width: 35%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
border-right: solid 1px var(--divider);
|
||||
|
||||
> .info {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
--c: rgb(255 196 0 / 15%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
|
||||
> .avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
> .names {
|
||||
margin-left: 0.3em;
|
||||
padding: 0 8px;
|
||||
flex: 1;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .detail {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import * as os from '@/os.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
user: Misskey.entities.UserLite;
|
||||
initialComment?: string;
|
||||
}>();
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { ref } from 'vue';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import MkMention from './MkMention.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { host as localHost } from '@/config.js';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
|
||||
const user = ref<Misskey.entities.UserLite>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -7,14 +7,13 @@
|
|||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { abuseUserReport } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAbuseReport from './MkAbuseReport.vue';
|
||||
import MkAntennaEditor from './MkAntennaEditor.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkAbuseReport,
|
||||
MkAntennaEditor,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
|
|
@ -29,26 +28,35 @@ export const Default = {
|
|||
},
|
||||
events() {
|
||||
return {
|
||||
resolved: action('resolved'),
|
||||
created: action('created'),
|
||||
updated: action('updated'),
|
||||
deleted: action('deleted'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkAbuseReport v-bind="props" v-on="events" />',
|
||||
template: '<MkAntennaEditor v-bind="props" v-on="events" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
report: abuseUserReport(),
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/admin/resolve-abuse-user-report', async ({ request }) => {
|
||||
action('POST /api/admin/resolve-abuse-user-report')(await request.json());
|
||||
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 MkAbuseReport>;
|
||||
} satisfies StoryObj<typeof MkAntennaEditor>;
|
||||
|
|
@ -43,7 +43,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<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="antenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
<MkButton v-if="initialAntenna.id != null" inline danger @click="deleteAntenna()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -61,28 +61,53 @@ 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: Misskey.entities.Antenna
|
||||
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'): void,
|
||||
(ev: 'updated'): void,
|
||||
(ev: 'created', newAntenna: Misskey.entities.Antenna): void,
|
||||
(ev: 'updated', editedAntenna: Misskey.entities.Antenna): void,
|
||||
(ev: 'deleted'): void,
|
||||
}>();
|
||||
|
||||
const name = ref<string>(props.antenna.name);
|
||||
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(props.antenna.src);
|
||||
const userListId = ref<string | null>(props.antenna.userListId);
|
||||
const users = ref<string>(props.antenna.users.join('\n'));
|
||||
const keywords = ref<string>(props.antenna.keywords.map(x => x.join(' ')).join('\n'));
|
||||
const excludeKeywords = ref<string>(props.antenna.excludeKeywords.map(x => x.join(' ')).join('\n'));
|
||||
const caseSensitive = ref<boolean>(props.antenna.caseSensitive);
|
||||
const localOnly = ref<boolean>(props.antenna.localOnly);
|
||||
const excludeBots = ref<boolean>(props.antenna.excludeBots);
|
||||
const withReplies = ref<boolean>(props.antenna.withReplies);
|
||||
const withFile = ref<boolean>(props.antenna.withFile);
|
||||
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 () => {
|
||||
|
|
@ -106,24 +131,26 @@ async function saveAntenna() {
|
|||
excludeKeywords: excludeKeywords.value.trim().split('\n').map(x => x.trim().split(' ')),
|
||||
};
|
||||
|
||||
if (props.antenna.id == null) {
|
||||
await os.apiWithDialog('antennas/create', antennaData);
|
||||
emit('created');
|
||||
if (initialAntenna.id == null) {
|
||||
const res = await os.apiWithDialog('antennas/create', antennaData);
|
||||
emit('created', res);
|
||||
} else {
|
||||
await os.apiWithDialog('antennas/update', { ...antennaData, antennaId: props.antenna.id });
|
||||
emit('updated');
|
||||
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: props.antenna.name }),
|
||||
text: i18n.tsx.removeAreYouSure({ x: initialAntenna.name }),
|
||||
});
|
||||
if (canceled) return;
|
||||
|
||||
await misskeyApi('antennas/delete', {
|
||||
antennaId: props.antenna.id,
|
||||
antennaId: initialAntenna.id,
|
||||
});
|
||||
|
||||
os.success();
|
||||
|
|
@ -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>
|
||||
|
|
@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
|
||||
</template>
|
||||
</MkFolder>
|
||||
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align, backgroundColor: c.bgColor, color: c.fgColor, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
|
||||
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="containerStyle">
|
||||
<template v-for="child in c.children" :key="child">
|
||||
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/>
|
||||
</template>
|
||||
|
|
@ -63,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Ref, ref } from 'vue';
|
||||
import { Ref, ref, computed } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
|
|
@ -97,6 +97,29 @@ function g(id) {
|
|||
} as AsUiRoot;
|
||||
}
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
if (c.type !== 'container') return undefined;
|
||||
|
||||
// width, color, styleのうち一つでも指定があれば、枠線がちゃんと表示されるようにwidthとstyleのデフォルト値を設定
|
||||
// radiusは単に角を丸める用途もあるため除外
|
||||
const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle;
|
||||
|
||||
const border = isBordered ? {
|
||||
borderWidth: c.borderWidth ?? '1px',
|
||||
borderColor: c.borderColor ?? 'var(--divider)',
|
||||
borderStyle: c.borderStyle ?? 'solid',
|
||||
} : undefined;
|
||||
|
||||
return {
|
||||
textAlign: c.align,
|
||||
backgroundColor: c.bgColor,
|
||||
color: c.fgColor,
|
||||
padding: c.padding ? `${c.padding}px` : 0,
|
||||
borderRadius: (c.borderRadius ?? (c.rounded ? 8 : 0)) + 'px',
|
||||
...border,
|
||||
};
|
||||
});
|
||||
|
||||
const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false);
|
||||
|
||||
function onSwitchUpdate(v) {
|
||||
|
|
|
|||
|
|
@ -46,17 +46,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts">
|
||||
import { markRaw, ref, shallowRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { emojilist, getEmojiName } from '@/scripts/emojilist.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@/const.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
|
||||
import { searchEmoji, EmojiDef } from '@/scripts/search-emoji.js';
|
||||
|
||||
const lib = emojilist.filter(x => x.category !== 'flags');
|
||||
|
|
|
|||
|
|
@ -171,11 +171,11 @@ function onMousedown(evt: MouseEvent): void {
|
|||
background: var(--accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--X8);
|
||||
background: hsl(from var(--accent) h s calc(l + 5));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--X8);
|
||||
background: hsl(from var(--accent) h s calc(l + 5));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -220,15 +220,16 @@ function onMousedown(evt: MouseEvent): void {
|
|||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
font-weight: bold;
|
||||
color: #ff2a2a;
|
||||
|
||||
&.primary {
|
||||
|
|
@ -246,11 +247,10 @@ function onMousedown(evt: MouseEvent): void {
|
|||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,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: 32px;
|
||||
}
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
@ -100,7 +117,7 @@ const bannerStyle = computed(() => {
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
|
||||
}
|
||||
|
||||
> .name {
|
||||
|
|
|
|||
|
|
@ -13,29 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* eslint-disable id-denylist --
|
||||
Chart.js has a `data` attribute in most chart definitions, which triggers the
|
||||
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 } 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';
|
||||
import { chartVLine } from '@/scripts/chart-vline.js';
|
||||
import { alpha } from '@/scripts/color.js';
|
||||
import date from '@/filters/date.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { initChart } from '@/scripts/init-chart.js';
|
||||
import { chartLegend } from '@/scripts/chart-legend.js';
|
||||
import MkChartLegend from '@/components/MkChartLegend.vue';
|
||||
|
||||
initChart();
|
||||
|
||||
type ChartSrc =
|
||||
<script lang="ts">
|
||||
export type ChartSrc =
|
||||
| 'federation'
|
||||
| 'ap-request'
|
||||
| 'users'
|
||||
|
|
@ -62,7 +41,30 @@ type ChartSrc =
|
|||
| 'per-user-pv'
|
||||
| 'per-user-following'
|
||||
| 'per-user-followers'
|
||||
| 'per-user-drive'
|
||||
| 'per-user-drive';
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* eslint-disable id-denylist --
|
||||
Chart.js has a `data` attribute in most chart definitions, which triggers the
|
||||
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 } 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';
|
||||
import { chartVLine } from '@/scripts/chart-vline.js';
|
||||
import { alpha } from '@/scripts/color.js';
|
||||
import date from '@/filters/date.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { initChart } from '@/scripts/init-chart.js';
|
||||
import { chartLegend } from '@/scripts/chart-legend.js';
|
||||
import MkChartLegend from '@/components/MkChartLegend.vue';
|
||||
|
||||
initChart();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src: ChartSrc;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import * as game from '@/scripts/clicker-game.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export const Default = {
|
|||
},
|
||||
args: {
|
||||
clip: clip(),
|
||||
noUserInfo: false,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div :class="$style.codeBlockRoot">
|
||||
<button :class="$style.codeBlockCopyButton" class="_button" @click="copy">
|
||||
<button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy">
|
||||
<i class="ti ti-copy"></i>
|
||||
</button>
|
||||
<Suspense>
|
||||
|
|
@ -30,14 +30,19 @@ 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<{
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string;
|
||||
forceShow?: boolean;
|
||||
copyButton?: boolean;
|
||||
lang?: string;
|
||||
}>();
|
||||
}>(), {
|
||||
copyButton: true,
|
||||
forceShow: false,
|
||||
});
|
||||
|
||||
const show = ref(!defaultStore.state.dataSaver.code);
|
||||
const show = ref(props.forceShow === true ? true : !defaultStore.state.dataSaver.code);
|
||||
|
||||
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
|
||||
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ onUnmounted(() => {
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
|
||||
|
||||
> .fadeLabel {
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, onBeforeUnmount, shallowRef, ref } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import contains from '@/scripts/contains.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import * as os from '@/os.js';
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
|
|||
import * as os from '@/os.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { apiUrl } from '@/config.js';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -43,9 +43,9 @@ export default defineComponent({
|
|||
setup(props, { slots, expose }) {
|
||||
const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
|
||||
|
||||
function getDateText(time: string) {
|
||||
const date = new Date(time).getDate();
|
||||
const month = new Date(time).getMonth() + 1;
|
||||
function getDateText(dateInstance: Date) {
|
||||
const date = dateInstance.getDate();
|
||||
const month = dateInstance.getMonth() + 1;
|
||||
return i18n.tsx.monthAndDay({
|
||||
month: month.toString(),
|
||||
day: date.toString(),
|
||||
|
|
@ -62,9 +62,16 @@ export default defineComponent({
|
|||
})[0];
|
||||
if (el.key == null && item.id) el.key = item.id;
|
||||
|
||||
const date = new Date(item.createdAt);
|
||||
const nextDate = props.items[i + 1] ? new Date(props.items[i + 1].createdAt) : null;
|
||||
|
||||
if (
|
||||
i !== props.items.length - 1 &&
|
||||
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
|
||||
nextDate != null && (
|
||||
date.getFullYear() !== nextDate.getFullYear() ||
|
||||
date.getMonth() !== nextDate.getMonth() ||
|
||||
date.getDate() !== nextDate.getDate()
|
||||
)
|
||||
) {
|
||||
const separator = h('div', {
|
||||
class: $style['separator'],
|
||||
|
|
@ -78,12 +85,12 @@ export default defineComponent({
|
|||
h('i', {
|
||||
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
|
||||
}),
|
||||
getDateText(item.createdAt),
|
||||
getDateText(date),
|
||||
]),
|
||||
h('span', {
|
||||
class: $style['date-2'],
|
||||
}, [
|
||||
getDateText(props.items[i + 1].createdAt),
|
||||
getDateText(nextDate),
|
||||
h('i', {
|
||||
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
|
||||
}),
|
||||
|
|
|
|||
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;
|
||||
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>;
|
||||
|
|
@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
import { host } from '@/config.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -115,14 +115,14 @@ function onDragend() {
|
|||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b65a5;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
|
@ -133,14 +133,14 @@ function onDragend() {
|
|||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b588c;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #ce2212;
|
||||
}
|
||||
}
|
||||
|
|
@ -159,8 +159,8 @@ function onDragend() {
|
|||
}
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -181,8 +181,8 @@ function onDragend() {
|
|||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
@ -190,14 +190,14 @@ function onDragend() {
|
|||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
|
|
@ -205,8 +205,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,8 +41,8 @@ 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 { MenuItem } from '@/types/menu.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
folder: Misskey.entities.DriveFolder;
|
||||
|
|
@ -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,
|
||||
|
|
@ -267,6 +285,10 @@ function onContextmenu(ev: MouseEvent) {
|
|||
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',
|
||||
|
|
@ -296,7 +318,7 @@ function onContextmenu(ev: MouseEvent) {
|
|||
cursor: pointer;
|
||||
|
||||
&.draghover {
|
||||
&:after {
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
|
|
@ -310,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>;
|
||||
|
|
@ -52,6 +52,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"
|
||||
|
|
@ -428,6 +429,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();
|
||||
|
|
@ -614,7 +620,9 @@ function fetchMoreFiles() {
|
|||
}
|
||||
|
||||
function getMenu() {
|
||||
const menu: MenuItem[] = [{
|
||||
const menu: MenuItem[] = [];
|
||||
|
||||
menu.push({
|
||||
type: 'switch',
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
|
|
@ -632,19 +640,25 @@ function getMenu() {
|
|||
}, { type: 'divider' }, {
|
||||
text: folder.value ? folder.value.name : i18n.ts.drive,
|
||||
type: 'label',
|
||||
}, folder.value ? {
|
||||
text: i18n.ts.renameFolder,
|
||||
icon: 'ti ti-forms',
|
||||
action: () => { if (folder.value) renameFolder(folder.value); },
|
||||
} : undefined, folder.value ? {
|
||||
text: i18n.ts.deleteFolder,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
|
||||
} : undefined, {
|
||||
});
|
||||
|
||||
if (folder.value) {
|
||||
menu.push({
|
||||
text: i18n.ts.renameFolder,
|
||||
icon: 'ti ti-forms',
|
||||
action: () => { if (folder.value) renameFolder(folder.value); },
|
||||
}, {
|
||||
text: i18n.ts.deleteFolder,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
|
||||
});
|
||||
}
|
||||
|
||||
menu.push({
|
||||
text: i18n.ts.createFolder,
|
||||
icon: 'ti ti-folder-plus',
|
||||
action: () => { createFolder(); },
|
||||
}];
|
||||
});
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -4,7 +4,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div ref="thumbnail" :class="$style.root">
|
||||
<div
|
||||
ref="thumbnail"
|
||||
:class="[
|
||||
$style.root,
|
||||
{ [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive },
|
||||
]"
|
||||
>
|
||||
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
|
||||
<i v-else-if="is === 'image'" class="ti ti-photo" :class="$style.icon"></i>
|
||||
<i v-else-if="is === 'video'" class="ti ti-video" :class="$style.icon"></i>
|
||||
|
|
@ -26,7 +32,8 @@ import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
|||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
fit: string;
|
||||
fit: 'cover' | 'contain';
|
||||
highlightWhenSensitive?: boolean;
|
||||
}>();
|
||||
|
||||
const is = computed(() => {
|
||||
|
|
@ -67,6 +74,18 @@ const isThumbnailAvailable = computed(() => {
|
|||
overflow: clip;
|
||||
}
|
||||
|
||||
.sensitiveHighlight::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
box-shadow: inset 0 0 0 4px var(--warn);
|
||||
}
|
||||
|
||||
.iconSub {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
414
packages/frontend/src/components/MkEmbedCodeGenDialog.vue
Normal file
414
packages/frontend/src/components/MkEmbedCodeGenDialog.vue
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialogEl"
|
||||
:width="1000"
|
||||
:height="600"
|
||||
:scroll="false"
|
||||
:withOkButton="false"
|
||||
@close="cancel()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header><i class="ti ti-code"></i> {{ i18n.ts._embedCodeGen.title }}</template>
|
||||
|
||||
<div :class="$style.embedCodeGenRoot">
|
||||
<Transition
|
||||
mode="out-in"
|
||||
:enterActiveClass="$style.transition_x_enterActive"
|
||||
:leaveActiveClass="$style.transition_x_leaveActive"
|
||||
:enterFromClass="$style.transition_x_enterFrom"
|
||||
:leaveToClass="$style.transition_x_leaveTo"
|
||||
>
|
||||
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
|
||||
<div
|
||||
:class="$style.embedCodeGenPreviewRoot"
|
||||
>
|
||||
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
|
||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
|
||||
<div
|
||||
:class="$style.embedCodeGenPreviewResizer"
|
||||
:style="{ transform: iframeStyle }"
|
||||
>
|
||||
<iframe
|
||||
ref="iframeEl"
|
||||
:src="embedPreviewUrl"
|
||||
:class="$style.embedCodeGenPreviewIframe"
|
||||
:style="{ height: `${iframeHeight}px` }"
|
||||
@load="iframeOnLoad"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.embedCodeGenSettings" class="_gaps">
|
||||
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
|
||||
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
|
||||
<template #suffix>px</template>
|
||||
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="colorMode">
|
||||
<template #label>{{ i18n.ts.theme }}</template>
|
||||
<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
|
||||
<option value="light">{{ i18n.ts.light }}</option>
|
||||
<option value="dark">{{ i18n.ts.dark }}</option>
|
||||
</MkSelect>
|
||||
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
|
||||
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
|
||||
<MkSwitch v-model="border">{{ i18n.ts._embedCodeGen.border }}</MkSwitch>
|
||||
<MkInfo v-if="isEmbedWithScrollbar && (!maxHeight || maxHeight <= 0)" warn>{{ i18n.ts._embedCodeGen.maxHeightWarn }}</MkInfo>
|
||||
<MkInfo v-if="typeof maxHeight === 'number' && (maxHeight <= 0 || maxHeight > 700)">{{ i18n.ts._embedCodeGen.previewIsNotActual }}</MkInfo>
|
||||
<div class="_buttons">
|
||||
<MkButton :disabled="iframeLoading" @click="applyToPreview">{{ i18n.ts._embedCodeGen.applyToPreview }}</MkButton>
|
||||
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
|
||||
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
<div :class="$style.embedCodeGenResultHeadingIcon"><i class="ti ti-check"></i></div>
|
||||
<div :class="$style.embedCodeGenResultHeading">{{ i18n.ts._embedCodeGen.codeGenerated }}</div>
|
||||
<div :class="$style.embedCodeGenResultDescription">{{ i18n.ts._embedCodeGen.codeGeneratedDescription }}</div>
|
||||
</div>
|
||||
<div class="_gaps_s">
|
||||
<MkCode :code="result" lang="html" :forceShow="true" :copyButton="false" :class="$style.embedCodeGenResultCode"/>
|
||||
<MkButton :class="$style.embedCodeGenResultButtons" rounded primary @click="doCopy"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
|
||||
</div>
|
||||
<MkButton :class="$style.embedCodeGenResultButtons" rounded transparent @click="close">{{ i18n.ts.close }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { shallowRef, ref, computed, nextTick, onMounted, onDeactivated, onUnmounted } from 'vue';
|
||||
import { url } from '@@/js/config.js';
|
||||
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { normalizeEmbedParams, getEmbedCode } from '@/scripts/get-embed-code.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok'): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
entity: EmbeddableEntity;
|
||||
id: string;
|
||||
params?: EmbedParams;
|
||||
}>();
|
||||
|
||||
//#region Modalの制御
|
||||
const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>();
|
||||
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
dialogEl.value?.close();
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialogEl.value?.close();
|
||||
}
|
||||
|
||||
const phase = ref<'input' | 'result'>('input');
|
||||
//#endregion
|
||||
|
||||
//#region 埋め込みURL生成・カスタマイズ
|
||||
|
||||
// 本URL生成用params
|
||||
const paramsForUrl = computed<EmbedParams>(() => ({
|
||||
header: header.value,
|
||||
maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
|
||||
colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
|
||||
rounded: rounded.value,
|
||||
border: border.value,
|
||||
}));
|
||||
|
||||
// プレビュー用params(手動で更新を掛けるのでref)
|
||||
const paramsForPreview = ref<EmbedParams>(props.params ?? {});
|
||||
|
||||
const embedPreviewUrl = computed(() => {
|
||||
const paramClass = new URLSearchParams(normalizeEmbedParams(paramsForPreview.value));
|
||||
if (paramClass.has('maxHeight')) {
|
||||
const maxHeight = parseInt(paramClass.get('maxHeight')!);
|
||||
paramClass.set('maxHeight', maxHeight === 0 ? '500' : Math.min(maxHeight, 700).toString()); // プレビューであまりにも縮小されると見づらいため、700pxまでに制限
|
||||
}
|
||||
return `${url}/embed/${props.entity}/${props.id}${paramClass.toString() ? '?' + paramClass.toString() : ''}`;
|
||||
});
|
||||
|
||||
const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(props.entity));
|
||||
const header = ref(props.params?.header ?? true);
|
||||
const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? undefined : 500);
|
||||
|
||||
const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
|
||||
const rounded = ref(props.params?.rounded ?? true);
|
||||
const border = ref(props.params?.border ?? true);
|
||||
|
||||
function applyToPreview() {
|
||||
const currentPreviewUrl = embedPreviewUrl.value;
|
||||
|
||||
paramsForPreview.value = {
|
||||
header: header.value,
|
||||
maxHeight: typeof maxHeight.value === 'number' ? Math.max(0, maxHeight.value) : undefined,
|
||||
colorMode: colorMode.value === 'auto' ? undefined : colorMode.value,
|
||||
rounded: rounded.value,
|
||||
border: border.value,
|
||||
};
|
||||
|
||||
nextTick(() => {
|
||||
if (currentPreviewUrl === embedPreviewUrl.value) {
|
||||
// URLが変わらなくてもリロード
|
||||
iframeEl.value?.contentWindow?.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const result = ref('');
|
||||
|
||||
function generate() {
|
||||
result.value = getEmbedCode(`/embed/${props.entity}/${props.id}`, paramsForUrl.value);
|
||||
phase.value = 'result';
|
||||
}
|
||||
|
||||
function doCopy() {
|
||||
copyToClipboard(result.value);
|
||||
os.success();
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region プレビューのリサイズ
|
||||
const resizerRootEl = shallowRef<HTMLDivElement>();
|
||||
const iframeLoading = ref(true);
|
||||
const iframeEl = shallowRef<HTMLIFrameElement>();
|
||||
const iframeHeight = ref(0);
|
||||
const iframeScale = ref(1);
|
||||
const iframeStyle = computed(() => {
|
||||
return `translate(-50%, -50%) scale(${iframeScale.value})`;
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calcScale();
|
||||
});
|
||||
|
||||
function iframeOnLoad() {
|
||||
iframeEl.value?.contentWindow?.addEventListener('beforeunload', () => {
|
||||
iframeLoading.value = true;
|
||||
nextTick(() => {
|
||||
iframeHeight.value = 0;
|
||||
iframeScale.value = 1;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function windowEventHandler(event: MessageEvent) {
|
||||
if (event.source !== iframeEl.value?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
if (event.data.type === 'misskey:embed:ready') {
|
||||
iframeEl.value!.contentWindow?.postMessage({
|
||||
type: 'misskey:embedParent:registerIframeId',
|
||||
payload: {
|
||||
iframeId: 'embedCodeGen', // 同じタイミングで複数のembed iframeがある際の区別用なのでここではなんでもいい
|
||||
},
|
||||
});
|
||||
}
|
||||
if (event.data.type === 'misskey:embed:changeHeight') {
|
||||
iframeHeight.value = event.data.payload.height;
|
||||
nextTick(() => {
|
||||
calcScale();
|
||||
iframeLoading.value = false; // 初回の高さ変更まで待つ
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function calcScale() {
|
||||
if (!resizerRootEl.value) return;
|
||||
const previewWidth = resizerRootEl.value.clientWidth - 40; // 左右の余白 20pxずつ
|
||||
const previewHeight = resizerRootEl.value.clientHeight - 40; // 上下の余白 20pxずつ
|
||||
const iframeWidth = 500;
|
||||
const scale = Math.min(previewWidth / iframeWidth, previewHeight / iframeHeight.value, 1); // 拡大はしないので1を上限に
|
||||
iframeScale.value = scale;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', windowEventHandler);
|
||||
if (!resizerRootEl.value) return;
|
||||
resizeObserver.observe(resizerRootEl.value);
|
||||
});
|
||||
|
||||
function reset() {
|
||||
window.removeEventListener('message', windowEventHandler);
|
||||
resizeObserver.disconnect();
|
||||
|
||||
// プレビューのリセット
|
||||
iframeHeight.value = 0;
|
||||
iframeScale.value = 1;
|
||||
iframeLoading.value = true;
|
||||
result.value = '';
|
||||
phase.value = 'input';
|
||||
}
|
||||
|
||||
onDeactivated(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
reset();
|
||||
});
|
||||
//#endregion
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.transition_x_enterActive,
|
||||
.transition_x_leaveActive {
|
||||
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||
}
|
||||
.transition_x_enterFrom {
|
||||
opacity: 0;
|
||||
transform: translateX(50px);
|
||||
}
|
||||
.transition_x_leaveTo {
|
||||
opacity: 0;
|
||||
transform: translateX(-50px);
|
||||
}
|
||||
|
||||
.embedCodeGenRoot {
|
||||
container-type: inline-size;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embedCodeGenInputRoot {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewRoot {
|
||||
position: relative;
|
||||
background-color: var(--bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--panel) 6px, var(--panel) 12px);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewTitle {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 85%;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewSpinner {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewResizerRoot {
|
||||
position: relative;
|
||||
flex: 1 0;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewResizer {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewIframe {
|
||||
display: block;
|
||||
border: none;
|
||||
width: 500px;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.embedCodeGenSettings {
|
||||
padding: 24px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.embedCodeGenResultRoot {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
height: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.embedCodeGenResultHeading {
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.embedCodeGenResultHeadingIcon {
|
||||
margin: 0 auto;
|
||||
background-color: var(--accentedBg);
|
||||
color: var(--accent);
|
||||
text-align: center;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
font-size: 24px;
|
||||
line-height: 64px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.embedCodeGenResultDescription {
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.embedCodeGenResultWrapper,
|
||||
.embedCodeGenResultCode {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.embedCodeGenResultButtons {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.embedCodeGenInputRoot {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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;
|
||||
|
|
@ -62,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, Ref } from 'vue';
|
||||
import { CustomEmojiFolderTree, getEmojiName } from '@/scripts/emojilist.js';
|
||||
import { CustomEmojiFolderTree, getEmojiName } from '@@/js/emojilist.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
import MkEmojiPickerSection from '@/components/MkEmojiPicker.section.vue';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -105,7 +117,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, computed, watch, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XSection from '@/components/MkEmojiPicker.section.vue';
|
||||
import {
|
||||
emojilist,
|
||||
emojiCharByCategory,
|
||||
|
|
@ -114,7 +125,8 @@ import {
|
|||
getEmojiName,
|
||||
CustomEmojiFolderTree,
|
||||
getUnicodeEmoji,
|
||||
} from '@/scripts/emojilist.js';
|
||||
} from '@@/js/emojilist.js';
|
||||
import XSection from '@/components/MkEmojiPicker.section.vue';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { isTouchUsing } from '@/scripts/touch.js';
|
||||
|
|
@ -139,6 +151,7 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: string): void;
|
||||
(ev: 'esc'): void;
|
||||
}>();
|
||||
|
||||
const searchEl = shallowRef<HTMLInputElement>();
|
||||
|
|
@ -433,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 {
|
||||
|
|
@ -589,6 +611,7 @@ defineExpose({
|
|||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
|
|
@ -695,18 +718,13 @@ defineExpose({
|
|||
|
||||
> .item {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
padding: 0 3px;
|
||||
width: var(--eachSize);
|
||||
height: var(--eachSize);
|
||||
contain: strict;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
|
|
@ -8,11 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="modal"
|
||||
v-slot="{ type, maxHeight }"
|
||||
:zPriority="'middle'"
|
||||
:preferType="defaultStore.state.emojiPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
|
||||
:preferType="defaultStore.state.emojiPickerStyle"
|
||||
: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>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
class="file _button"
|
||||
>
|
||||
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
|
||||
<div v-if="viewMode === 'list'" class="body">
|
||||
<div>
|
||||
<small style="opacity: 0.7;">{{ file.name }}</small>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkFlashPreview from './MkFlashPreview.vue';
|
||||
import { flash } from './../../.storybook/fakes.js';
|
||||
export const Public = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkFlashPreview,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkFlashPreview v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
flash: {
|
||||
...flash(),
|
||||
visibility: 'public',
|
||||
},
|
||||
},
|
||||
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 MkFlashPreview>;
|
||||
export const Private = {
|
||||
...Public,
|
||||
args: {
|
||||
flash: {
|
||||
...flash(),
|
||||
visibility: 'private',
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkFlashPreview>;
|
||||
|
|
@ -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" :class="[{ gray: flash.visibility === 'private' }]">
|
||||
<article>
|
||||
<header>
|
||||
<h1 :title="flash.title">{{ flash.title }}</h1>
|
||||
|
|
@ -22,11 +22,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { userName } from '@/filters/user.js';
|
||||
|
||||
const props = defineProps<{
|
||||
//flash: Misskey.entities.Flash;
|
||||
flash: any;
|
||||
flash: Misskey.entities.Flash;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
|
|
@ -39,6 +39,10 @@ const props = defineProps<{
|
|||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
|
|
@ -87,6 +91,12 @@ const props = defineProps<{
|
|||
}
|
||||
}
|
||||
|
||||
&:global(.gray) {
|
||||
--c: var(--bg);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
@ -38,9 +38,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer :marginMin="14" :marginMax="22">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-if="$slots.footer" :class="$style.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
|
@ -56,9 +62,11 @@ import { defaultStore } from '@/store.js';
|
|||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
maxHeight?: number | null;
|
||||
withSpacer?: boolean;
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
maxHeight: null,
|
||||
withSpacer: true,
|
||||
});
|
||||
|
||||
const getBgColor = (el: HTMLElement) => {
|
||||
|
|
@ -136,7 +144,7 @@ onMounted(() => {
|
|||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 9px 12px 9px 12px;
|
||||
background: var(--buttonBg);
|
||||
background: var(--folderHeaderBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
border-radius: 6px;
|
||||
|
|
@ -144,12 +152,16 @@ onMounted(() => {
|
|||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--buttonHoverBg);
|
||||
background: var(--folderHeaderHoverBg);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--accent);
|
||||
background: var(--buttonHoverBg);
|
||||
background: var(--folderHeaderHoverBg);
|
||||
}
|
||||
|
||||
&.opened {
|
||||
|
|
@ -190,6 +202,12 @@ onMounted(() => {
|
|||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.headerTextMain,
|
||||
.headerTextSub {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.headerTextSub {
|
||||
color: var(--fgTransparentWeak);
|
||||
font-size: .85em;
|
||||
|
|
@ -214,4 +232,18 @@ onMounted(() => {
|
|||
background: var(--bg);
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky !important;
|
||||
z-index: 1;
|
||||
bottom: var(--stickyBottom, 0px);
|
||||
left: 0;
|
||||
padding: 12px;
|
||||
background: var(--acrylicBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, var(--panel) 5px, var(--panel) 10px);
|
||||
border-radius: 0 0 6px 6px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-if="full" :class="$style.text">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
|
||||
</template>
|
||||
<template v-else-if="isFollowing">
|
||||
<span v-if="full" :class="$style.text">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
|
||||
<span v-if="full" :class="$style.text">{{ i18n.ts.youFollowing }}</span><i class="ti ti-minus"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && user.isLocked">
|
||||
<span v-if="full" :class="$style.text">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
|
||||
|
|
@ -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 '@@/js/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 {
|
||||
|
|
@ -185,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: 32px;
|
||||
}
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
49
packages/frontend/src/components/MkFormFooter.vue
Normal file
49
packages/frontend/src/components/MkFormFooter.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
|
||||
<div style="margin-left: auto;" class="_buttons">
|
||||
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
|
||||
<MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
form: {
|
||||
modifiedCount: {
|
||||
value: number;
|
||||
};
|
||||
discard: () => void;
|
||||
save: () => void;
|
||||
};
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--warn);
|
||||
font-size: 90%;
|
||||
animation: modified-blink 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes modified-blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
100
packages/frontend/src/components/MkFukidashi.vue
Normal file
100
packages/frontend/src/components/MkFukidashi.vue
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
$style.root,
|
||||
tail === 'left' ? $style.left : $style.right,
|
||||
negativeMargin === true && $style.negativeMergin,
|
||||
shadow === true && $style.shadow,
|
||||
]"
|
||||
>
|
||||
<div :class="$style.bg">
|
||||
<svg v-if="tail !== 'none'" :class="$style.tail" version="1.1" viewBox="0 0 14.597 14.58" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="translate(-173.71 -87.184)">
|
||||
<path d="m188.19 87.657c-1.469 2.3218-3.9315 3.8312-6.667 4.0865-2.2309-1.7379-4.9781-2.6816-7.8061-2.6815h-5.1e-4v12.702h12.702v-5.1e-4c2e-5 -1.9998-0.47213-3.9713-1.378-5.754 2.0709-1.6834 3.2732-4.2102 3.273-6.8791-6e-5 -0.49375-0.0413-0.98662-0.1235-1.4735z" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round" stroke-width=".33225" style="paint-order:stroke fill markers"/>
|
||||
</g>
|
||||
</svg>
|
||||
<div :class="$style.content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
tail?: 'left' | 'right' | 'none';
|
||||
negativeMargin?: boolean;
|
||||
shadow?: boolean;
|
||||
}>(), {
|
||||
tail: 'right',
|
||||
negativeMargin: false,
|
||||
shadow: false,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
--fukidashi-radius: var(--radius);
|
||||
--fukidashi-bg: var(--panel);
|
||||
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
min-height: calc(var(--fukidashi-radius) * 2);
|
||||
padding-top: calc(var(--fukidashi-radius) * .13);
|
||||
|
||||
&.shadow {
|
||||
filter: drop-shadow(0 4px 32px var(--shadow));
|
||||
}
|
||||
|
||||
&.left {
|
||||
padding-left: calc(var(--fukidashi-radius) * .13);
|
||||
|
||||
&.negativeMergin {
|
||||
margin-left: calc(calc(var(--fukidashi-radius) * .13) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
&.right {
|
||||
padding-right: calc(var(--fukidashi-radius) * .13);
|
||||
|
||||
&.negativeMergin {
|
||||
margin-right: calc(calc(var(--fukidashi-radius) * .13) * -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--fukidashi-bg);
|
||||
border-radius: var(--fukidashi-radius);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.tail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: block;
|
||||
width: calc(var(--fukidashi-radius) * 1.13);
|
||||
height: auto;
|
||||
fill: var(--fukidashi-bg);
|
||||
}
|
||||
|
||||
.left .tail {
|
||||
left: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.right .tail {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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>
|
||||
|
|
@ -23,8 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts">
|
||||
import DrawBlurhash from '@/workers/draw-blurhash?worker';
|
||||
import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch.js';
|
||||
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash.js';
|
||||
import { WorkerMultiDispatch } from '@@/js/worker-multi-dispatch.js';
|
||||
import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurhash.js';
|
||||
|
||||
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
|
||||
// テスト環境で Web Worker インスタンスは作成できない
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { onMounted, onUnmounted, nextTick, ref, shallowRef, watch, computed, toRefs } from 'vue';
|
||||
import { debounce } from 'throttle-debounce';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { Autocomplete, SuggestionType } from '@/scripts/autocomplete.js';
|
||||
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { instanceName } from '@/config.js';
|
||||
import { instanceName } from '@@/js/config.js';
|
||||
import { instance as Instance } from '@/instance.js';
|
||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span>
|
||||
<span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="_buttons">
|
||||
<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
|
||||
<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="_gaps_s" :class="$style.root">
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.items">
|
||||
<div>
|
||||
<div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
|
||||
|
|
@ -49,10 +55,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div><MkTime :time="invite.createdAt" mode="absolute"/></div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.buttons">
|
||||
<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
|
||||
<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
|
@ -62,7 +64,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';
|
||||
|
||||
|
|
@ -121,9 +123,4 @@ function copyInviteCode() {
|
|||
width: var(--height);
|
||||
height: var(--height);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { url as local } from '@/config.js';
|
||||
import { url as local } from '@@/js/config.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
|
|
|
|||
|
|
@ -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,23 +39,37 @@ 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>
|
||||
</div>
|
||||
<div :class="[$style.controlsChild, $style.controlsRight]">
|
||||
<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>
|
||||
|
|
@ -80,6 +94,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';
|
||||
|
|
@ -90,32 +105,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,13 +156,23 @@ const audioEl = shallowRef<HTMLAudioElement>();
|
|||
// 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);
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
let menu: MenuItem[] = [];
|
||||
|
||||
menu = [
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
|
|
@ -183,7 +220,7 @@ function showMenu(ev: MouseEvent) {
|
|||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'link' as const,
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.audio.id}`,
|
||||
|
|
@ -358,7 +395,7 @@ onDeactivated(() => {
|
|||
border-radius: var(--radius);
|
||||
overflow: clip;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -424,6 +461,10 @@ onDeactivated(() => {
|
|||
color: var(--accent);
|
||||
background-color: var(--accentedBg);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkMediaAudio v-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" :audio="media"/>
|
||||
<div v-else-if="media.isSensitive && hide" :class="$style.sensitive" @click="hide = false">
|
||||
<div v-else-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>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" :style="darkMode ? '--c: rgb(255 255 255 / 2%);' : '--c: rgb(0 0 0 / 2%);'" @click="onclick">
|
||||
<div :class="[hide ? $style.hidden : $style.visible, (image.isSensitive && defaultStore.state.highlightSensitiveMedia) && $style.sensitive]" @click="onclick">
|
||||
<component
|
||||
:is="disableImageLink ? 'div' : 'a'"
|
||||
v-bind="disableImageLink ? {
|
||||
|
|
@ -53,6 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
|
|
@ -74,7 +75,6 @@ const props = withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const hide = ref(true);
|
||||
const darkMode = ref<boolean>(defaultStore.state.darkMode);
|
||||
|
||||
const url = computed(() => (props.raw || defaultStore.state.loadRawImages)
|
||||
? props.image.url
|
||||
|
|
@ -83,11 +83,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -101,27 +111,39 @@ watch(() => props.image, () => {
|
|||
});
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
menuItems.push({
|
||||
text: i18n.ts.hide,
|
||||
icon: 'ti ti-eye-off',
|
||||
action: () => {
|
||||
hide.value = true;
|
||||
},
|
||||
}, ...(iAmModerator ? [{
|
||||
text: i18n.ts.markAsSensitive,
|
||||
icon: 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: () => {
|
||||
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
|
||||
},
|
||||
}] : []), ...($i?.id === props.image.userId ? [{
|
||||
type: 'divider' as const,
|
||||
}, {
|
||||
type: 'link' as const,
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.image.id}`,
|
||||
}] : [])], ev.currentTarget ?? ev.target);
|
||||
});
|
||||
|
||||
if (iAmModerator) {
|
||||
menuItems.push({
|
||||
text: i18n.ts.markAsSensitive,
|
||||
icon: 'ti ti-eye-exclamation',
|
||||
danger: true,
|
||||
action: () => {
|
||||
os.apiWithDialog('drive/files/update', { fileId: props.image.id, isSensitive: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if ($i?.id === props.image.userId) {
|
||||
menuItems.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.image.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
os.popupMenu(menuItems, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -186,10 +208,19 @@ function showMenu(ev: MouseEvent) {
|
|||
position: relative;
|
||||
//box-shadow: 0 0 0 1px var(--divider) inset;
|
||||
background: var(--bg);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
html[data-color-scheme=dark] .visible {
|
||||
--c: rgb(255 255 255 / 2%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
html[data-color-scheme=light] .visible {
|
||||
--c: rgb(0 0 0 / 2%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, var(--bg) 16.67%, var(--bg) 50%, var(--c) 50%, var(--c) 66.67%, var(--bg) 66.67%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: block;
|
||||
position: absolute;
|
||||
|
|
|
|||
|
|
@ -37,8 +37,9 @@ import XBanner from '@/components/MkMediaBanner.vue';
|
|||
import XImage from '@/components/MkMediaImage.vue';
|
||||
import XVideo from '@/components/MkMediaVideo.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@@/js/const.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { focusParent } from '@/scripts/focus.js';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: Misskey.entities.DriveFile[];
|
||||
|
|
@ -49,7 +50,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) {
|
||||
|
|
@ -60,7 +63,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 = '';
|
||||
|
|
@ -131,18 +134,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);
|
||||
|
|
@ -154,44 +156,54 @@ onMounted(() => {
|
|||
itemData.alt = file.comment ?? file.name;
|
||||
itemData.comment = file.comment ?? file.name;
|
||||
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', () => {
|
||||
textBox.textContent = pwsp.currSlide?.data.comment;
|
||||
pswp.on('change', () => {
|
||||
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 => {
|
||||
|
|
@ -199,6 +211,16 @@ const previewable = (file: Misskey.entities.DriveFile): boolean => {
|
|||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
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>
|
||||
|
|
@ -298,7 +320,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;
|
||||
|
|
@ -312,7 +334,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>
|
||||
|
|
@ -112,6 +112,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';
|
||||
|
|
@ -127,32 +128,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,13 +176,23 @@ function hasFocus() {
|
|||
// 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);
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
let menu: MenuItem[] = [];
|
||||
|
||||
menu = [
|
||||
const menu: MenuItem[] = [
|
||||
// TODO: 再生キューに追加
|
||||
{
|
||||
type: 'switch',
|
||||
|
|
@ -222,7 +245,7 @@ function showMenu(ev: MouseEvent) {
|
|||
menu.push({
|
||||
type: 'divider',
|
||||
}, {
|
||||
type: 'link' as const,
|
||||
type: 'link',
|
||||
text: i18n.ts._fileViewer.title,
|
||||
icon: 'ti ti-info-circle',
|
||||
to: `/my/drive/file/${props.video.id}`,
|
||||
|
|
@ -468,7 +491,7 @@ onDeactivated(() => {
|
|||
position: relative;
|
||||
overflow: clip;
|
||||
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -575,6 +598,10 @@ onDeactivated(() => {
|
|||
border-radius: 99rem;
|
||||
|
||||
font-size: 1.1rem;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.videoLoading {
|
||||
|
|
@ -638,6 +665,10 @@ onDeactivated(() => {
|
|||
&:hover {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :style="{ background: bgCss }" :behavior="navigationBehavior">
|
||||
<MkA v-user-preview="canonical" :class="[$style.root, { [$style.isMe]: isMe }]" :to="url" :behavior="navigationBehavior">
|
||||
<img :class="$style.icon" :src="avatarUrl" alt="">
|
||||
<span>
|
||||
<span>@{{ username }}</span>
|
||||
|
|
@ -16,8 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { toUnicode } from 'punycode';
|
||||
import { computed } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { host as localHost } from '@/config.js';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
|
||||
|
|
@ -37,11 +36,7 @@ const isMe = $i && (
|
|||
`@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase()
|
||||
);
|
||||
|
||||
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention'));
|
||||
bg.setAlpha(0.1);
|
||||
const bgCss = bg.toRgbString();
|
||||
|
||||
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
|
||||
const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages || defaultStore.state.dataSaver.avatar
|
||||
? getStaticImageUrl(`/avatar/@${props.username}@${props.host}`)
|
||||
: `/avatar/@${props.username}@${props.host}`,
|
||||
);
|
||||
|
|
@ -53,9 +48,11 @@ const avatarUrl = computed(() => defaultStore.state.disableShowingAnimatedImages
|
|||
padding: 4px 8px 4px 4px;
|
||||
border-radius: 999px;
|
||||
color: var(--mention);
|
||||
background: color(from var(--mention) srgb r g b / 0.1);
|
||||
|
||||
&.isMe {
|
||||
color: var(--mentionMe);
|
||||
background: color(from var(--mentionMe) srgb r g b / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,16 +10,15 @@ 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';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
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,47 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div role="menu">
|
||||
<div
|
||||
role="menu"
|
||||
:class="{
|
||||
[$style.root]: true,
|
||||
[$style.center]: align === 'center',
|
||||
[$style.big]: big,
|
||||
[$style.asDrawer]: asDrawer,
|
||||
}"
|
||||
@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.menu"
|
||||
: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 +52,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 +102,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 +165,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 +194,6 @@ const XChild = defineAsyncComponent(() => import('./MkMenu.child.vue'));
|
|||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
viaKeyboard?: boolean;
|
||||
asDrawer?: boolean;
|
||||
align?: 'center' | string;
|
||||
width?: number;
|
||||
|
|
@ -119,17 +205,30 @@ const emit = defineEmits<{
|
|||
(ev: 'hide'): void;
|
||||
}>();
|
||||
|
||||
const itemsEl = shallowRef<HTMLDivElement>();
|
||||
const big = isTouchUsing;
|
||||
|
||||
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 +266,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 +293,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 +303,9 @@ async function showRadioOptions(item: MenuRadio, ev: MouseEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
async function showChildren(item: MenuParent, ev: MouseEvent) {
|
||||
async function showChildren(item: MenuParent, ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const children: MenuItem[] = await (async () => {
|
||||
if (childrenCache.has(item)) {
|
||||
return childrenCache.get(item)!;
|
||||
|
|
@ -227,7 +322,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 +341,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,30 +353,132 @@ 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>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
&.center {
|
||||
> .menu {
|
||||
> .item {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.big:not(.asDrawer) {
|
||||
> .menu {
|
||||
> .item {
|
||||
padding: 6px 20px;
|
||||
font-size: 1em;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.asDrawer {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
|
||||
> .menu {
|
||||
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
> .item {
|
||||
font-size: 1em;
|
||||
padding: 12px 24px;
|
||||
|
||||
&::before {
|
||||
width: calc(100% - 24px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
margin-right: 14px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
margin: 12px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 8px 0;
|
||||
box-sizing: border-box;
|
||||
max-width: 100vw;
|
||||
|
|
@ -293,37 +486,8 @@ onBeforeUnmount(() => {
|
|||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
|
||||
&.center {
|
||||
> .item {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&.asDrawer {
|
||||
padding: 12px 0 max(env(safe-area-inset-bottom, 0px), 12px) 0;
|
||||
width: 100%;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
> .item {
|
||||
font-size: 1em;
|
||||
padding: 12px 24px;
|
||||
|
||||
&:before {
|
||||
width: calc(100% - 24px);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
margin-right: 14px;
|
||||
width: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
margin: 12px 0;
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,8 +504,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 +521,56 @@ onBeforeUnmount(() => {
|
|||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&: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 +588,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 +606,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 +617,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 +645,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);
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { watch, ref } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { useInterval } from '@/scripts/use-interval.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
|
||||
const props = defineProps<{
|
||||
src: number[];
|
||||
|
|
|
|||
|
|
@ -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,12 +100,13 @@ 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);
|
||||
const type = computed<ModalTypes>(() => {
|
||||
if (props.preferType === 'auto') {
|
||||
if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
|
||||
if ((defaultStore.state.menuStyle === 'drawer') || (defaultStore.state.menuStyle === 'auto' && isTouchUsing && deviceKind === 'smartphone')) {
|
||||
return 'drawer';
|
||||
} else {
|
||||
return props.src != null ? 'popup' : 'dialog';
|
||||
|
|
@ -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;
|
||||
|
|
@ -99,12 +94,12 @@ defineExpose({
|
|||
|
||||
--root-margin: 24px;
|
||||
|
||||
--headerHeight: 46px;
|
||||
--headerHeightNarrow: 42px;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
|
||||
--headerHeight: 46px;
|
||||
--headerHeightNarrow: 42px;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
>
|
||||
<MkNoteSub v-if="appearNote.reply && !renoteCollapsed" :note="appearNote.reply" :class="$style.replyTo"/>
|
||||
<div v-if="pinned" :class="$style.tip"><i class="ti ti-pin"></i> {{ i18n.ts.pinnedNote }}</div>
|
||||
|
|
@ -28,7 +28,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>
|
||||
|
|
@ -53,7 +53,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
|
||||
<div style="container-type: inline-size;">
|
||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
||||
<Mfm
|
||||
v-if="appearNote.cw != ''"
|
||||
:text="appearNote.cw"
|
||||
:author="appearNote.user"
|
||||
:nyaize="'respect'"
|
||||
:enableEmojiMenu="true"
|
||||
:enableEmojiMenuReaction="true"
|
||||
/>
|
||||
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll" style="margin: 4px 0;"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent" :class="[{ [$style.contentCollapsed]: collapsed }]">
|
||||
|
|
@ -79,7 +86,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<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" :noteId="appearNote.id" :poll="appearNote.poll" :class="$style.poll"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
|
|
@ -110,7 +117,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="renoteButton"
|
||||
:class="$style.footerButton"
|
||||
class="_button"
|
||||
@mousedown="renote()"
|
||||
@mousedown.prevent="renote()"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.footerButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -119,16 +126,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.footerButton" class="_button" @click="toggleReact()">
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--love);"></i>
|
||||
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
|
||||
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></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>
|
||||
|
|
@ -163,6 +170,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed, inject, onMounted, ref, shallowRef, Ref, watch, provide } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteHeader from '@/components/MkNoteHeader.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
|
|
@ -174,8 +182,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 { 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';
|
||||
|
|
@ -193,11 +200,15 @@ import { deepClone } from '@/scripts/clone.js';
|
|||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { getNoteSummary } from '@/scripts/get-note-summary.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
||||
import { shouldCollapsed } from '@/scripts/collapsed.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
import { focusPrev, focusNext } from '@/scripts/focus.js';
|
||||
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -240,14 +251,7 @@ if (noteViewInterruptors.length > 0) {
|
|||
});
|
||||
}
|
||||
|
||||
const isRenote = (
|
||||
note.value.renote != null &&
|
||||
note.value.reply == null &&
|
||||
note.value.text == null &&
|
||||
note.value.cw == null &&
|
||||
note.value.fileIds && note.value.fileIds.length === 0 &&
|
||||
note.value.poll == null
|
||||
);
|
||||
const isRenote = Misskey.note.isPureRenote(note.value);
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const menuButton = shallowRef<HTMLElement>();
|
||||
|
|
@ -255,7 +259,8 @@ const renoteButton = shallowRef<HTMLElement>();
|
|||
const renoteTime = shallowRef<HTMLElement>();
|
||||
const reactButton = shallowRef<HTMLElement>();
|
||||
const clipButton = shallowRef<HTMLElement>();
|
||||
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
|
||||
const appearNote = computed(() => getAppearNote(note.value));
|
||||
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(false);
|
||||
const parsed = computed(() => appearNote.value.text ? mfm.parse(appearNote.value.text) : null);
|
||||
|
|
@ -276,6 +281,11 @@ const renoteCollapsed = ref(
|
|||
),
|
||||
);
|
||||
|
||||
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';
|
||||
|
|
@ -294,15 +304,53 @@ function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string
|
|||
}
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
'e|a|plus': () => react(true),
|
||||
'q': () => renote(true),
|
||||
'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;
|
||||
renote();
|
||||
},
|
||||
'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', {
|
||||
|
|
@ -371,7 +419,7 @@ if (!props.mock) {
|
|||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
pleaseLogin();
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton, mock: props.mock });
|
||||
|
|
@ -380,22 +428,21 @@ function renote(viaKeyboard = false) {
|
|||
});
|
||||
}
|
||||
|
||||
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 react(viaKeyboard = false): void {
|
||||
pleaseLogin();
|
||||
function react(): void {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
||||
sound.playMisskeySfx('reaction');
|
||||
|
|
@ -467,16 +514,6 @@ function onContextmenu(ev: MouseEvent): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const isLink = (el: HTMLElement): boolean => {
|
||||
if (el.tagName === 'A') return true;
|
||||
// 再生速度の選択などのために、Audio要素のコンテキストメニューはブラウザデフォルトとする。
|
||||
if (el.tagName === 'AUDIO') return true;
|
||||
if (el.parentElement) {
|
||||
return isLink(el.parentElement);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
|
|
@ -489,18 +526,16 @@ 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 clip() {
|
||||
async function clip(): Promise<void> {
|
||||
if (props.mock) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -508,7 +543,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;
|
||||
}
|
||||
|
|
@ -528,23 +563,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -557,11 +588,11 @@ function blur() {
|
|||
}
|
||||
|
||||
function focusBefore() {
|
||||
focusPrev(rootEl.value ?? null);
|
||||
focusPrev(rootEl.value);
|
||||
}
|
||||
|
||||
function focusAfter() {
|
||||
focusNext(rootEl.value ?? null);
|
||||
focusNext(rootEl.value);
|
||||
}
|
||||
|
||||
function readPromo() {
|
||||
|
|
@ -594,12 +625,12 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
// 今度はその処理自体がパフォーマンス低下の原因にならないか懸念される。また、被リアクションでも高さは変化するため、やはり多少のズレは生じる
|
||||
// 一度レンダリングされた要素はブラウザがよしなにサイズを覚えておいてくれるような実装になるまで待った方が良さそう(なるのか?)
|
||||
//content-visibility: auto;
|
||||
//contain-intrinsic-size: 0 128px;
|
||||
//contain-intrinsic-size: 0 128px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
&:after {
|
||||
&::after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
|
|
@ -612,7 +643,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;
|
||||
}
|
||||
|
|
@ -826,7 +857,7 @@ function emitUpdReaction(emoji: string, delta: number) {
|
|||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
|
||||
|
||||
&:hover > .collapsedLabel {
|
||||
background: var(--panelHighlight);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -67,7 +68,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</header>
|
||||
<div :class="$style.noteContent">
|
||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||
<Mfm v-if="appearNote.cw != ''" style="margin-right: 8px;" :text="appearNote.cw" :author="appearNote.user" :nyaize="'respect'"/>
|
||||
<Mfm
|
||||
v-if="appearNote.cw != ''"
|
||||
:text="appearNote.cw"
|
||||
:author="appearNote.user"
|
||||
:nyaize="'respect'"
|
||||
:enableEmojiMenu="true"
|
||||
:enableEmojiMenuReaction="true"
|
||||
/>
|
||||
<MkCwButton v-model="showContent" :text="appearNote.text" :renote="appearNote.renote" :files="appearNote.files" :poll="appearNote.poll"/>
|
||||
</p>
|
||||
<div v-show="appearNote.cw == null || showContent">
|
||||
|
|
@ -92,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<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">
|
||||
|
|
@ -118,7 +126,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
ref="renoteButton"
|
||||
class="_button"
|
||||
:class="$style.noteFooterButton"
|
||||
@mousedown="renote()"
|
||||
@mousedown.prevent="renote()"
|
||||
>
|
||||
<i class="ti ti-repeat"></i>
|
||||
<p v-if="appearNote.renoteCount > 0" :class="$style.noteFooterButtonCount">{{ number(appearNote.renoteCount) }}</p>
|
||||
|
|
@ -127,16 +135,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i class="ti ti-ban"></i>
|
||||
</button>
|
||||
<button ref="reactButton" :class="$style.noteFooterButton" class="_button" @click="toggleReact()">
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--eventReactionHeart);"></i>
|
||||
<i v-if="appearNote.reactionAcceptance === 'likeOnly' && appearNote.myReaction != null" class="ti ti-heart-filled" style="color: var(--love);"></i>
|
||||
<i v-else-if="appearNote.myReaction != null" class="ti ti-minus" style="color: var(--accent);"></i>
|
||||
<i v-else-if="appearNote.reactionAcceptance === 'likeOnly'" class="ti ti-heart"></i>
|
||||
<i v-else class="ti ti-plus"></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>
|
||||
|
|
@ -198,6 +206,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { computed, inject, onMounted, provide, ref, shallowRef } from 'vue';
|
||||
import * as mfm from 'mfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
import MkNoteSub from '@/components/MkNoteSub.vue';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkReactionsViewer from '@/components/MkReactionsViewer.vue';
|
||||
|
|
@ -208,7 +217,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';
|
||||
|
|
@ -221,6 +230,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 '@@/js/config.js';
|
||||
import { getNoteClipMenu, getNoteMenu, getRenoteMenu } from '@/scripts/get-note-menu.js';
|
||||
import { useNoteCapture } from '@/scripts/use-note-capture.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
|
|
@ -233,6 +243,8 @@ import MkPagination, { type Paging } from '@/components/MkPagination.vue';
|
|||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { isEnabledUrlPreview } from '@/instance.js';
|
||||
import { getAppearNote } from '@/scripts/get-appear-note.js';
|
||||
import { type Keymap } from '@/scripts/hotkey.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
@ -264,14 +276,7 @@ if (noteViewInterruptors.length > 0) {
|
|||
});
|
||||
}
|
||||
|
||||
const isRenote = (
|
||||
note.value.renote != null &&
|
||||
note.value.reply == null &&
|
||||
note.value.text == null &&
|
||||
note.value.cw == null &&
|
||||
note.value.fileIds && note.value.fileIds.length === 0 &&
|
||||
note.value.poll == null
|
||||
);
|
||||
const isRenote = Misskey.note.isPureRenote(note.value);
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
const menuButton = shallowRef<HTMLElement>();
|
||||
|
|
@ -279,7 +284,8 @@ const renoteButton = shallowRef<HTMLElement>();
|
|||
const renoteTime = shallowRef<HTMLElement>();
|
||||
const reactButton = shallowRef<HTMLElement>();
|
||||
const clipButton = shallowRef<HTMLElement>();
|
||||
const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value);
|
||||
const appearNote = computed(() => getAppearNote(note.value));
|
||||
const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>();
|
||||
const isMyRenote = $i && ($i.id === note.value.userId);
|
||||
const showContent = ref(false);
|
||||
const isDeleted = ref(false);
|
||||
|
|
@ -293,14 +299,31 @@ const conversation = ref<Misskey.entities.Note[]>([]);
|
|||
const replies = ref<Misskey.entities.Note[]>([]);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.value.visibility) || appearNote.value.userId === $i?.id);
|
||||
|
||||
const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${appearNote.value.id}`,
|
||||
}));
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
'e|a|plus': () => react(true),
|
||||
'q': () => renote(true),
|
||||
'esc': blur,
|
||||
'm|o': () => showMenu(true),
|
||||
's': () => showContent.value !== showContent.value,
|
||||
};
|
||||
'r': () => reply(),
|
||||
'e|a|plus': () => react(),
|
||||
'q': () => renote(),
|
||||
'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', {
|
||||
|
|
@ -380,30 +403,27 @@ if (appearNote.value.reactionAcceptance === 'likeOnly') {
|
|||
});
|
||||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
pleaseLogin();
|
||||
function renote() {
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
showMovedDialog();
|
||||
|
||||
const { menu } = getRenoteMenu({ note: note.value, renoteButton });
|
||||
os.popupMenu(menu, renoteButton.value, {
|
||||
viaKeyboard,
|
||||
});
|
||||
os.popupMenu(menu, renoteButton.value);
|
||||
}
|
||||
|
||||
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');
|
||||
|
|
@ -412,7 +432,7 @@ function react(viaKeyboard = false): void {
|
|||
noteId: appearNote.value.id,
|
||||
reaction: '❤️',
|
||||
});
|
||||
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);
|
||||
|
|
@ -456,14 +476,6 @@ function toggleReact() {
|
|||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent): void {
|
||||
const isLink = (el: HTMLElement): boolean => {
|
||||
if (el.tagName === 'A') return true;
|
||||
if (el.parentElement) {
|
||||
return isLink(el.parentElement);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (ev.target && isLink(ev.target as HTMLElement)) return;
|
||||
if (window.getSelection()?.toString() !== '') return;
|
||||
|
||||
|
|
@ -476,20 +488,18 @@ 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 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',
|
||||
|
|
@ -500,9 +510,7 @@ function showRenoteMenu(viaKeyboard = false): void {
|
|||
});
|
||||
isDeleted.value = true;
|
||||
},
|
||||
}], renoteTime.value, {
|
||||
viaKeyboard: viaKeyboard,
|
||||
});
|
||||
}], renoteTime.value);
|
||||
}
|
||||
|
||||
function focus() {
|
||||
|
|
@ -544,6 +552,28 @@ function loadConversation() {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.replyTo {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<header :class="$style.root">
|
||||
<div v-if="mock" :class="$style.name">
|
||||
<MkUserName :user="note.user"/>
|
||||
</div>
|
||||
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
|
||||
<div :class="$style.username"><MkAcct :user="note.user"/></div>
|
||||
<component :is="defaultStore.state.enableCondensedLine ? 'MkCondensedLine' : 'div'" :minScale="0.7" style="min-width: 0;">
|
||||
<div style="display: flex; white-space: nowrap; align-items: baseline;">
|
||||
<div v-if="mock" :class="$style.name">
|
||||
<MkUserName :user="note.user"/>
|
||||
</div>
|
||||
<MkA v-else v-user-preview="note.user.id" :class="$style.name" :to="userPage(note.user)">
|
||||
<MkUserName :user="note.user"/>
|
||||
</MkA>
|
||||
<div v-if="note.user.isBot" :class="$style.isBot">bot</div>
|
||||
<div :class="$style.username"><MkAcct :user="note.user"/></div>
|
||||
</div>
|
||||
</component>
|
||||
<div v-if="note.user.badgeRoles" :class="$style.badgeRoles">
|
||||
<img v-for="(role, i) in note.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.badgeRole" :src="role.iconUrl!"/>
|
||||
</div>
|
||||
|
|
@ -40,6 +44,7 @@ import * as Misskey from 'misskey-js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { userPage } from '@/filters/user.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
|
|
|
|||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.root">
|
||||
<div :class="$style.head">
|
||||
<MkAvatar v-if="['pollEnded', 'note'].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/>
|
||||
<MkAvatar v-else-if="['roleAssigned', 'achievementEarned', 'exportCompleted', 'login'].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_reactionGroupHeart]"><i class="ti ti-heart" style="line-height: 1;"></i></div>
|
||||
<div v-else-if="notification.type === 'reaction:grouped'" :class="[$style.icon, $style.icon_reactionGroup]"><i class="ti ti-plus" 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="'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=""/>
|
||||
<img v-else-if="'icon' in notification && notification.icon != null" :class="[$style.icon, $style.icon_app]" :src="notification.icon" alt=""/>
|
||||
<div
|
||||
:class="[$style.subIcon, {
|
||||
[$style.t_follow]: notification.type === 'follow',
|
||||
|
|
@ -25,6 +25,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
[$style.t_quote]: notification.type === 'quote',
|
||||
[$style.t_pollEnded]: notification.type === 'pollEnded',
|
||||
[$style.t_achievementEarned]: notification.type === 'achievementEarned',
|
||||
[$style.t_exportCompleted]: notification.type === 'exportCompleted',
|
||||
[$style.t_login]: notification.type === 'login',
|
||||
[$style.t_roleAssigned]: notification.type === 'roleAssigned' && notification.role.iconUrl == null,
|
||||
}]"
|
||||
>
|
||||
|
|
@ -37,6 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<i v-else-if="notification.type === 'quote'" class="ti ti-quote"></i>
|
||||
<i v-else-if="notification.type === 'pollEnded'" class="ti ti-chart-arrows"></i>
|
||||
<i v-else-if="notification.type === 'achievementEarned'" class="ti ti-medal"></i>
|
||||
<i v-else-if="notification.type === 'exportCompleted'" class="ti ti-archive"></i>
|
||||
<i v-else-if="notification.type === 'login'" class="ti ti-login-2"></i>
|
||||
<template v-else-if="notification.type === 'roleAssigned'">
|
||||
<img v-if="notification.role.iconUrl" style="height: 1.3em; vertical-align: -22%;" :src="notification.role.iconUrl" alt=""/>
|
||||
<i v-else class="ti ti-badges"></i>
|
||||
|
|
@ -46,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:withTooltip="true"
|
||||
:reaction="notification.reaction.replace(/^:(\w+):$/, ':$1@.:')"
|
||||
:noStyle="true"
|
||||
style="width: 100%; height: 100%;"
|
||||
style="width: 100%; height: 100% !important; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -56,7 +60,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<span v-else-if="notification.type === 'note'">{{ i18n.ts._notification.newNote }}: <MkUserName :user="notification.note.user"/></span>
|
||||
<span v-else-if="notification.type === 'roleAssigned'">{{ i18n.ts._notification.roleAssigned }}</span>
|
||||
<span v-else-if="notification.type === 'achievementEarned'">{{ i18n.ts._notification.achievementEarned }}</span>
|
||||
<span v-else-if="notification.type === 'login'">{{ i18n.ts._notification.login }}</span>
|
||||
<span v-else-if="notification.type === 'test'">{{ i18n.ts._notification.testNotification }}</span>
|
||||
<span v-else-if="notification.type === 'exportCompleted'">{{ i18n.tsx._notification.exportOfXCompleted({ x: exportEntityName[notification.exportedEntity] }) }}</span>
|
||||
<MkA v-else-if="notification.type === 'follow' || notification.type === 'mention' || notification.type === 'reply' || notification.type === 'renote' || notification.type === 'quote' || notification.type === 'reaction' || notification.type === 'receiveFollowRequest' || notification.type === 'followRequestAccepted'" v-user-preview="notification.user.id" :class="$style.headerName" :to="userPage(notification.user)"><MkUserName :user="notification.user"/></MkA>
|
||||
<span v-else-if="notification.type === 'reaction:grouped' && notification.note.reactionAcceptance === 'likeOnly'">{{ i18n.tsx._notification.likedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
|
||||
<span v-else-if="notification.type === 'reaction:grouped'">{{ i18n.tsx._notification.reactedBySomeUsers({ n: getActualReactedUsersCount(notification) }) }}</span>
|
||||
|
|
@ -98,10 +104,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkA v-else-if="notification.type === 'achievementEarned'" :class="$style.text" to="/my/achievements">
|
||||
{{ i18n.ts._achievements._types['_' + notification.achievement].title }}
|
||||
</MkA>
|
||||
<MkA v-else-if="notification.type === 'exportCompleted'" :class="$style.text" :to="`/my/drive/file/${notification.fileId}`">
|
||||
{{ i18n.ts.showFile }}
|
||||
</MkA>
|
||||
<template v-else-if="notification.type === 'follow'">
|
||||
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.youGotNewFollower }}</span>
|
||||
</template>
|
||||
<span v-else-if="notification.type === 'followRequestAccepted'" :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</span>
|
||||
<template v-else-if="notification.type === 'followRequestAccepted'">
|
||||
<div :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.followRequestAccepted }}</div>
|
||||
<div v-if="notification.message" :class="$style.text" style="opacity: 0.6; font-style: oblique;">
|
||||
<i class="ti ti-quote" :class="$style.quote"></i>
|
||||
<span>{{ notification.message }}</span>
|
||||
<i class="ti ti-quote" :class="$style.quote"></i>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="notification.type === 'receiveFollowRequest'">
|
||||
<span :class="$style.text" style="opacity: 0.6;">{{ i18n.ts.receiveFollowRequest }}</span>
|
||||
<div v-if="full && !followRequestDone" :class="$style.followRequestCommands">
|
||||
|
|
@ -122,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
:withTooltip="true"
|
||||
:reaction="reaction.reaction.replace(/^:(\w+):$/, ':$1@.:')"
|
||||
:noStyle="true"
|
||||
style="width: 100%; height: 100%;"
|
||||
style="width: 100%; height: 100% !important; object-fit: contain;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -161,6 +177,20 @@ const props = withDefaults(defineProps<{
|
|||
full: false,
|
||||
});
|
||||
|
||||
type ExportCompletedNotification = Misskey.entities.Notification & { type: 'exportCompleted' };
|
||||
|
||||
const exportEntityName = {
|
||||
antenna: i18n.ts.antennas,
|
||||
blocking: i18n.ts.blockedUsers,
|
||||
clip: i18n.ts.clips,
|
||||
customEmoji: i18n.ts.customEmojis,
|
||||
favorite: i18n.ts.favorites,
|
||||
following: i18n.ts.following,
|
||||
muting: i18n.ts.mutedUsers,
|
||||
note: i18n.ts.notes,
|
||||
userList: i18n.ts.lists,
|
||||
} as const satisfies Record<ExportCompletedNotification['exportedEntity'], string>;
|
||||
|
||||
const followRequestDone = ref(false);
|
||||
|
||||
const acceptFollowRequest = () => {
|
||||
|
|
@ -190,6 +220,15 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
overflow-wrap: break-word;
|
||||
display: flex;
|
||||
contain: content;
|
||||
|
||||
--eventFollow: #36aed2;
|
||||
--eventRenote: #36d298;
|
||||
--eventReply: #007aff;
|
||||
--eventReactionHeart: var(--love);
|
||||
--eventReaction: #e99a0b;
|
||||
--eventAchievement: #cb9a11;
|
||||
--eventLogin: #007aff;
|
||||
--eventOther: #88a6b7;
|
||||
}
|
||||
|
||||
.head {
|
||||
|
|
@ -298,12 +337,24 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_exportCompleted {
|
||||
padding: 3px;
|
||||
background: var(--eventOther);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_roleAssigned {
|
||||
padding: 3px;
|
||||
background: var(--eventOther);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.t_login {
|
||||
padding: 3px;
|
||||
background: var(--eventLogin);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
|
@ -343,7 +394,7 @@ function getActualReactedUsersCount(notification: Misskey.entities.Notification)
|
|||
margin-right: 4px;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
position: absolute;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import MkSwitch from './MkSwitch.vue';
|
|||
import MkInfo from './MkInfo.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { notificationTypes } from '@/const.js';
|
||||
import { notificationTypes } from '@@/js/const.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import MkDateSeparatedList from '@/components/MkDateSeparatedList.vue';
|
|||
import MkNote from '@/components/MkNote.vue';
|
||||
import { useStream } from '@/stream.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { notificationTypes } from '@/const.js';
|
||||
import { notificationTypes } from '@@/js/const.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ onUnmounted(() => {
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
background: linear-gradient(0deg, var(--panel), color(from var(--panel) srgb r g b / 0));
|
||||
|
||||
> .fadeLabel {
|
||||
display: inline-block;
|
||||
|
|
|
|||
|
|
@ -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,14 +33,14 @@ 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 { url } from '@/config.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { url } from '@@/js/config.js';
|
||||
import { useScrollPositionManager } from '@/nirax.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { PageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||
import { openingWindowsCount } from '@/os.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { getScrollContainer } from '@/scripts/scroll.js';
|
||||
import { getScrollContainer } from '@@/js/scroll.js';
|
||||
import { useRouterFactory } from '@/router/supplier.js';
|
||||
import { mainRouter } from '@/router/main.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts">
|
||||
import { computed, ComputedRef, isRef, nextTick, onActivated, onBeforeMount, onBeforeUnmount, onDeactivated, ref, shallowRef, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { useDocumentVisibility } from '@@/js/use-document-visibility.js';
|
||||
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@@/js/scroll.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { onScrollTop, isTopVisible, getBodyScrollHeight, getScrollContainer, onScrollBottom, scrollToBottom, scroll, isBottomVisible } from '@/scripts/scroll.js';
|
||||
import { useDocumentVisibility } from '@/scripts/use-document-visibility.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { MisskeyEntity } from '@/types/date-separated-list.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -125,8 +125,6 @@ const items = ref<MisskeyEntityMap>(new Map());
|
|||
*/
|
||||
const queue = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
/**
|
||||
* 初期化中かどうか(trueならMkLoadingで全て隠す)
|
||||
*/
|
||||
|
|
@ -179,7 +177,9 @@ watch([backed, contentEl], () => {
|
|||
if (!backed.value) {
|
||||
if (!contentEl.value) return;
|
||||
|
||||
scrollRemove.value = (props.pagination.reversed ? onScrollBottom : onScrollTop)(contentEl.value, executeQueue, TOLERANCE);
|
||||
scrollRemove.value = props.pagination.reversed
|
||||
? onScrollBottom(contentEl.value, executeQueue, TOLERANCE)
|
||||
: onScrollTop(contentEl.value, (topVisible) => { if (topVisible) executeQueue(); }, TOLERANCE);
|
||||
} else {
|
||||
if (scrollRemove.value) scrollRemove.value();
|
||||
scrollRemove.value = null;
|
||||
|
|
@ -223,7 +223,6 @@ async function init(): Promise<void> {
|
|||
more.value = true;
|
||||
}
|
||||
|
||||
offset.value = res.length;
|
||||
error.value = false;
|
||||
fetching.value = false;
|
||||
}, err => {
|
||||
|
|
@ -244,7 +243,7 @@ const fetchMore = async (): Promise<void> => {
|
|||
...params,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
offset: items.value.size,
|
||||
} : {
|
||||
untilId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
|
|
@ -294,7 +293,6 @@ const fetchMore = async (): Promise<void> => {
|
|||
moreFetching.value = false;
|
||||
}
|
||||
}
|
||||
offset.value += res.length;
|
||||
}, err => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
|
|
@ -308,7 +306,7 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
...params,
|
||||
limit: SECOND_FETCH_LIMIT,
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
offset: items.value.size,
|
||||
} : {
|
||||
sinceId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
|
|
@ -320,7 +318,6 @@ const fetchMoreAhead = async (): Promise<void> => {
|
|||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
moreFetching.value = false;
|
||||
}, err => {
|
||||
moreFetching.value = false;
|
||||
|
|
|
|||
|
|
@ -29,12 +29,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { OpenOnRemoteOptions } from '@/scripts/please-login.js';
|
||||
import { sum } from '@/scripts/array.js';
|
||||
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 { useInterval } from '@/scripts/use-interval.js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
|
|
@ -60,6 +62,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 = () => {
|
||||
|
|
@ -76,10 +83,10 @@ if (props.poll.expiresAt) {
|
|||
}
|
||||
|
||||
const vote = async (id) => {
|
||||
pleaseLogin();
|
||||
|
||||
if (props.readOnly || closed.value || isVoted.value) return;
|
||||
|
||||
pleaseLogin(undefined, pleaseLoginContext.value);
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'question',
|
||||
text: i18n.tsx.voteConfirm({ choice: props.poll.choices[id].text }),
|
||||
|
|
@ -138,7 +145,7 @@ const vote = async (id) => {
|
|||
|
||||
.done {
|
||||
.choice {
|
||||
cursor: default;
|
||||
cursor: initial;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -13,14 +13,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref, shallowRef } from 'vue';
|
||||
import MkModal from './MkModal.vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
|
||||
defineProps<{
|
||||
items: MenuItem[];
|
||||
align?: 'center' | string;
|
||||
width?: number;
|
||||
viaKeyboard?: boolean;
|
||||
src?: any;
|
||||
returnFocusTo?: HTMLElement | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
|
|||
|
|
@ -105,11 +105,11 @@ import * as mfm from 'mfm-js';
|
|||
import * as Misskey from 'misskey-js';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { toASCII } from 'punycode/';
|
||||
import { host, url } from '@@/js/config.js';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||
import MkPollEditor, { type PollEditorModelValue } from '@/components/MkPollEditor.vue';
|
||||
import { host, url } from '@/config.js';
|
||||
import { erase, unique } from '@/scripts/array.js';
|
||||
import { extractMentions } from '@/scripts/extract-mentions.js';
|
||||
import { formatTimeString } from '@/scripts/format-time-string.js';
|
||||
|
|
@ -245,7 +245,7 @@ const submitText = computed((): string => {
|
|||
});
|
||||
|
||||
const textLength = computed((): number => {
|
||||
return (text.value + imeText.value).trim().length;
|
||||
return (text.value + imeText.value).length;
|
||||
});
|
||||
|
||||
const maxTextLength = computed((): number => {
|
||||
|
|
@ -259,7 +259,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);
|
||||
|
|
@ -367,6 +367,8 @@ function watchForDraft() {
|
|||
watch(files, () => saveDraft(), { deep: true });
|
||||
watch(visibility, () => saveDraft());
|
||||
watch(localOnly, () => saveDraft());
|
||||
watch(quoteId, () => saveDraft());
|
||||
watch(reactionAcceptance, () => saveDraft());
|
||||
}
|
||||
|
||||
function checkMissingMention() {
|
||||
|
|
@ -570,6 +572,7 @@ function clear() {
|
|||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey) && canPost.value) post();
|
||||
|
||||
if (ev.key === 'Escape') emit('esc');
|
||||
}
|
||||
|
||||
|
|
@ -702,6 +705,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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -901,10 +906,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;
|
||||
|
|
@ -990,6 +1008,8 @@ onMounted(() => {
|
|||
users.forEach(u => pushVisibleUser(u));
|
||||
});
|
||||
}
|
||||
quoteId.value = draft.data.quoteId;
|
||||
reactionAcceptance.value = draft.data.reactionAcceptance;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -997,9 +1017,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),
|
||||
|
|
@ -1008,9 +1030,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());
|
||||
|
|
@ -1083,6 +1109,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;
|
||||
}
|
||||
|
|
@ -1093,13 +1128,13 @@ defineExpose({
|
|||
|
||||
&:not(:disabled):hover {
|
||||
> .inner {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
> .inner {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
background: linear-gradient(90deg, hsl(from var(--accent) h s calc(l + 5)), hsl(from var(--accent) h s calc(l + 5)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1166,6 +1201,15 @@ defineExpose({
|
|||
min-height: 75px;
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
background-size: auto auto;
|
||||
}
|
||||
|
||||
html[data-color-scheme=dark] .preview {
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, #0004 5px, #0004 10px);
|
||||
}
|
||||
|
||||
html[data-color-scheme=light] .preview {
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, #00000005 5px, #00000005 10px);
|
||||
}
|
||||
|
||||
.targetNote {
|
||||
|
|
|
|||
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