mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-06-12 07:03:59 +02:00
Merge branch 'develop' into mahjong
This commit is contained in:
1
packages/frontend/.gitignore
vendored
1
packages/frontend/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
/storybook-static
|
||||
/build/
|
||||
|
||||
@@ -55,7 +55,7 @@ await fs.readFile(
|
||||
'../../locales/ja-JP.yml',
|
||||
'assets/**',
|
||||
'public/**',
|
||||
'../../pnpm-lock.yaml',
|
||||
'package.json',
|
||||
]).length
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import type { DefaultBodyType, HttpResponseResolver, JsonBodyType, PathParams } from 'msw';
|
||||
import seedrandom from 'seedrandom';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
function getChartArray(seed: string, limit: number, option?: { accumulate?: boolean, mul?: number }): number[] {
|
||||
const rng = seedrandom(seed);
|
||||
@@ -33,7 +33,7 @@ export function getChartResolver(fields: string[], option?: { accumulate?: boole
|
||||
const res = {};
|
||||
for (const field of fields) {
|
||||
const layers = field.split('.');
|
||||
let current = res;
|
||||
let current = res as any;
|
||||
while (layers.length > 1) {
|
||||
const currentKey = layers.shift()!;
|
||||
if (current[currentKey] == null) current[currentKey] = {};
|
||||
|
||||
@@ -33,6 +33,7 @@ export function channel(id = 'somechannelid', name = 'Some Channel', bannerUrl:
|
||||
description: null,
|
||||
userId: null,
|
||||
bannerUrl,
|
||||
bannerId: null,
|
||||
pinnedNoteIds: [],
|
||||
color: '#000',
|
||||
isArchived: false,
|
||||
@@ -127,7 +128,7 @@ export function galleryPost(isSensitive = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export function file(isSensitive = false) {
|
||||
export function file(isSensitive = false): entities.DriveFile {
|
||||
return {
|
||||
id: 'somefileid',
|
||||
createdAt: '2016-12-28T22:49:51.000Z',
|
||||
@@ -207,6 +208,7 @@ export function federationInstance(): entities.FederationInstance {
|
||||
isSuspended: false,
|
||||
suspensionState: 'none',
|
||||
isBlocked: false,
|
||||
isMediaSilenced: false,
|
||||
softwareName: 'misskey',
|
||||
softwareVersion: '2024.5.0',
|
||||
openRegistrations: false,
|
||||
@@ -311,6 +313,8 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host: enti
|
||||
alsoKnownAs: null,
|
||||
notify: 'none',
|
||||
memo: null,
|
||||
canChat: true,
|
||||
chatScope: 'everyone',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,6 +382,7 @@ export function role(params: {
|
||||
asBadge: params.asBadge ?? true,
|
||||
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
|
||||
usersCount: params.usersCount ?? 10,
|
||||
preserveAssignmentOnMoveAccount: false,
|
||||
condFormula: {
|
||||
id: '',
|
||||
type: 'or',
|
||||
|
||||
@@ -3,12 +3,11 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { existsSync, readFileSync, globSync } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { basename, dirname } from 'node:path/posix';
|
||||
import { GENERATOR, type State, generate } from 'astring';
|
||||
import type * as estree from 'estree';
|
||||
import glob from 'fast-glob';
|
||||
import { format } from 'prettier';
|
||||
|
||||
interface SatisfiesExpression extends estree.BaseExpression {
|
||||
@@ -439,38 +438,37 @@ function toStories(component: string): Promise<string> {
|
||||
|
||||
// glob('src/{components,pages,ui,widgets}/**/*.vue')
|
||||
(async () => {
|
||||
const globs = await Promise.all([
|
||||
glob('src/components/global/Mk*.vue'),
|
||||
glob('src/components/global/RouterView.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/MkImgPreviewDialog.vue'),
|
||||
glob('src/components/MkInstanceCardMini.vue'),
|
||||
glob('src/components/MkInviteCode.vue'),
|
||||
glob('src/components/MkTagItem.vue'),
|
||||
glob('src/components/MkRoleSelectDialog.vue'),
|
||||
glob('src/components/grid/MkGrid.vue'),
|
||||
glob('src/pages/admin/custom-emojis-manager2.vue'),
|
||||
glob('src/pages/admin/overview.ap-requests.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
glob('src/pages/search.vue'),
|
||||
]);
|
||||
const components = globs.flat();
|
||||
const components = [
|
||||
globSync('src/components/global/Mk*.vue'),
|
||||
globSync('src/components/global/RouterView.vue'),
|
||||
globSync('src/components/MkAbuseReportWindow.vue'),
|
||||
globSync('src/components/MkAccountMoved.vue'),
|
||||
globSync('src/components/MkAchievements.vue'),
|
||||
globSync('src/components/MkAnalogClock.vue'),
|
||||
globSync('src/components/MkAnimBg.vue'),
|
||||
globSync('src/components/MkAnnouncementDialog.vue'),
|
||||
globSync('src/components/MkAntennaEditor.vue'),
|
||||
globSync('src/components/MkAntennaEditorDialog.vue'),
|
||||
globSync('src/components/MkAsUi.vue'),
|
||||
globSync('src/components/MkAutocomplete.vue'),
|
||||
globSync('src/components/MkAvatars.vue'),
|
||||
globSync('src/components/Mk[B-E]*.vue'),
|
||||
globSync('src/components/MkFlashPreview.vue'),
|
||||
globSync('src/components/MkGalleryPostPreview.vue'),
|
||||
globSync('src/components/MkSignupServerRules.vue'),
|
||||
globSync('src/components/MkUserSetupDialog.vue'),
|
||||
globSync('src/components/MkUserSetupDialog.*.vue'),
|
||||
globSync('src/components/MkImgPreviewDialog.vue'),
|
||||
globSync('src/components/MkInstanceCardMini.vue'),
|
||||
globSync('src/components/MkInviteCode.vue'),
|
||||
globSync('src/components/MkTagItem.vue'),
|
||||
globSync('src/components/MkRoleSelectDialog.vue'),
|
||||
globSync('src/components/grid/MkGrid.vue'),
|
||||
globSync('src/pages/admin/custom-emojis-manager2.vue'),
|
||||
globSync('src/pages/admin/overview.ap-requests.vue'),
|
||||
globSync('src/pages/user/home.vue'),
|
||||
globSync('src/pages/search.vue'),
|
||||
].flat();
|
||||
await Promise.all(components.map(async (component) => {
|
||||
const stories = component.replace(/\.vue$/, '.stories.ts');
|
||||
await writeFile(stories, await toStories(component));
|
||||
|
||||
@@ -34,7 +34,7 @@ export const commonHandlers = [
|
||||
}),
|
||||
http.get('/twemoji/:codepoints.svg', async ({ params }) => {
|
||||
const { codepoints } = params;
|
||||
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@15.0.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
|
||||
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@16.0.1/dist/svg/${codepoints}.svg`).then((response) => response.blob());
|
||||
return new HttpResponse(value, {
|
||||
headers: {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import locales from '../../../locales/index.js';
|
||||
import locales from 'i18n';
|
||||
|
||||
await writeFile(
|
||||
new URL('locale.ts', import.meta.url),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { type Preview, setup } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initialize, mswLoader } from 'msw-storybook-addon';
|
||||
import { userDetailed } from './fakes.js';
|
||||
import locale from './locale.js';
|
||||
import { commonHandlers, onUnhandledRequest } from './mocks.js';
|
||||
import themes from './themes.js';
|
||||
import '../src/style.scss';
|
||||
@@ -55,7 +54,6 @@ function initLocalStorage() {
|
||||
...userDetailed(),
|
||||
policies: {},
|
||||
}));
|
||||
localStorage.setItem('locale', JSON.stringify(locale));
|
||||
}
|
||||
|
||||
initialize({
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"prefix": "storyimplevent",
|
||||
"body": [
|
||||
"/* eslint-disable @typescript-eslint/explicit-function-return-type */",
|
||||
"import { action } from '@storybook/addon-actions';",
|
||||
"import { action } from 'storybook/actions';",
|
||||
"import { StoryObj } from '@storybook/vue3';",
|
||||
"import $1 from './$1.vue';",
|
||||
"export const Default = {",
|
||||
|
||||
3
packages/frontend/@types/global.d.ts
vendored
3
packages/frontend/@types/global.d.ts
vendored
@@ -10,9 +10,6 @@ declare const _VERSION_: string;
|
||||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
|
||||
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
|
||||
|
||||
// for dev-mode
|
||||
declare const _LANGS_FULL_: string[][];
|
||||
|
||||
BIN
packages/frontend/assets/artist_palette_3d.png
Normal file
BIN
packages/frontend/assets/artist_palette_3d.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
packages/frontend/assets/sample/2-3.jpg
Normal file
BIN
packages/frontend/assets/sample/2-3.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
BIN
packages/frontend/assets/sample/3-2.jpg
Normal file
BIN
packages/frontend/assets/sample/3-2.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 410 KiB |
BIN
packages/frontend/assets/unknown.png
Normal file
BIN
packages/frontend/assets/unknown.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
51
packages/frontend/build.ts
Normal file
51
packages/frontend/build.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import url from 'node:url';
|
||||
import path from 'node:path';
|
||||
import { execa } from 'execa';
|
||||
import locales from 'i18n';
|
||||
import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
|
||||
import { createLogger } from '../frontend-builder/logger';
|
||||
|
||||
// requires node 21 or later
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const outputDir = __dirname + '/../../built/_frontend_vite_';
|
||||
|
||||
/**
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async function viteBuild() {
|
||||
await execa('vite', ['build'], {
|
||||
cwd: __dirname,
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function buildAllLocale() {
|
||||
const logger = createLogger()
|
||||
const inliner = await LocaleInliner.create({
|
||||
outputDir,
|
||||
logger,
|
||||
scriptsDir: 'scripts',
|
||||
i18nFile: 'src/i18n.ts',
|
||||
})
|
||||
|
||||
await inliner.loadFiles();
|
||||
|
||||
inliner.collectsModifications();
|
||||
|
||||
await inliner.saveAllLocales(locales);
|
||||
|
||||
if (logger.errorCount > 0) {
|
||||
throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function build() {
|
||||
await fs.rm(outputDir, { recursive: true, force: true });
|
||||
await viteBuild();
|
||||
await buildAllLocale();
|
||||
}
|
||||
|
||||
await build();
|
||||
@@ -14,6 +14,7 @@ export default [
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['src/**/*.{ts,vue}'],
|
||||
ignores: ['**/*.stories.ts'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
|
||||
@@ -30,9 +31,6 @@ export default [
|
||||
_VERSION_: false,
|
||||
_ENV_: false,
|
||||
_PERF_PREFIX_: false,
|
||||
_DATA_TRANSFER_DRIVE_FILE_: false,
|
||||
_DATA_TRANSFER_DRIVE_FOLDER_: false,
|
||||
_DATA_TRANSFER_DECK_COLUMN_: false,
|
||||
},
|
||||
parser,
|
||||
parserOptions: {
|
||||
@@ -149,12 +147,23 @@ export default [
|
||||
'vue/return-in-computed-property': 'warn',
|
||||
'vue/no-setup-props-reactivity-loss': 'warn',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/html-self-closing': ['error', {
|
||||
html: {
|
||||
void: 'any',
|
||||
normal: 'never',
|
||||
component: 'any',
|
||||
},
|
||||
svg: 'any',
|
||||
math: 'any',
|
||||
}],
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/v-on-event-hyphenation': ['error', 'never', {
|
||||
autofix: true,
|
||||
}],
|
||||
'vue/attribute-hyphenation': ['error', 'never'],
|
||||
'vue/no-mutating-props': ['error', {
|
||||
shallowOnly: true,
|
||||
}],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { parse as vueSfcParse } from 'vue/compiler-sfc';
|
||||
import {
|
||||
createLogger,
|
||||
EnvironmentModuleGraph,
|
||||
type EnvironmentModuleGraph,
|
||||
type LogErrorOptions,
|
||||
type LogOptions,
|
||||
normalizePath,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type PluginOption
|
||||
} from 'vite';
|
||||
import fs from 'node:fs';
|
||||
import { glob } from 'glob';
|
||||
import JSON5 from 'json5';
|
||||
import MagicString, { SourceMap } from 'magic-string';
|
||||
import path from 'node:path'
|
||||
@@ -39,6 +38,7 @@ export interface SearchIndexItem {
|
||||
path?: string;
|
||||
label: string;
|
||||
keywords: string[];
|
||||
texts: string[];
|
||||
icon?: string;
|
||||
inlining?: string[];
|
||||
}
|
||||
@@ -227,14 +227,14 @@ function extractElementText2Inner(node: TemplateChildNode, processingNodeName: s
|
||||
// region extractUsageInfoFromTemplateAst
|
||||
|
||||
/**
|
||||
* SearchLabel/SearchKeyword/SearchIconを探して抽出する関数
|
||||
* SearchLabel/SearchText/SearchIconを探して抽出する関数
|
||||
*/
|
||||
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } {
|
||||
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null; texts: string[]; icon: string | null; } {
|
||||
let label: string | null | undefined = undefined;
|
||||
let icon: string | null | undefined = undefined;
|
||||
const keywords: string[] = [];
|
||||
const texts: string[] = [];
|
||||
|
||||
logger.info(`Extracting labels and keywords from ${nodes.length} nodes`);
|
||||
logger.info(`Extracting labels and texts from ${nodes.length} nodes`);
|
||||
|
||||
walkVueElements(nodes, null, (node) => {
|
||||
switch (node.tag) {
|
||||
@@ -248,10 +248,10 @@ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: stri
|
||||
|
||||
label = extractElementText(node, id);
|
||||
return;
|
||||
case 'SearchKeyword':
|
||||
case 'SearchText':
|
||||
const content = extractElementText(node, id);
|
||||
if (content) {
|
||||
keywords.push(content);
|
||||
texts.push(content);
|
||||
}
|
||||
return;
|
||||
case 'SearchIcon':
|
||||
@@ -278,8 +278,8 @@ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: stri
|
||||
});
|
||||
|
||||
// デバッグ情報
|
||||
logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`);
|
||||
return { label: label ?? null, keywords, icon: icon ?? null };
|
||||
logger.info(`Extraction completed: label=${label}, text=[${texts.join(', ')}, icon=${icon}]`);
|
||||
return { label: label ?? null, texts, icon: icon ?? null };
|
||||
}
|
||||
|
||||
function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null {
|
||||
@@ -351,33 +351,36 @@ function extractUsageInfoFromTemplateAst(
|
||||
parentId: parentId ?? undefined,
|
||||
label: '', // デフォルト値
|
||||
keywords: [],
|
||||
texts: [],
|
||||
};
|
||||
|
||||
// バインドプロパティを取得
|
||||
const path = getStringProp(findAttribute(node.props, 'path'), id)
|
||||
const icon = getStringProp(findAttribute(node.props, 'icon'), id)
|
||||
const label = getStringProp(findAttribute(node.props, 'label'), id)
|
||||
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id)
|
||||
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id)
|
||||
const path = getStringProp(findAttribute(node.props, 'path'), id);
|
||||
const icon = getStringProp(findAttribute(node.props, 'icon'), id);
|
||||
const label = getStringProp(findAttribute(node.props, 'label'), id);
|
||||
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id);
|
||||
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id);
|
||||
const texts = getStringArrayProp(findAttribute(node.props, 'texts'), id);
|
||||
|
||||
if (path) markerInfo.path = path;
|
||||
if (icon) markerInfo.icon = icon;
|
||||
if (label) markerInfo.label = label;
|
||||
if (inlining) markerInfo.inlining = inlining;
|
||||
if (keywords) markerInfo.keywords = keywords;
|
||||
if (texts) markerInfo.texts = texts;
|
||||
|
||||
//pathがない場合はファイルパスを設定
|
||||
// pathがない場合はファイルパスを設定
|
||||
if (markerInfo.path == null && parentId == null) {
|
||||
markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1];
|
||||
}
|
||||
|
||||
// SearchLabelとSearchKeywordを抽出 (AST全体を探索)
|
||||
// SearchLabelとSearchTextを抽出 (AST全体を探索)
|
||||
{
|
||||
const extracted = extractSugarTags(node.children, id);
|
||||
if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`);
|
||||
if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`);
|
||||
markerInfo.label = extracted.label ?? markerInfo.label ?? '';
|
||||
markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords];
|
||||
markerInfo.texts = [...extracted.texts, ...markerInfo.texts];
|
||||
markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined;
|
||||
}
|
||||
|
||||
@@ -720,7 +723,7 @@ export function pluginCreateSearchIndexVirtualModule(options: Options, asigner:
|
||||
|
||||
async load(id) {
|
||||
if (id == '\0' + allSearchIndexFile) {
|
||||
const files = await Promise.all(options.targetFilePaths.map(async (filePathPattern) => await glob(filePathPattern))).then(paths => paths.flat());
|
||||
const files = options.targetFilePaths.map((filePathPattern) => fs.globSync(filePathPattern)).flat();
|
||||
let generatedFile = '';
|
||||
let arrayElements = '';
|
||||
for (let file of files) {
|
||||
|
||||
36
packages/frontend/lib/vite-plugin-watch-locales.ts
Normal file
36
packages/frontend/lib/vite-plugin-watch-locales.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import locales from 'i18n';
|
||||
|
||||
const localesDir = path.resolve(__dirname, '../../../locales')
|
||||
|
||||
/**
|
||||
* 外部ファイルを監視し、必要に応じてwebSocketでメッセージを送るViteプラグイン
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export default function pluginWatchLocales() {
|
||||
return {
|
||||
name: 'watch-locales',
|
||||
|
||||
configureServer(server) {
|
||||
const localeYmlPaths = Object.keys(locales).map(locale => path.join(localesDir, `${locale}.yml`));
|
||||
|
||||
// watcherにパスを追加
|
||||
server.watcher.add(localeYmlPaths);
|
||||
|
||||
server.watcher.on('change', (filePath) => {
|
||||
if (localeYmlPaths.includes(filePath)) {
|
||||
server.ws.send({
|
||||
type: 'custom',
|
||||
event: 'locale-update',
|
||||
data: filePath.match(/([^\/]+)\.yml$/)?.[1] || null,
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"watch": "vite",
|
||||
"build": "vite build",
|
||||
"build": "tsx build.ts",
|
||||
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
|
||||
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
|
||||
"build-storybook-pre": "(tsgo -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
|
||||
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
|
||||
"chromatic": "chromatic",
|
||||
"test": "vitest --run --globals",
|
||||
@@ -17,131 +17,133 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@analytics/google-analytics": "1.1.0",
|
||||
"@discordapp/twemoji": "15.1.0",
|
||||
"@discordapp/twemoji": "16.0.1",
|
||||
"@github/webauthn-json": "2.1.1",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
|
||||
"@mcaptcha/vanilla-glue": "0.1.0-rc2",
|
||||
"@misskey-dev/browser-image-resizer": "2024.1.0",
|
||||
"@rollup/plugin-json": "6.1.0",
|
||||
"@rollup/plugin-replace": "6.0.2",
|
||||
"@rollup/pluginutils": "5.1.4",
|
||||
"@sentry/vue": "9.12.0",
|
||||
"@syuilo/aiscript": "0.19.0",
|
||||
"@tabler/icons-webfont": "3.31.0",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.2.3",
|
||||
"@vue/compiler-sfc": "3.5.13",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||
"analytics": "0.8.16",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.1.0",
|
||||
"@rollup/plugin-replace": "6.0.3",
|
||||
"@rollup/pluginutils": "5.3.0",
|
||||
"@sentry/vue": "10.32.1",
|
||||
"@syuilo/aiscript": "1.2.1",
|
||||
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
|
||||
"@twemoji/parser": "16.0.0",
|
||||
"@vitejs/plugin-vue": "6.0.3",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.16",
|
||||
"analytics": "0.8.19",
|
||||
"broadcast-channel": "7.2.0",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.9.3",
|
||||
"chart.js": "4.4.8",
|
||||
"canvas-confetti": "1.9.4",
|
||||
"chart.js": "4.5.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.1.1",
|
||||
"chartjs-chart-matrix": "3.0.0",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.2.0",
|
||||
"chromatic": "11.28.0",
|
||||
"chromatic": "13.3.4",
|
||||
"compare-versions": "6.1.1",
|
||||
"cropperjs": "2.0.0",
|
||||
"cropperjs": "2.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"estree-walker": "3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"execa": "9.6.1",
|
||||
"exifreader": "4.33.1",
|
||||
"frontend-shared": "workspace:*",
|
||||
"idb-keyval": "6.2.1",
|
||||
"i18n": "workspace:*",
|
||||
"icons-subsetter": "workspace:*",
|
||||
"idb-keyval": "6.2.2",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"ios-haptics": "0.1.4",
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.3",
|
||||
"magic-string": "0.30.17",
|
||||
"matter-js": "0.20.0",
|
||||
"mfm-js": "0.24.0",
|
||||
"mediabunny": "1.27.2",
|
||||
"mfm-js": "0.25.0",
|
||||
"misskey-bubble-game": "workspace:*",
|
||||
"misskey-mahjong": "workspace:*",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.39.0",
|
||||
"sanitize-html": "2.15.0",
|
||||
"sass": "1.86.3",
|
||||
"shiki": "3.2.2",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"qr-code-styling": "1.9.2",
|
||||
"qr-scanner": "1.4.2",
|
||||
"rollup": "4.54.0",
|
||||
"sanitize-html": "2.17.0",
|
||||
"sass": "1.97.1",
|
||||
"shiki": "3.20.0",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.175.0",
|
||||
"three": "0.182.0",
|
||||
"throttle-debounce": "5.0.2",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.15",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.8.3",
|
||||
"uuid": "11.1.0",
|
||||
"v-code-diff": "1.13.1",
|
||||
"vite": "6.3.1",
|
||||
"vue": "3.5.13",
|
||||
"vuedraggable": "next",
|
||||
"vite": "7.3.0",
|
||||
"vue": "3.5.26",
|
||||
"wanakana": "5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@misskey-dev/summaly": "5.2.0",
|
||||
"@storybook/addon-actions": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-mdx-gfm": "8.6.12",
|
||||
"@storybook/addon-storysource": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/components": "8.6.12",
|
||||
"@storybook/core-events": "8.6.12",
|
||||
"@storybook/manager-api": "8.6.12",
|
||||
"@storybook/preview-api": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@storybook/theming": "8.6.12",
|
||||
"@storybook/types": "8.6.12",
|
||||
"@storybook/vue3": "8.6.12",
|
||||
"@storybook/vue3-vite": "8.6.12",
|
||||
"@misskey-dev/summaly": "5.2.5",
|
||||
"@storybook/addon-essentials": "8.6.15",
|
||||
"@storybook/addon-interactions": "8.6.15",
|
||||
"@storybook/addon-links": "10.1.10",
|
||||
"@storybook/addon-mdx-gfm": "8.6.15",
|
||||
"@storybook/addon-storysource": "8.6.15",
|
||||
"@storybook/blocks": "8.6.15",
|
||||
"@storybook/components": "8.6.15",
|
||||
"@storybook/core-events": "8.6.15",
|
||||
"@storybook/manager-api": "8.6.15",
|
||||
"@storybook/preview-api": "8.6.15",
|
||||
"@storybook/react": "10.1.10",
|
||||
"@storybook/react-vite": "10.1.10",
|
||||
"@storybook/test": "8.6.15",
|
||||
"@storybook/theming": "8.6.15",
|
||||
"@storybook/types": "8.6.15",
|
||||
"@storybook/vue3": "10.1.10",
|
||||
"@storybook/vue3-vite": "10.1.10",
|
||||
"@tabler/icons-webfont": "3.35.0",
|
||||
"@testing-library/vue": "8.1.0",
|
||||
"@types/canvas-confetti": "1.9.0",
|
||||
"@types/estree": "1.0.7",
|
||||
"@types/matter-js": "0.19.8",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.14.0",
|
||||
"@types/estree": "1.0.8",
|
||||
"@types/insert-text-at-cursor": "0.3.2",
|
||||
"@types/matter-js": "0.20.2",
|
||||
"@types/micromatch": "4.0.10",
|
||||
"@types/node": "24.10.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/textarea-caret": "3.0.4",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/ws": "8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.1",
|
||||
"@typescript-eslint/parser": "8.29.1",
|
||||
"@vitest/coverage-v8": "3.1.1",
|
||||
"@vue/compiler-core": "3.5.13",
|
||||
"@vue/runtime-core": "3.5.13",
|
||||
"acorn": "8.14.1",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "14.3.0",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-vue": "10.0.0",
|
||||
"fast-glob": "3.3.3",
|
||||
"happy-dom": "17.4.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.50.1",
|
||||
"@typescript-eslint/parser": "8.50.1",
|
||||
"@vitest/coverage-v8": "4.0.16",
|
||||
"@vue/compiler-core": "3.5.26",
|
||||
"acorn": "8.15.0",
|
||||
"astring": "1.9.0",
|
||||
"cross-env": "10.1.0",
|
||||
"cypress": "15.8.1",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-vue": "10.6.2",
|
||||
"estree-walker": "3.0.3",
|
||||
"happy-dom": "20.0.11",
|
||||
"intersection-observer": "0.12.2",
|
||||
"magic-string": "0.30.21",
|
||||
"micromatch": "4.0.8",
|
||||
"minimatch": "10.0.1",
|
||||
"msw": "2.7.3",
|
||||
"msw-storybook-addon": "2.0.4",
|
||||
"nodemon": "3.1.9",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"minimatch": "10.1.1",
|
||||
"msw": "2.12.6",
|
||||
"msw-storybook-addon": "2.0.6",
|
||||
"nodemon": "3.1.11",
|
||||
"prettier": "3.7.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"seedrandom": "3.0.5",
|
||||
"start-server-and-test": "2.0.11",
|
||||
"storybook": "8.6.12",
|
||||
"start-server-and-test": "2.1.3",
|
||||
"storybook": "10.1.10",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"tsx": "4.21.0",
|
||||
"vite-plugin-glsl": "1.5.5",
|
||||
"vite-plugin-turbosnap": "1.0.3",
|
||||
"vitest": "3.1.1",
|
||||
"vitest": "4.0.16",
|
||||
"vitest-fetch-mock": "0.4.5",
|
||||
"vue-component-type-helpers": "2.2.8",
|
||||
"vue-eslint-parser": "10.1.3",
|
||||
"vue-tsc": "2.2.8"
|
||||
"vue-component-type-helpers": "3.2.1",
|
||||
"vue-eslint-parser": "10.2.0",
|
||||
"vue-tsc": "3.2.1"
|
||||
}
|
||||
}
|
||||
|
||||
338
packages/frontend/public/loader/boot.js
Normal file
338
packages/frontend/public/loader/boot.js
Normal file
@@ -0,0 +1,338 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので
|
||||
(async () => {
|
||||
window.onerror = (e) => {
|
||||
console.error(e);
|
||||
renderError('SOMETHING_HAPPENED', e);
|
||||
};
|
||||
window.onunhandledrejection = (e) => {
|
||||
console.error(e);
|
||||
renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e);
|
||||
};
|
||||
|
||||
let forceError = localStorage.getItem('forceError');
|
||||
if (forceError != null) {
|
||||
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
|
||||
return;
|
||||
}
|
||||
|
||||
//#region Detect language
|
||||
const supportedLangs = LANGS;
|
||||
/** @type { string } */
|
||||
let lang = localStorage.getItem('lang');
|
||||
if (lang == null || !supportedLangs.includes(lang)) {
|
||||
if (supportedLangs.includes(navigator.language)) {
|
||||
lang = navigator.language;
|
||||
} else {
|
||||
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
|
||||
|
||||
// Fallback
|
||||
if (lang == null) lang = 'en-US';
|
||||
}
|
||||
}
|
||||
|
||||
// for https://github.com/misskey-dev/misskey/issues/10202
|
||||
if (lang == null || lang.toString == null || lang.toString() === 'null') {
|
||||
console.error('invalid lang value detected!!!', typeof lang, lang);
|
||||
lang = 'en-US';
|
||||
}
|
||||
|
||||
localStorage.setItem('lang', lang);
|
||||
//#endregion
|
||||
|
||||
//#region Script
|
||||
async function importAppScript() {
|
||||
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
|
||||
.catch(async e => {
|
||||
console.error(e);
|
||||
renderError('APP_IMPORT', e);
|
||||
});
|
||||
}
|
||||
|
||||
// タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある
|
||||
if (document.readyState !== 'loading') {
|
||||
importAppScript();
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
importAppScript();
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
let isSafeMode = (localStorage.getItem('isSafeMode') === 'true');
|
||||
|
||||
if (!isSafeMode) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (urlParams.has('safemode') && urlParams.get('safemode') === 'true') {
|
||||
localStorage.setItem('isSafeMode', 'true');
|
||||
isSafeMode = true;
|
||||
}
|
||||
}
|
||||
|
||||
//#region Theme
|
||||
if (!isSafeMode) {
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (theme) {
|
||||
for (const [k, v] of Object.entries(JSON.parse(theme))) {
|
||||
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
|
||||
|
||||
// HTMLの theme-color 適用
|
||||
if (k === 'htmlThemeColor') {
|
||||
for (const tag of document.head.children) {
|
||||
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
|
||||
tag.setAttribute('content', v);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colorScheme = localStorage.getItem('colorScheme');
|
||||
if (colorScheme) {
|
||||
document.documentElement.style.setProperty('color-scheme', colorScheme);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const fontSize = localStorage.getItem('fontSize');
|
||||
if (fontSize) {
|
||||
document.documentElement.classList.add('f-' + fontSize);
|
||||
}
|
||||
|
||||
const useSystemFont = localStorage.getItem('useSystemFont');
|
||||
if (useSystemFont) {
|
||||
document.documentElement.classList.add('useSystemFont');
|
||||
}
|
||||
|
||||
if (!isSafeMode) {
|
||||
const customCss = localStorage.getItem('customCss');
|
||||
if (customCss && customCss.length > 0) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = customCss;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
async function addStyle(styleText) {
|
||||
let css = document.createElement('style');
|
||||
css.appendChild(document.createTextNode(styleText));
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
|
||||
async function renderError(code, details) {
|
||||
// Cannot set property 'innerHTML' of null を回避
|
||||
if (document.readyState === 'loading') {
|
||||
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
|
||||
}
|
||||
|
||||
let messages = null;
|
||||
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
|
||||
if (bootloaderLocales) {
|
||||
messages = JSON.parse(bootloaderLocales);
|
||||
}
|
||||
if (!messages) {
|
||||
// older version of misskey does not store bootloaderLocales, stores locale as a whole
|
||||
const legacyLocale = localStorage.getItem('locale');
|
||||
if (legacyLocale) {
|
||||
const parsed = JSON.parse(legacyLocale);
|
||||
messages = {
|
||||
...(parsed._bootErrors ?? {}),
|
||||
reload: parsed.reload,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!messages) messages = {};
|
||||
|
||||
messages = Object.assign({
|
||||
title: 'Failed to initialize Misskey',
|
||||
solution: 'The following actions may solve the problem.',
|
||||
solution1: 'Update your os and browser',
|
||||
solution2: 'Disable an adblocker',
|
||||
solution3: 'Clear the browser cache',
|
||||
solution4: '(Tor Browser) Set dom.webaudio.enabled to true',
|
||||
otherOption: 'Other options',
|
||||
otherOption1: 'Clear preferences and cache',
|
||||
otherOption2: 'Start the simple client',
|
||||
otherOption3: 'Start the repair tool',
|
||||
otherOption4: 'Start Misskey in safe mode',
|
||||
reload: 'Reload',
|
||||
}, messages);
|
||||
|
||||
const safeModeUrl = new URL(window.location.href);
|
||||
safeModeUrl.searchParams.set('safemode', 'true');
|
||||
|
||||
let errorsElement = document.getElementById('errors');
|
||||
|
||||
if (!errorsElement) {
|
||||
document.body.innerHTML = `
|
||||
<svg class="icon-warning" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 9v2m0 4v.01"></path>
|
||||
<path d="M5 19h14a2 2 0 0 0 1.84 -2.75l-7.1 -12.25a2 2 0 0 0 -3.5 0l-7.1 12.25a2 2 0 0 0 1.75 2.75"></path>
|
||||
</svg>
|
||||
<h1>${messages.title}</h1>
|
||||
<button class="button-big" onclick="location.reload(true);">
|
||||
<span class="button-label-big">${messages?.reload}</span>
|
||||
</button>
|
||||
<p><b>${messages.solution}</b></p>
|
||||
<p>${messages.solution1}</p>
|
||||
<p>${messages.solution2}</p>
|
||||
<p>${messages.solution3}</p>
|
||||
<p>${messages.solution4}</p>
|
||||
<details style="color: #86b300;">
|
||||
<summary>${messages.otherOption}</summary>
|
||||
<a href="${safeModeUrl}">
|
||||
<button class="button-small">
|
||||
<span class="button-label-small">${messages.otherOption4}</span>
|
||||
</button>
|
||||
</a>
|
||||
<br>
|
||||
<a href="/flush">
|
||||
<button class="button-small">
|
||||
<span class="button-label-small">${messages.otherOption1}</span>
|
||||
</button>
|
||||
</a>
|
||||
<br>
|
||||
<a href="/cli">
|
||||
<button class="button-small">
|
||||
<span class="button-label-small">${messages.otherOption2}</span>
|
||||
</button>
|
||||
</a>
|
||||
<br>
|
||||
<a href="/bios">
|
||||
<button class="button-small">
|
||||
<span class="button-label-small">${messages.otherOption3}</span>
|
||||
</button>
|
||||
</a>
|
||||
</details>
|
||||
<br>
|
||||
<div id="errors"></div>
|
||||
`;
|
||||
errorsElement = document.getElementById('errors');
|
||||
}
|
||||
const detailsElement = document.createElement('details');
|
||||
detailsElement.id = 'errorInfo';
|
||||
detailsElement.innerHTML = `
|
||||
<br>
|
||||
<summary>
|
||||
<code>ERROR CODE: ${code}</code>
|
||||
</summary>
|
||||
<code>${details.toString()} ${JSON.stringify(details)}</code>`;
|
||||
errorsElement.appendChild(detailsElement);
|
||||
addStyle(`
|
||||
* {
|
||||
font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
}
|
||||
|
||||
#misskey_app,
|
||||
#splash {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
background-color: #222;
|
||||
color: #dfddcc;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 999px;
|
||||
padding: 0px 12px 0px 12px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.button-big {
|
||||
background: linear-gradient(90deg, rgb(134, 179, 0), rgb(74, 179, 0));
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.button-big:hover {
|
||||
background: rgb(153, 204, 0);
|
||||
}
|
||||
|
||||
.button-small {
|
||||
background: #444;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.button-small:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.button-label-big {
|
||||
color: #222;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.button-label-small {
|
||||
color: rgb(153, 204, 0);
|
||||
font-size: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(134, 179, 0);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: #dec340;
|
||||
height: 4rem;
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 1em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: Fira, FiraCode, monospace;
|
||||
}
|
||||
|
||||
#errorInfo {
|
||||
background: #333;
|
||||
margin-bottom: 2rem;
|
||||
padding: 0.5rem 1rem;
|
||||
width: 40rem;
|
||||
border-radius: 10px;
|
||||
justify-content: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#errorInfo summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#errorInfo summary > * {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
#errorInfo {
|
||||
width: 50%;
|
||||
}
|
||||
}`);
|
||||
}
|
||||
})();
|
||||
78
packages/frontend/public/loader/style.css
Normal file
78
packages/frontend/public/loader/style.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
html {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
color: var(--MI_THEME-fg);
|
||||
}
|
||||
|
||||
#splash {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
cursor: wait;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
opacity: 1;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
#splashIcon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#splashSpinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
transform: translateY(70px);
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
#splashSpinner > .spinner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-miterlimit: 1.5;
|
||||
}
|
||||
#splashSpinner > .spinner.bg {
|
||||
opacity: 0.275;
|
||||
}
|
||||
#splashSpinner > .spinner.fg {
|
||||
animation: splashSpinner 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes splashSpinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@
|
||||
// https://vitejs.dev/config/build-options.html#build-modulepreload
|
||||
import 'vite/modulepreload-polyfill';
|
||||
|
||||
import '@tabler/icons-webfont/dist/tabler-icons.scss';
|
||||
if (import.meta.env.DEV) {
|
||||
await import('@tabler/icons-webfont/dist/tabler-icons.scss');
|
||||
} else {
|
||||
await import('icons-subsetter/built/tabler-icons-frontend.css');
|
||||
}
|
||||
|
||||
import '@/style.scss';
|
||||
import { mainBoot } from '@/boot/main-boot.js';
|
||||
import { subBoot } from '@/boot/sub-boot.js';
|
||||
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/install-extensions'];
|
||||
const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete', '/verify-email', '/install-extensions'];
|
||||
|
||||
if (subBootPaths.some(i => window.location.pathname === i || window.location.pathname.startsWith(i + '/'))) {
|
||||
subBoot();
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function getAccounts(): Promise<{
|
||||
host: string;
|
||||
id: Misskey.entities.User['id'];
|
||||
username: Misskey.entities.User['username'];
|
||||
user?: Misskey.entities.User | null;
|
||||
user?: Misskey.entities.MeDetailed | null;
|
||||
token: string | null;
|
||||
}[]> {
|
||||
const tokens = store.s.accountTokens;
|
||||
@@ -38,7 +38,7 @@ export async function getAccounts(): Promise<{
|
||||
}));
|
||||
}
|
||||
|
||||
async function addAccount(host: string, user: Misskey.entities.User, token: AccountWithToken['token']) {
|
||||
async function addAccount(host: string, user: Misskey.entities.MeDetailed, token: AccountWithToken['token']) {
|
||||
if (!prefer.s.accounts.some(x => x[0] === host && x[1].id === user.id)) {
|
||||
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + user.id]: token });
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + user.id]: user });
|
||||
@@ -126,10 +126,10 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
|
||||
if (!$i) return;
|
||||
const token = $i.token;
|
||||
for (const key of Object.keys($i)) {
|
||||
delete $i[key];
|
||||
delete $i[key as keyof typeof $i];
|
||||
}
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
($i[key as keyof typeof accountData] as any) = value;
|
||||
}
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
|
||||
$i.token = token;
|
||||
@@ -139,7 +139,7 @@ export function updateCurrentAccount(accountData: Misskey.entities.MeDetailed) {
|
||||
export function updateCurrentAccountPartial(accountData: Partial<Misskey.entities.MeDetailed>) {
|
||||
if (!$i) return;
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
($i[key as keyof typeof accountData] as any) = value;
|
||||
}
|
||||
|
||||
store.set('accountInfos', { ...store.s.accountInfos, [host + '/' + $i.id]: $i });
|
||||
@@ -149,9 +149,10 @@ export function updateCurrentAccountPartial(accountData: Partial<Misskey.entitie
|
||||
|
||||
export async function refreshCurrentAccount() {
|
||||
if (!$i) return;
|
||||
const me = $i;
|
||||
return fetchAccount($i.token, $i.id).then(updateCurrentAccount).catch(reason => {
|
||||
if (reason === isAccountDeleted) {
|
||||
removeAccount(host, $i.id);
|
||||
removeAccount(host, me.id);
|
||||
if (Object.keys(store.s.accountTokens).length > 0) {
|
||||
login(Object.values(store.s.accountTokens)[0]);
|
||||
} else {
|
||||
@@ -210,50 +211,79 @@ export async function switchAccount(host: string, id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function openAccountMenu(opts: {
|
||||
export async function getAccountMenu(opts: {
|
||||
includeCurrentAccount?: boolean;
|
||||
withExtraOperation: boolean;
|
||||
active?: Misskey.entities.User['id'];
|
||||
onChoose?: (account: Misskey.entities.User) => void;
|
||||
}, ev: MouseEvent) {
|
||||
if (!$i) return;
|
||||
onChoose?: (account: Misskey.entities.MeDetailed) => void;
|
||||
}) {
|
||||
if ($i == null) throw new Error('No current account');
|
||||
const me = $i;
|
||||
|
||||
function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.User | null | undefined, token: string): MenuItem {
|
||||
const callback = opts.onChoose;
|
||||
|
||||
function createItem(host: string, id: Misskey.entities.User['id'], username: Misskey.entities.User['username'], account: Misskey.entities.MeDetailed | null | undefined, token: string | null): MenuItem {
|
||||
if (account) {
|
||||
return {
|
||||
type: 'user' as const,
|
||||
user: account,
|
||||
active: opts.active != null ? opts.active === id : false,
|
||||
action: async () => {
|
||||
if (opts.onChoose) {
|
||||
opts.onChoose(account);
|
||||
if (callback) {
|
||||
callback(account);
|
||||
} else {
|
||||
switchAccount(host, id);
|
||||
}
|
||||
},
|
||||
};
|
||||
} else {
|
||||
} else if (token != null) {
|
||||
return {
|
||||
type: 'button' as const,
|
||||
text: username,
|
||||
active: opts.active != null ? opts.active === id : false,
|
||||
action: async () => {
|
||||
if (opts.onChoose) {
|
||||
if (callback) {
|
||||
fetchAccount(token, id).then(account => {
|
||||
opts.onChoose(account);
|
||||
callback(account);
|
||||
});
|
||||
} else {
|
||||
switchAccount(host, id);
|
||||
}
|
||||
},
|
||||
};
|
||||
} else { // プロファイルを復元した場合などはアカウントのトークンや詳細情報はstoreにキャッシュされていない
|
||||
return {
|
||||
type: 'button' as const,
|
||||
text: username,
|
||||
active: opts.active != null ? opts.active === id : false,
|
||||
action: async () => {
|
||||
const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {
|
||||
initialUsername: username,
|
||||
}, {
|
||||
done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => {
|
||||
store.set('accountTokens', { ...store.s.accountTokens, [host + '/' + res.id]: res.i });
|
||||
|
||||
if (callback) {
|
||||
fetchAccount(res.i, id).then(account => {
|
||||
callback(account);
|
||||
});
|
||||
} else {
|
||||
switchAccount(host, id);
|
||||
}
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// TODO: $iのホストも比較したいけど通常null
|
||||
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
|
||||
const accountItems = (await getAccounts().then(accounts => accounts.filter(x => x.id !== me.id))).map(a => createItem(a.host, a.id, a.username, a.user, a.token));
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
menuItems.push({
|
||||
@@ -308,9 +338,7 @@ export async function openAccountMenu(opts: {
|
||||
menuItems.push(...accountItems);
|
||||
}
|
||||
|
||||
popupMenu(menuItems, ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
return menuItems;
|
||||
}
|
||||
|
||||
export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> {
|
||||
|
||||
@@ -40,48 +40,109 @@ export function createAiScriptEnv(opts: { storageKey: string, token?: string })
|
||||
CUSTOM_EMOJIS: utils.jsToVal(customEmojis.value),
|
||||
LOCALE: values.STR(lang),
|
||||
SERVER_URL: values.STR(url),
|
||||
'Mk:dialog': values.FN_NATIVE(async ([title, text, type]) => {
|
||||
utils.assertString(title);
|
||||
utils.assertString(text);
|
||||
if (type != null) {
|
||||
assertStringAndIsIn(type, DIALOG_TYPES);
|
||||
'Mk:dialog': values.FN_NATIVE(async ([_title, _text, _type]) => {
|
||||
let title: string | undefined = undefined;
|
||||
let text: string | undefined = undefined;
|
||||
let type: typeof DIALOG_TYPES[number] = 'info';
|
||||
|
||||
if (_title != null) {
|
||||
if (utils.isString(_title)) {
|
||||
title = _title.value;
|
||||
} else {
|
||||
utils.assertNull(_title);
|
||||
}
|
||||
}
|
||||
|
||||
if (_text != null) {
|
||||
if (utils.isString(_text)) {
|
||||
text = _text.value;
|
||||
} else {
|
||||
utils.assertNull(_text);
|
||||
}
|
||||
}
|
||||
|
||||
if (_type != null) {
|
||||
if (utils.isString(_type)) {
|
||||
assertStringAndIsIn(_type, DIALOG_TYPES);
|
||||
type = _type.value;
|
||||
} else {
|
||||
utils.assertNull(_type);
|
||||
}
|
||||
}
|
||||
|
||||
await os.alert({
|
||||
type: type ? type.value : 'info',
|
||||
title: title.value,
|
||||
text: text.value,
|
||||
type,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
return values.NULL;
|
||||
}),
|
||||
'Mk:confirm': values.FN_NATIVE(async ([title, text, type]) => {
|
||||
utils.assertString(title);
|
||||
utils.assertString(text);
|
||||
if (type != null) {
|
||||
assertStringAndIsIn(type, DIALOG_TYPES);
|
||||
'Mk:confirm': values.FN_NATIVE(async ([_title, _text, _type]) => {
|
||||
let title: string | undefined = undefined;
|
||||
let text: string | undefined = undefined;
|
||||
let type: typeof DIALOG_TYPES[number] = 'question';
|
||||
|
||||
if (_title != null) {
|
||||
if (utils.isString(_title)) {
|
||||
title = _title.value;
|
||||
} else {
|
||||
utils.assertNull(_title);
|
||||
}
|
||||
}
|
||||
|
||||
if (_text != null) {
|
||||
if (utils.isString(_text)) {
|
||||
text = _text.value;
|
||||
} else {
|
||||
utils.assertNull(_text);
|
||||
}
|
||||
}
|
||||
|
||||
if (_type != null) {
|
||||
if (utils.isString(_type)) {
|
||||
assertStringAndIsIn(_type, DIALOG_TYPES);
|
||||
type = _type.value;
|
||||
} else {
|
||||
utils.assertNull(_type);
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = await os.confirm({
|
||||
type: type ? type.value : 'question',
|
||||
title: title.value,
|
||||
text: text.value,
|
||||
type,
|
||||
title,
|
||||
text,
|
||||
});
|
||||
return confirm.canceled ? values.FALSE : values.TRUE;
|
||||
}),
|
||||
'Mk:toast': values.FN_NATIVE(([text]) => {
|
||||
utils.assertString(text);
|
||||
os.toast(text.value);
|
||||
return values.NULL;
|
||||
}),
|
||||
'Mk:api': values.FN_NATIVE(async ([ep, param, token]) => {
|
||||
utils.assertString(ep);
|
||||
if (ep.value.includes('://')) {
|
||||
if (ep.value.includes('://') || ep.value.includes('..')) {
|
||||
throw new errors.AiScriptRuntimeError('invalid endpoint');
|
||||
}
|
||||
if (token) {
|
||||
|
||||
let actualToken: string | null = null;
|
||||
if (token != null && !utils.isNull(token)) {
|
||||
utils.assertString(token);
|
||||
// バグがあればundefinedもあり得るため念のため
|
||||
if (typeof token.value !== 'string') throw new Error('invalid token');
|
||||
if (typeof token.value !== 'string') throw new errors.AiScriptRuntimeError('invalid token');
|
||||
actualToken = token.value;
|
||||
}
|
||||
const actualToken: string | null = token?.value ?? opts.token ?? null;
|
||||
|
||||
if (actualToken == null) {
|
||||
actualToken = opts.token ?? null;
|
||||
}
|
||||
|
||||
if (param == null) {
|
||||
throw new errors.AiScriptRuntimeError('expected param');
|
||||
}
|
||||
|
||||
utils.assertObject(param);
|
||||
return misskeyApi(ep.value, utils.valToJs(param) as object, actualToken).then(res => {
|
||||
return misskeyApi(ep.value as keyof Misskey.Endpoints, utils.valToJs(param) as object, actualToken).then(res => {
|
||||
return utils.jsToVal(res);
|
||||
}, err => {
|
||||
return values.ERROR('request_failed', utils.jsToVal(err));
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
*/
|
||||
|
||||
import { utils, values } from '@syuilo/aiscript';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ref } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { assertStringAndIsIn } from './common.js';
|
||||
import type { Ref } from 'vue';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const ALIGNS = ['left', 'center', 'right'] as const;
|
||||
const FONTS = ['serif', 'sans-serif', 'monospace'] as const;
|
||||
@@ -21,16 +21,15 @@ type BorderStyle = (typeof BORDER_STYLES)[number];
|
||||
export type AsUiComponentBase = {
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
children?: AsUiComponent['id'][];
|
||||
};
|
||||
|
||||
export type AsUiRoot = AsUiComponentBase & {
|
||||
type: 'root';
|
||||
children: AsUiComponent['id'][];
|
||||
};
|
||||
|
||||
export type AsUiContainer = AsUiComponentBase & {
|
||||
type: 'container';
|
||||
children?: AsUiComponent['id'][];
|
||||
align?: Align;
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
@@ -123,7 +122,6 @@ export type AsUiSelect = AsUiComponentBase & {
|
||||
|
||||
export type AsUiFolder = AsUiComponentBase & {
|
||||
type: 'folder';
|
||||
children?: AsUiComponent['id'][];
|
||||
title?: string;
|
||||
opened?: boolean;
|
||||
};
|
||||
@@ -533,7 +531,7 @@ function getPostFormOptions(def: values.Value | undefined, call: (fn: values.VFn
|
||||
export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
|
||||
type OptionsConverter<T extends AsUiComponent, C> = (def: values.Value | undefined, call: C) => Options<T>;
|
||||
|
||||
const instances = {};
|
||||
const instances = {} as Record<string, values.VObj>;
|
||||
|
||||
function createComponentInstance<T extends AsUiComponent, C>(
|
||||
type: T['type'],
|
||||
@@ -543,7 +541,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
|
||||
call: C,
|
||||
) {
|
||||
if (id) utils.assertString(id);
|
||||
const _id = id?.value ?? uuid();
|
||||
const _id = id?.value ?? genId();
|
||||
const component = ref({
|
||||
...getOptions(def, call),
|
||||
type,
|
||||
@@ -557,7 +555,7 @@ export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: R
|
||||
const updates = getOptions(def, call);
|
||||
for (const update of def.value.keys()) {
|
||||
if (!Object.hasOwn(updates, update)) continue;
|
||||
component.value[update] = updates[update];
|
||||
component.value[update] = updates[update as keyof Options<T>];
|
||||
}
|
||||
})],
|
||||
]));
|
||||
|
||||
@@ -90,6 +90,7 @@ export async function initAnalytics(instance: Misskey.entities.MetaDetailed) {
|
||||
|
||||
// Google Analytics
|
||||
if (instance.googleAnalyticsMeasurementId) {
|
||||
//@ts-expect-error Dynamic import
|
||||
const { default: googleAnalytics } = await import('@analytics/google-analytics');
|
||||
|
||||
plugins.push(googleAnalytics({
|
||||
|
||||
@@ -3,22 +3,23 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { computed, watch, version as vueVersion } from 'vue';
|
||||
import { watch, version as vueVersion } from 'vue';
|
||||
import { compareVersions } from 'compare-versions';
|
||||
import { version, lang, updateLocale, locale, apiUrl } from '@@/js/config.js';
|
||||
import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
|
||||
import defaultLightTheme from '@@/themes/l-light.json5';
|
||||
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
|
||||
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
|
||||
import type { App } from 'vue';
|
||||
import widgets from '@/widgets/index.js';
|
||||
import directives from '@/directives/index.js';
|
||||
import components from '@/components/index.js';
|
||||
import { applyTheme } from '@/theme.js';
|
||||
import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js';
|
||||
import { updateI18n, i18n } from '@/i18n.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { refreshCurrentAccount, login } from '@/accounts.js';
|
||||
import { store } from '@/store.js';
|
||||
import { fetchInstance, instance } from '@/instance.js';
|
||||
import { deviceKind, updateDeviceKind } from '@/utility/device-kind.js';
|
||||
import { updateDeviceKind } from '@/utility/device-kind.js';
|
||||
import { reloadChannel } from '@/utility/unison-reload.js';
|
||||
import { getUrlWithoutLoginId } from '@/utility/login-id.js';
|
||||
import { getAccountFromId } from '@/utility/get-account-from-id.js';
|
||||
@@ -28,6 +29,7 @@ import { miLocalStorage } from '@/local-storage.js';
|
||||
import { fetchCustomEmojis } from '@/custom-emojis.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { launchPlugins } from '@/plugin.js';
|
||||
|
||||
export async function common(createVue: () => Promise<App<Element>>) {
|
||||
console.info(`Misskey v${version}`);
|
||||
@@ -67,9 +69,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
if (lastVersion !== version) {
|
||||
miLocalStorage.setItem('lastVersion', version);
|
||||
|
||||
// テーマリビルドするため
|
||||
miLocalStorage.removeItem('theme');
|
||||
|
||||
try { // 変なバージョン文字列来るとcompareVersionsでエラーになるため
|
||||
if (lastVersion != null && compareVersions(version, lastVersion) === 1) {
|
||||
isClientUpdated = true;
|
||||
@@ -79,18 +78,20 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
//#endregion
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
const localeVersion = miLocalStorage.getItem('localeVersion');
|
||||
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
|
||||
if (localeOutdated) {
|
||||
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
|
||||
if (res.status === 200) {
|
||||
const newLocale = await res.text();
|
||||
const parsedNewLocale = JSON.parse(newLocale);
|
||||
miLocalStorage.setItem('locale', newLocale);
|
||||
miLocalStorage.setItem('localeVersion', version);
|
||||
updateLocale(parsedNewLocale);
|
||||
updateI18n(parsedNewLocale);
|
||||
}
|
||||
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('locale-update', async (updatedLang: string) => {
|
||||
console.info(`Locale updated: ${updatedLang}`);
|
||||
if (updatedLang === lang) {
|
||||
await new Promise(resolve => {
|
||||
window.setTimeout(resolve, 500);
|
||||
});
|
||||
// fetch with cache: 'no-store' to ensure the latest locale is fetched
|
||||
await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text());
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
@@ -108,13 +109,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
else window.location.reload();
|
||||
});
|
||||
|
||||
// If mobile, insert the viewport meta tag
|
||||
if (['smartphone', 'tablet'].includes(deviceKind)) {
|
||||
const viewport = window.document.getElementsByName('viewport').item(0);
|
||||
viewport.setAttribute('content',
|
||||
`${viewport.getAttribute('content')}, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`);
|
||||
}
|
||||
|
||||
//#region Set lang attr
|
||||
const html = window.document.documentElement;
|
||||
html.setAttribute('lang', lang);
|
||||
@@ -147,31 +141,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
watch(store.r.darkMode, (darkMode) => {
|
||||
applyTheme(darkMode
|
||||
? (prefer.s.darkTheme ?? defaultDarkTheme)
|
||||
: (prefer.s.lightTheme ?? defaultLightTheme),
|
||||
);
|
||||
}, { immediate: miLocalStorage.getItem('theme') == null });
|
||||
|
||||
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
|
||||
|
||||
const darkTheme = prefer.model('darkTheme');
|
||||
const lightTheme = prefer.model('lightTheme');
|
||||
|
||||
watch(darkTheme, (theme) => {
|
||||
if (store.s.darkMode) {
|
||||
applyTheme(theme ?? defaultDarkTheme);
|
||||
}
|
||||
});
|
||||
|
||||
watch(lightTheme, (theme) => {
|
||||
if (!store.s.darkMode) {
|
||||
applyTheme(theme ?? defaultLightTheme);
|
||||
}
|
||||
});
|
||||
|
||||
//#region Sync dark mode
|
||||
if (prefer.s.syncDeviceDarkMode) {
|
||||
store.set('darkMode', isDeviceDarkmode());
|
||||
@@ -184,17 +153,43 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
});
|
||||
//#endregion
|
||||
|
||||
if (prefer.s.darkTheme && store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);
|
||||
} else if (prefer.s.lightTheme && !store.s.darkMode) {
|
||||
if (miLocalStorage.getItem('themeId') !== prefer.s.lightTheme.id) applyTheme(prefer.s.lightTheme);
|
||||
}
|
||||
|
||||
fetchInstanceMetaPromise.then(() => {
|
||||
if (!isSafeMode) {
|
||||
// TODO: instance.defaultLightTheme/instance.defaultDarkThemeが不正な形式だった場合のケア
|
||||
if (prefer.s.lightTheme == null && instance.defaultLightTheme != null) prefer.commit('lightTheme', JSON.parse(instance.defaultLightTheme));
|
||||
if (prefer.s.darkTheme == null && instance.defaultDarkTheme != null) prefer.commit('darkTheme', JSON.parse(instance.defaultDarkTheme));
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
|
||||
// NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため)
|
||||
// NOTE: この処理は必ずサーバーテーマ適用処理より後に来ること(二重applyTheme発火を防ぐため)
|
||||
// see: https://github.com/misskey-dev/misskey/issues/16562
|
||||
watch(store.r.darkMode, (darkMode) => {
|
||||
const theme = (() => {
|
||||
if (darkMode) {
|
||||
return isSafeMode ? defaultDarkTheme : (prefer.s.darkTheme ?? defaultDarkTheme);
|
||||
} else {
|
||||
return isSafeMode ? defaultLightTheme : (prefer.s.lightTheme ?? defaultLightTheme);
|
||||
}
|
||||
})();
|
||||
|
||||
applyTheme(theme);
|
||||
}, { immediate: true });
|
||||
|
||||
window.document.documentElement.dataset.colorScheme = store.s.darkMode ? 'dark' : 'light';
|
||||
|
||||
if (!isSafeMode) {
|
||||
watch(prefer.r.darkTheme, (theme) => {
|
||||
if (store.s.darkMode) {
|
||||
applyTheme(theme ?? defaultDarkTheme);
|
||||
}
|
||||
});
|
||||
|
||||
watch(prefer.r.lightTheme, (theme) => {
|
||||
if (!store.s.darkMode) {
|
||||
applyTheme(theme ?? defaultLightTheme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(prefer.r.overridedDeviceKind, (kind) => {
|
||||
updateDeviceKind(kind);
|
||||
@@ -326,6 +321,12 @@ export async function common(createVue: () => Promise<App<Element>>) {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await launchPlugins();
|
||||
} catch (error) {
|
||||
console.error('Failed to launch plugins:', error);
|
||||
}
|
||||
|
||||
app.mount(rootEl);
|
||||
|
||||
// boot.jsのやつを解除
|
||||
|
||||
@@ -26,10 +26,10 @@ import { mainRouter } from '@/router.js';
|
||||
import { makeHotkey } from '@/utility/hotkey.js';
|
||||
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { launchPlugins } from '@/plugin.js';
|
||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||
import { signout } from '@/signout.js';
|
||||
import { migrateOldSettings } from '@/pref-migrate.js';
|
||||
import { unisonReload } from '@/utility/unison-reload.js';
|
||||
import { isBirthday } from '@/utility/is-birthday.js';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated, lastVersion } = await common(async () => {
|
||||
@@ -79,41 +79,6 @@ export async function mainBoot() {
|
||||
}
|
||||
}
|
||||
|
||||
const stream = useStream();
|
||||
|
||||
let reloadDialogShowing = false;
|
||||
stream.on('_disconnected_', async () => {
|
||||
if (prefer.s.serverDisconnectedBehavior === 'reload') {
|
||||
window.location.reload();
|
||||
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
|
||||
if (reloadDialogShowing) return;
|
||||
reloadDialogShowing = true;
|
||||
const { canceled } = await confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts.disconnectedFromServer,
|
||||
text: i18n.ts.reloadConfirm,
|
||||
});
|
||||
reloadDialogShowing = false;
|
||||
if (!canceled) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('emojiAdded', emojiData => {
|
||||
addCustomEmoji(emojiData.emoji);
|
||||
});
|
||||
|
||||
stream.on('emojiUpdated', emojiData => {
|
||||
updateCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
stream.on('emojiDeleted', emojiData => {
|
||||
removeCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
launchPlugins();
|
||||
|
||||
try {
|
||||
if (prefer.s.enableSeasonalScreenEffect) {
|
||||
const month = new Date().getMonth() + 1;
|
||||
@@ -169,8 +134,6 @@ export async function mainBoot() {
|
||||
}
|
||||
}
|
||||
|
||||
stream.on('announcementCreated', onAnnouncementCreated);
|
||||
|
||||
if ($i.isDeleted) {
|
||||
alert({
|
||||
type: 'warning',
|
||||
@@ -182,12 +145,8 @@ export async function mainBoot() {
|
||||
const m = now.getMonth() + 1;
|
||||
const d = now.getDate();
|
||||
|
||||
if ($i.birthday) {
|
||||
const bm = parseInt($i.birthday.split('-')[1]);
|
||||
const bd = parseInt($i.birthday.split('-')[2]);
|
||||
if (m === bm && d === bd) {
|
||||
claimAchievement('loggedInOnBirthday');
|
||||
}
|
||||
if (isBirthday($i, now)) {
|
||||
claimAchievement('loggedInOnBirthday');
|
||||
}
|
||||
|
||||
if (m === 1 && d === 1) {
|
||||
@@ -341,60 +300,81 @@ export async function mainBoot() {
|
||||
});
|
||||
}
|
||||
|
||||
if ('Notification' in window) {
|
||||
// 許可を得ていなかったらリクエスト
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
if (store.s.realtimeMode) {
|
||||
const stream = useStream();
|
||||
|
||||
let reloadDialogShowing = false;
|
||||
stream.on('_disconnected_', async () => {
|
||||
if (prefer.s.serverDisconnectedBehavior === 'reload') {
|
||||
window.location.reload();
|
||||
} else if (prefer.s.serverDisconnectedBehavior === 'dialog') {
|
||||
if (reloadDialogShowing) return;
|
||||
reloadDialogShowing = true;
|
||||
const { canceled } = await confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts.disconnectedFromServer,
|
||||
text: i18n.ts.reloadConfirm,
|
||||
});
|
||||
reloadDialogShowing = false;
|
||||
if (!canceled) {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('emojiAdded', emojiData => {
|
||||
addCustomEmoji(emojiData.emoji);
|
||||
});
|
||||
|
||||
stream.on('emojiUpdated', emojiData => {
|
||||
updateCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
stream.on('emojiDeleted', emojiData => {
|
||||
removeCustomEmojis(emojiData.emojis);
|
||||
});
|
||||
|
||||
stream.on('announcementCreated', onAnnouncementCreated);
|
||||
|
||||
const main = markRaw(stream.useChannel('main', null, 'System'));
|
||||
|
||||
// 自分の情報が更新されたとき
|
||||
main.on('meUpdated', i => {
|
||||
updateCurrentAccountPartial(i);
|
||||
});
|
||||
|
||||
main.on('readAllNotifications', () => {
|
||||
updateCurrentAccountPartial({
|
||||
hasUnreadNotification: false,
|
||||
unreadNotificationsCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadNotification', () => {
|
||||
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
|
||||
updateCurrentAccountPartial({
|
||||
hasUnreadNotification: true,
|
||||
unreadNotificationsCount,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('newChatMessage', () => {
|
||||
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
|
||||
sound.playMisskeySfx('chatMessage');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// 個人宛てお知らせが発行されたとき
|
||||
main.on('announcementCreated', onAnnouncementCreated);
|
||||
}
|
||||
|
||||
const main = markRaw(stream.useChannel('main', null, 'System'));
|
||||
|
||||
// 自分の情報が更新されたとき
|
||||
main.on('meUpdated', i => {
|
||||
updateCurrentAccountPartial(i);
|
||||
});
|
||||
|
||||
main.on('readAllNotifications', () => {
|
||||
updateCurrentAccountPartial({
|
||||
hasUnreadNotification: false,
|
||||
unreadNotificationsCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadNotification', () => {
|
||||
const unreadNotificationsCount = ($i?.unreadNotificationsCount ?? 0) + 1;
|
||||
updateCurrentAccountPartial({
|
||||
hasUnreadNotification: true,
|
||||
unreadNotificationsCount,
|
||||
});
|
||||
});
|
||||
|
||||
main.on('unreadAntenna', () => {
|
||||
updateCurrentAccountPartial({ hasUnreadAntenna: true });
|
||||
sound.playMisskeySfx('antenna');
|
||||
});
|
||||
|
||||
main.on('newChatMessage', () => {
|
||||
updateCurrentAccountPartial({ hasUnreadChatMessages: true });
|
||||
sound.playMisskeySfx('chatMessage');
|
||||
});
|
||||
|
||||
main.on('readAllAnnouncements', () => {
|
||||
updateCurrentAccountPartial({ hasUnreadAnnouncement: false });
|
||||
});
|
||||
|
||||
// 個人宛てお知らせが発行されたとき
|
||||
main.on('announcementCreated', onAnnouncementCreated);
|
||||
|
||||
// トークンが再生成されたとき
|
||||
// このままではMisskeyが利用できないので強制的にサインアウトさせる
|
||||
main.on('myTokenRegenerated', () => {
|
||||
signout();
|
||||
});
|
||||
}
|
||||
|
||||
// shortcut
|
||||
let safemodeRequestCount = 0;
|
||||
let safemodeRequestTimer: number | null = null;
|
||||
const keymap = {
|
||||
'p|n': () => {
|
||||
if ($i == null) return;
|
||||
@@ -406,6 +386,24 @@ export async function mainBoot() {
|
||||
's': () => {
|
||||
mainRouter.push('/search');
|
||||
},
|
||||
'g': {
|
||||
callback: () => {
|
||||
// mを5回押すとセーフモードに入る
|
||||
safemodeRequestCount++;
|
||||
if (safemodeRequestCount >= 5) {
|
||||
miLocalStorage.setItem('isSafeMode', 'true');
|
||||
unisonReload();
|
||||
} else {
|
||||
if (safemodeRequestTimer != null) {
|
||||
window.clearTimeout(safemodeRequestTimer);
|
||||
}
|
||||
safemodeRequestTimer = window.setTimeout(() => {
|
||||
safemodeRequestCount = 0;
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
allowRepeat: true,
|
||||
},
|
||||
} as const satisfies Keymap;
|
||||
window.document.addEventListener('keydown', makeHotkey(keymap), { passive: false });
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import * as Misskey from 'misskey-js';
|
||||
import { Cache } from '@/utility/cache.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list'));
|
||||
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
|
||||
export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, () => misskeyApi('clips/list', { limit: 30 }));
|
||||
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list', { limit: 30 }));
|
||||
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
|
||||
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list'));
|
||||
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list', { limit: 30 }));
|
||||
export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));
|
||||
|
||||
@@ -115,7 +115,7 @@ watch(moderationNote, async () => {
|
||||
});
|
||||
});
|
||||
|
||||
function resolve(resolvedAs) {
|
||||
function resolve(resolvedAs: 'accept' | 'reject' | null) {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
reportId: props.report.id,
|
||||
resolvedAs,
|
||||
@@ -132,7 +132,7 @@ function forward() {
|
||||
});
|
||||
}
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
function showMenu(ev: PointerEvent) {
|
||||
os.popupMenu([{
|
||||
icon: 'ti ti-hash',
|
||||
text: 'Copy ID',
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
* 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 type { StoryObj } from '@storybook/vue3';
|
||||
|
||||
import { action } from 'storybook/actions';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAbuseReportWindow from './MkAbuseReportWindow.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
|
||||
<div class="_gaps_m" :class="$style.root">
|
||||
<div class="">
|
||||
<MkTextarea v-model="comment">
|
||||
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
||||
@@ -16,20 +16,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
[$style.iconFrame_platinum]: ACHIEVEMENT_BADGES[achievement.name].frame === 'platinum',
|
||||
}]"
|
||||
>
|
||||
<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg }">
|
||||
<div :class="[$style.iconInner]" :style="{ background: ACHIEVEMENT_BADGES[achievement.name].bg ?? '' }">
|
||||
<img :class="$style.iconImg" :src="ACHIEVEMENT_BADGES[achievement.name].img">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.body">
|
||||
<div :class="$style.header">
|
||||
<span :class="$style.title">{{ i18n.ts._achievements._types['_' + achievement.name].title }}</span>
|
||||
<span :class="$style.title">{{ i18n.ts._achievements._types[`_${achievement.name}`].title }}</span>
|
||||
<span :class="$style.time">
|
||||
<time v-tooltip="new Date(achievement.unlockedAt).toLocaleString()">{{ new Date(achievement.unlockedAt).getFullYear() }}/{{ new Date(achievement.unlockedAt).getMonth() + 1 }}/{{ new Date(achievement.unlockedAt).getDate() }}</time>
|
||||
</span>
|
||||
</div>
|
||||
<div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types['_' + achievement.name].description : '???' }}</div>
|
||||
<div v-if="i18n.ts._achievements._types['_' + achievement.name].flavor && withDescription" :class="$style.flavor">{{ i18n.ts._achievements._types['_' + achievement.name].flavor }}</div>
|
||||
<div :class="$style.description">{{ withDescription ? i18n.ts._achievements._types[`_${achievement.name}`].description : '???' }}</div>
|
||||
<div v-if="'flavor' in i18n.ts._achievements._types[`_${achievement.name}`] && withDescription" :class="$style.flavor">{{ (i18n.ts._achievements._types[`_${achievement.name}`] as { flavor: string; }).flavor }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="withLocked">
|
||||
@@ -54,15 +54,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ACHIEVEMENT_TYPES, ACHIEVEMENT_BADGES, claimAchievement } from '@/utility/achievements.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.User;
|
||||
withLocked: boolean;
|
||||
withDescription: boolean;
|
||||
withLocked?: boolean;
|
||||
withDescription?: boolean;
|
||||
}>(), {
|
||||
withLocked: true,
|
||||
withDescription: true,
|
||||
@@ -71,7 +70,7 @@ const props = withDefaults(defineProps<{
|
||||
const achievements = ref<Misskey.entities.UsersAchievementsResponse | null>(null);
|
||||
const lockedAchievements = computed(() => ACHIEVEMENT_TYPES.filter(x => !(achievements.value ?? []).some(a => a.name === x)));
|
||||
|
||||
function fetch() {
|
||||
function _fetch_() {
|
||||
misskeyApi('users/achievements', { userId: props.user.id }).then(res => {
|
||||
achievements.value = [];
|
||||
for (const t of ACHIEVEMENT_TYPES) {
|
||||
@@ -84,11 +83,11 @@ function fetch() {
|
||||
|
||||
function clickHere() {
|
||||
claimAchievement('clickedClickHere');
|
||||
fetch();
|
||||
_fetch_();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetch();
|
||||
_fetch_();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
111
packages/frontend/src/components/MkAnimBg.fragment.glsl
Normal file
111
packages/frontend/src/components/MkAnimBg.fragment.glsl
Normal file
@@ -0,0 +1,111 @@
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
vec3 mod289(vec3 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec2 mod289(vec2 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
|
||||
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
|
||||
vec2 i1;
|
||||
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
|
||||
i = mod289(i);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + i.x + vec3(0.0, i1.x, 1.0));
|
||||
|
||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
|
||||
m = m*m;
|
||||
m = m*m;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
in vec2 in_uv;
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_spread;
|
||||
uniform float u_speed;
|
||||
uniform float u_warp;
|
||||
uniform float u_focus;
|
||||
uniform float u_itensity;
|
||||
out vec4 out_color;
|
||||
|
||||
float circle(in vec2 _pos, in vec2 _origin, in float _radius) {
|
||||
float SPREAD = 0.7 * u_spread;
|
||||
float SPEED = 0.00055 * u_speed;
|
||||
float WARP = 1.5 * u_warp;
|
||||
float FOCUS = 1.15 * u_focus;
|
||||
|
||||
vec2 dist = _pos - _origin;
|
||||
|
||||
float distortion = snoise(vec2(
|
||||
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
|
||||
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
|
||||
)) * 0.5 + 0.5;
|
||||
|
||||
float feather = 0.01 + SPREAD * pow(distortion, FOCUS);
|
||||
|
||||
return 1.0 - smoothstep(
|
||||
_radius - (_radius * feather),
|
||||
_radius + (_radius * feather),
|
||||
dot( dist, dist ) * 4.0
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 green = vec3(1.0) - vec3(153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0);
|
||||
vec3 purple = vec3(1.0) - vec3(195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0);
|
||||
vec3 orange = vec3(1.0) - vec3(255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0);
|
||||
|
||||
float ratio = u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv = vec2(in_uv.x, in_uv.y / ratio) * 0.5 + 0.5;
|
||||
|
||||
vec3 color = vec3(0.0);
|
||||
|
||||
float greenMix = snoise(in_uv * 1.31 + u_time * 0.8 * 0.00017) * 0.5 + 0.5;
|
||||
float purpleMix = snoise(in_uv * 1.26 + u_time * 0.8 * -0.0001) * 0.5 + 0.5;
|
||||
float orangeMix = snoise(in_uv * 1.34 + u_time * 0.8 * 0.00015) * 0.5 + 0.5;
|
||||
|
||||
float alphaOne = 0.35 + 0.65 * pow(snoise(vec2(u_time * 0.00012, uv.x)) * 0.5 + 0.5, 1.2);
|
||||
float alphaTwo = 0.35 + 0.65 * pow(snoise(vec2((u_time + 1561.0) * 0.00014, uv.x )) * 0.5 + 0.5, 1.2);
|
||||
float alphaThree = 0.35 + 0.65 * pow(snoise(vec2((u_time + 3917.0) * 0.00013, uv.x )) * 0.5 + 0.5, 1.2);
|
||||
|
||||
color += vec3(circle(uv, vec2(0.22 + sin(u_time * 0.000201) * 0.06, 0.80 + cos(u_time * 0.000151) * 0.06), 0.15)) * alphaOne * (purple * purpleMix + orange * orangeMix);
|
||||
color += vec3(circle(uv, vec2(0.90 + cos(u_time * 0.000166) * 0.06, 0.42 + sin(u_time * 0.000138) * 0.06), 0.18)) * alphaTwo * (green * greenMix + purple * purpleMix);
|
||||
color += vec3(circle(uv, vec2(0.19 + sin(u_time * 0.000112) * 0.06, 0.25 + sin(u_time * 0.000192) * 0.06), 0.09)) * alphaThree * (orange * orangeMix);
|
||||
|
||||
color *= u_itensity + 1.0 * pow(snoise(vec2(in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009)) * 0.5 + 0.5, 2.0);
|
||||
|
||||
vec3 inverted = vec3(1.0) - color;
|
||||
out_color = vec4(color, max(max(color.x, color.y), color.z));
|
||||
}
|
||||
15
packages/frontend/src/components/MkAnimBg.vertex.glsl
Normal file
15
packages/frontend/src/components/MkAnimBg.vertex.glsl
Normal file
@@ -0,0 +1,15 @@
|
||||
#version 300 es
|
||||
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
in vec2 position;
|
||||
uniform vec2 u_scale;
|
||||
out vec2 in_uv;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
in_uv = position / u_scale;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import vertexShaderSource from './MkAnimBg.vertex.glsl';
|
||||
import fragmentShaderSource from './MkAnimBg.fragment.glsl';
|
||||
import { initShaderProgram } from '@/utility/webgl.js';
|
||||
|
||||
const canvasEl = useTemplateRef('canvasEl');
|
||||
|
||||
@@ -21,47 +24,6 @@ const props = withDefaults(defineProps<{
|
||||
focus: 1.0,
|
||||
});
|
||||
|
||||
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
|
||||
const shader = gl.createShader(type);
|
||||
if (shader == null) return null;
|
||||
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
alert(
|
||||
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
|
||||
);
|
||||
gl.deleteShader(shader);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shader;
|
||||
}
|
||||
|
||||
function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) {
|
||||
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
|
||||
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
|
||||
|
||||
const shaderProgram = gl.createProgram();
|
||||
if (shaderProgram == null || vertexShader == null || fragmentShader == null) return null;
|
||||
|
||||
gl.attachShader(shaderProgram, vertexShader);
|
||||
gl.attachShader(shaderProgram, fragmentShader);
|
||||
gl.linkProgram(shaderProgram);
|
||||
|
||||
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
|
||||
alert(
|
||||
`failed to init shader: ${gl.getProgramInfoLog(
|
||||
shaderProgram,
|
||||
)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return shaderProgram;
|
||||
}
|
||||
|
||||
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
@@ -71,8 +33,10 @@ onMounted(() => {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const gl = canvas.getContext('webgl', { premultipliedAlpha: true });
|
||||
if (gl == null) return;
|
||||
const maybeGl = canvas.getContext('webgl2', { premultipliedAlpha: true });
|
||||
if (maybeGl == null) return;
|
||||
|
||||
const gl = maybeGl;
|
||||
|
||||
gl.clearColor(0.0, 0.0, 0.0, 0.0);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||
@@ -80,128 +44,7 @@ onMounted(() => {
|
||||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
|
||||
const shaderProgram = initShaderProgram(gl, `
|
||||
attribute vec2 vertex;
|
||||
|
||||
uniform vec2 u_scale;
|
||||
|
||||
varying vec2 v_pos;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(vertex, 0.0, 1.0);
|
||||
v_pos = vertex / u_scale;
|
||||
}
|
||||
`, `
|
||||
precision mediump float;
|
||||
|
||||
vec3 mod289(vec3 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec2 mod289(vec2 x) {
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec3 permute(vec3 x) {
|
||||
return mod289(((x*34.0)+1.0)*x);
|
||||
}
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439);
|
||||
|
||||
vec2 i = floor(v + dot(v, C.yy) );
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
|
||||
vec2 i1;
|
||||
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
|
||||
i = mod289(i);
|
||||
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 ))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0 ));
|
||||
|
||||
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
|
||||
m = m*m ;
|
||||
m = m*m ;
|
||||
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
|
||||
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
|
||||
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
|
||||
uniform float u_time;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_spread;
|
||||
uniform float u_speed;
|
||||
uniform float u_warp;
|
||||
uniform float u_focus;
|
||||
uniform float u_itensity;
|
||||
|
||||
varying vec2 v_pos;
|
||||
|
||||
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
|
||||
float SPREAD = 0.7 * u_spread;
|
||||
float SPEED = 0.00055 * u_speed;
|
||||
float WARP = 1.5 * u_warp;
|
||||
float FOCUS = 1.15 * u_focus;
|
||||
|
||||
vec2 dist = _pos - _origin;
|
||||
|
||||
float distortion = snoise( vec2(
|
||||
_pos.x * 1.587 * WARP + u_time * SPEED * 0.5,
|
||||
_pos.y * 1.192 * WARP + u_time * SPEED * 0.3
|
||||
) ) * 0.5 + 0.5;
|
||||
|
||||
float feather = 0.01 + SPREAD * pow( distortion, FOCUS );
|
||||
|
||||
return 1.0 - smoothstep(
|
||||
_radius - ( _radius * feather ),
|
||||
_radius + ( _radius * feather ),
|
||||
dot( dist, dist ) * 4.0
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec3 green = vec3( 1.0 ) - vec3( 153.0 / 255.0, 211.0 / 255.0, 221.0 / 255.0 );
|
||||
vec3 purple = vec3( 1.0 ) - vec3( 195.0 / 255.0, 165.0 / 255.0, 242.0 / 255.0 );
|
||||
vec3 orange = vec3( 1.0 ) - vec3( 255.0 / 255.0, 156.0 / 255.0, 136.0 / 255.0 );
|
||||
|
||||
float ratio = u_resolution.x / u_resolution.y;
|
||||
|
||||
vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5;
|
||||
|
||||
vec3 color = vec3( 0.0 );
|
||||
|
||||
float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
|
||||
float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
|
||||
float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
|
||||
|
||||
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
float alphaThree = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 3917.0 ) * 0.00013, uv.x ) ) * 0.5 + 0.5, 1.2 );
|
||||
|
||||
color += vec3( circle( uv, vec2( 0.22 + sin( u_time * 0.000201 ) * 0.06, 0.80 + cos( u_time * 0.000151 ) * 0.06 ), 0.15 ) ) * alphaOne * ( purple * purpleMix + orange * orangeMix );
|
||||
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
|
||||
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
|
||||
|
||||
color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
|
||||
|
||||
vec3 inverted = vec3( 1.0 ) - color;
|
||||
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) );
|
||||
}
|
||||
`);
|
||||
const shaderProgram = initShaderProgram(gl, vertexShaderSource, fragmentShaderSource);
|
||||
if (shaderProgram == null) return;
|
||||
|
||||
gl.useProgram(shaderProgram);
|
||||
@@ -221,7 +64,7 @@ onMounted(() => {
|
||||
gl.uniform1f(u_itensity, 0.5);
|
||||
gl.uniform2fv(u_scale, [props.scale, props.scale]);
|
||||
|
||||
const vertex = gl.getAttribLocation(shaderProgram, 'vertex');
|
||||
const vertex = gl.getAttribLocation(shaderProgram, 'position');
|
||||
gl.enableVertexAttribArray(vertex);
|
||||
gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
@@ -229,8 +72,8 @@ onMounted(() => {
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.DYNAMIC_DRAW);
|
||||
|
||||
if (isChromatic()) {
|
||||
gl!.uniform1f(u_time, 0);
|
||||
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
|
||||
gl.uniform1f(u_time, 0);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
} else {
|
||||
function render(timeStamp: number) {
|
||||
let sizeChanged = false;
|
||||
@@ -249,8 +92,8 @@ onMounted(() => {
|
||||
gl.viewport(0, 0, width, height);
|
||||
}
|
||||
|
||||
gl!.uniform1f(u_time, timeStamp);
|
||||
gl!.drawArrays(gl!.TRIANGLE_STRIP, 0, 4);
|
||||
gl.uniform1f(u_time, timeStamp);
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
||||
|
||||
handle = window.requestAnimationFrame(render);
|
||||
}
|
||||
@@ -263,6 +106,8 @@ onUnmounted(() => {
|
||||
if (handle) {
|
||||
window.cancelAnimationFrame(handle);
|
||||
}
|
||||
|
||||
// TODO: WebGLリソースの解放
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
||||
@@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModal ref="modal" :zPriority="'middle'" @closed="$emit('closed')" @click="onBgClick">
|
||||
<MkModal ref="modal" :zPriority="'middle'" :preferType="'dialog'" @closed="emit('closed')" @click="onBgClick">
|
||||
<div ref="rootEl" :class="$style.root">
|
||||
<div :class="$style.header">
|
||||
<span :class="$style.icon">
|
||||
@@ -16,13 +16,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<span :class="$style.title">{{ announcement.title }}</span>
|
||||
</div>
|
||||
<div :class="$style.text"><Mfm :text="announcement.text"/></div>
|
||||
<MkButton primary full @click="ok">{{ i18n.ts.ok }}</MkButton>
|
||||
<div ref="bottomEl"></div>
|
||||
<div :class="$style.footer">
|
||||
<MkButton
|
||||
primary
|
||||
full
|
||||
:disabled="!hasReachedBottom"
|
||||
@click="ok"
|
||||
>{{ hasReachedBottom ? i18n.ts.close : i18n.ts.scrollToClose }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, useTemplateRef } from 'vue';
|
||||
import { onMounted, ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
@@ -32,12 +40,16 @@ import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { updateCurrentAccountPartial } from '@/accounts.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
const props = defineProps<{
|
||||
announcement: Misskey.entities.Announcement;
|
||||
}>(), {
|
||||
});
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const bottomEl = useTemplateRef('bottomEl');
|
||||
const modal = useTemplateRef('modal');
|
||||
|
||||
async function ok() {
|
||||
@@ -72,7 +84,34 @@ function onBgClick() {
|
||||
});
|
||||
}
|
||||
|
||||
const hasReachedBottom = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (bottomEl.value && rootEl.value) {
|
||||
const bottomElRect = bottomEl.value.getBoundingClientRect();
|
||||
const rootElRect = rootEl.value.getBoundingClientRect();
|
||||
if (
|
||||
bottomElRect.top >= rootElRect.top &&
|
||||
bottomElRect.top <= (rootElRect.bottom - 66) // 66 ≒ 75 * 0.9 (modalのアニメーション分)
|
||||
) {
|
||||
hasReachedBottom.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
hasReachedBottom.value = true;
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: rootEl.value,
|
||||
rootMargin: '0px 0px -75px 0px',
|
||||
});
|
||||
|
||||
observer.observe(bottomEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -80,9 +119,12 @@ onMounted(() => {
|
||||
.root {
|
||||
margin: auto;
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
padding: 32px 32px 0;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: var(--MI-radius);
|
||||
@@ -103,4 +145,14 @@ onMounted(() => {
|
||||
.text {
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: -32px;
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
background: color(from var(--MI_THEME-bg) srgb r g b / 0.5);
|
||||
margin: 0 -32px;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
||||
@@ -4,23 +4,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 700px;">
|
||||
<div>
|
||||
<div class="_gaps_m">
|
||||
<MkInput v-model="name">
|
||||
<template #label>{{ i18n.ts.name }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-model="src">
|
||||
<MkSelect v-model="src" :items="antennaSourcesSelectDef">
|
||||
<template #label>{{ i18n.ts.antennaSource }}</template>
|
||||
<option value="all">{{ i18n.ts._antennaSources.all }}</option>
|
||||
<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
|
||||
<option value="users">{{ i18n.ts._antennaSources.users }}</option>
|
||||
<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
|
||||
<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
|
||||
</MkSelect>
|
||||
<MkSelect v-if="src === 'list'" v-model="userListId">
|
||||
<MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef">
|
||||
<template #label>{{ i18n.ts.userList }}</template>
|
||||
<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
|
||||
</MkSelect>
|
||||
<MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
|
||||
<template #label>{{ i18n.ts.users }}</template>
|
||||
@@ -48,11 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref } from 'vue';
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { DeepPartial } from '@/utility/merge.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
@@ -64,6 +58,7 @@ import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { deepMerge } from '@/utility/merge.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
|
||||
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
|
||||
id?: string;
|
||||
@@ -99,9 +94,35 @@ const emit = defineEmits<{
|
||||
(ev: 'deleted'): void,
|
||||
}>();
|
||||
|
||||
const {
|
||||
model: src,
|
||||
def: antennaSourcesSelectDef,
|
||||
} = useMkSelect({
|
||||
items: [
|
||||
{ value: 'all', label: i18n.ts._antennaSources.all },
|
||||
//{ value: 'home', label: i18n.ts._antennaSources.homeTimeline },
|
||||
{ value: 'users', label: i18n.ts._antennaSources.users },
|
||||
//{ value: 'list', label: i18n.ts._antennaSources.userList },
|
||||
{ value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist },
|
||||
],
|
||||
initialValue: initialAntenna.src,
|
||||
});
|
||||
|
||||
const {
|
||||
model: userListId,
|
||||
def: userListsSelectDef,
|
||||
} = useMkSelect({
|
||||
items: computed(() => {
|
||||
if (userLists.value == null) return [];
|
||||
return userLists.value.map(list => ({
|
||||
value: list.id,
|
||||
label: list.name,
|
||||
}));
|
||||
}),
|
||||
initialValue: initialAntenna.userListId,
|
||||
});
|
||||
|
||||
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'));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
||||
@@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate">
|
||||
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate">
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
|
||||
</MkSelect>
|
||||
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
|
||||
<div v-else-if="c.type === 'postForm'" :class="$style.postForm">
|
||||
@@ -65,15 +64,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkPostForm from '@/components/MkPostForm.vue';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
component: AsUiComponent;
|
||||
@@ -106,7 +106,7 @@ const containerStyle = computed(() => {
|
||||
const isBordered = c.borderWidth ?? c.borderColor ?? c.borderStyle;
|
||||
|
||||
const border = isBordered ? {
|
||||
borderWidth: c.borderWidth ?? '1px',
|
||||
borderWidth: `${c.borderWidth ?? 1}px`,
|
||||
borderColor: c.borderColor ?? 'var(--MI_THEME-divider)',
|
||||
borderStyle: c.borderStyle ?? 'solid',
|
||||
} : undefined;
|
||||
@@ -130,9 +130,21 @@ function onSwitchUpdate(v: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null);
|
||||
const {
|
||||
model: valueForSelect,
|
||||
def: selectDef,
|
||||
} = useMkSelect({
|
||||
items: computed(() => {
|
||||
if (c.type !== 'select') return [];
|
||||
return (c.items ?? []).map(item => ({
|
||||
value: item.value,
|
||||
label: item.text,
|
||||
}));
|
||||
}),
|
||||
initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null,
|
||||
});
|
||||
|
||||
function onSelectUpdate(v) {
|
||||
function onSelectUpdate(v: string | null) {
|
||||
valueForSelect.value = v;
|
||||
if ('onChange' in c && c.onChange) {
|
||||
c.onChange(v as never);
|
||||
|
||||
@@ -167,9 +167,13 @@ async function init() {
|
||||
for (const user of usersRes) {
|
||||
if (users.value.has(user.id)) continue;
|
||||
|
||||
const account = accounts.find(a => a.id === user.id);
|
||||
|
||||
if (!account || account.token == null) continue;
|
||||
|
||||
users.value.set(user.id, {
|
||||
...user,
|
||||
token: accounts.find(a => a.id === user.id)!.token,
|
||||
token: account.token,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -179,7 +183,7 @@ async function init() {
|
||||
|
||||
init();
|
||||
|
||||
function clickAddAccount(ev: MouseEvent) {
|
||||
function clickAddAccount(ev: PointerEvent) {
|
||||
selectedUser.value = null;
|
||||
|
||||
os.popupMenu([{
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { userDetailed } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkAutocomplete from './MkAutocomplete.vue';
|
||||
import MkInput from './MkInput.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { tick } from '@/utility/test-utils.js';
|
||||
const common = {
|
||||
render(args) {
|
||||
@@ -81,7 +80,7 @@ export const User = {
|
||||
...common.args,
|
||||
type: 'user',
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
async play({ canvasElement }: { canvasElement: HTMLElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('combobox');
|
||||
await waitFor(() => userEvent.hover(input));
|
||||
@@ -114,7 +113,7 @@ export const Hashtag = {
|
||||
...common.args,
|
||||
type: 'hashtag',
|
||||
},
|
||||
async play({ canvasElement }) {
|
||||
async play({ canvasElement }: { canvasElement: HTMLElement }) {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('combobox');
|
||||
await waitFor(() => userEvent.hover(input));
|
||||
|
||||
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkCustomEmoji v-if="'isCustomEmoji' in emoji && emoji.isCustomEmoji" :name="emoji.emoji" :class="$style.emoji" :fallbackToImage="true"/>
|
||||
<MkEmoji v-else :emoji="emoji.emoji" :class="$style.emoji"/>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-if="q" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
|
||||
<span v-if="q != null && typeof q === 'string'" :class="$style.emojiName" v-html="sanitizeHtml(emoji.name.replace(q, `<b>${q}</b>`))"></span>
|
||||
<span v-else v-text="emoji.name"></span>
|
||||
<span v-if="emoji.aliasOf" :class="$style.emojiAlias">({{ emoji.aliasOf }})</span>
|
||||
</li>
|
||||
@@ -36,7 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="type === 'mfmParam' && mfmParams.length > 0" ref="suggests" :class="$style.list">
|
||||
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="complete(type, q.params.toSpliced(-1, 1, param).join(','))" @keydown="onKeydown">
|
||||
<li v-for="param in mfmParams" tabindex="-1" :class="$style.item" @click="completeMfmParam(param)" @keydown="onKeydown">
|
||||
<span>{{ param }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
@@ -45,12 +45,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts">
|
||||
import { markRaw, ref, useTemplateRef, computed, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { emojilist, getEmojiName } from '@@/js/emojilist.js';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@@/js/emoji-base.js';
|
||||
import { MFM_TAGS, MFM_PARAMS } from '@@/js/const.js';
|
||||
import type { EmojiDef } from '@/utility/search-emoji.js';
|
||||
import contains from '@/utility/contains.js';
|
||||
import { elementContains } from '@/utility/element-contains.js';
|
||||
import { acct } from '@/filters/user.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
@@ -63,7 +64,7 @@ import { prefer } from '@/preferences.js';
|
||||
|
||||
export type CompleteInfo = {
|
||||
user: {
|
||||
payload: any;
|
||||
payload: Misskey.entities.User;
|
||||
query: string | null;
|
||||
},
|
||||
hashtag: {
|
||||
@@ -185,22 +186,27 @@ const suggests = ref<Element>();
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
|
||||
const fetching = ref(true);
|
||||
const users = ref<any[]>([]);
|
||||
const hashtags = ref<any[]>([]);
|
||||
const emojis = ref<(EmojiDef)[]>([]);
|
||||
const users = ref<Misskey.entities.User[]>([]);
|
||||
const hashtags = ref<string[]>([]);
|
||||
const emojis = ref<EmojiDef[]>([]);
|
||||
const items = ref<Element[] | HTMLCollection>([]);
|
||||
const mfmTags = ref<string[]>([]);
|
||||
const mfmParams = ref<string[]>([]);
|
||||
const select = ref(-1);
|
||||
const zIndex = os.claimZIndex('high');
|
||||
|
||||
function completeMfmParam(param: string) {
|
||||
if (props.type !== 'mfmParam') throw new Error('Invalid type');
|
||||
complete('mfmParam', props.q.params.toSpliced(-1, 1, param).join(','));
|
||||
}
|
||||
|
||||
function complete<T extends keyof CompleteInfo>(type: T, value: CompleteInfo[T]['payload']) {
|
||||
emit('done', { type, value });
|
||||
emit('closed');
|
||||
if (type === 'emoji' || type === 'emojiComplete') {
|
||||
let recents = store.s.recentlyUsedEmojis;
|
||||
recents = recents.filter((emoji: any) => emoji !== value);
|
||||
recents.unshift(value);
|
||||
recents = recents.filter((emoji) => emoji !== value);
|
||||
recents.unshift(value as string);
|
||||
store.set('recentlyUsedEmojis', recents.splice(0, 32));
|
||||
}
|
||||
}
|
||||
@@ -249,7 +255,7 @@ function exec() {
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(searchedUsers => {
|
||||
users.value = searchedUsers as any[];
|
||||
users.value = searchedUsers;
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
|
||||
@@ -271,7 +277,7 @@ function exec() {
|
||||
query: props.q,
|
||||
limit: 30,
|
||||
}).then(searchedHashtags => {
|
||||
hashtags.value = searchedHashtags as any[];
|
||||
hashtags.value = searchedHashtags;
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
|
||||
@@ -305,8 +311,8 @@ function exec() {
|
||||
}
|
||||
}
|
||||
|
||||
function onMousedown(event: Event) {
|
||||
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
|
||||
function onMousedown(event: MouseEvent) {
|
||||
if (!elementContains(rootEl.value, event.target as Element) && (rootEl.value !== event.target)) props.close();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
|
||||
@@ -29,6 +29,6 @@ const users = ref<Misskey.entities.UserLite[]>([]);
|
||||
onMounted(async () => {
|
||||
users.value = await misskeyApi('users/show', {
|
||||
userIds: props.userIds,
|
||||
}) as unknown as Misskey.entities.UserLite[];
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import MkButton from './MkButton.vue';
|
||||
export const Default = {
|
||||
|
||||
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<button
|
||||
v-if="!link"
|
||||
ref="el" class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait, [$style.active]: active }]"
|
||||
:type="type"
|
||||
:name="name"
|
||||
:value="value"
|
||||
@@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</button>
|
||||
<MkA
|
||||
v-else class="_button"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait }]"
|
||||
:class="[$style.root, { [$style.inline]: inline, [$style.primary]: primary, [$style.gradate]: gradate, [$style.danger]: danger, [$style.rounded]: rounded, [$style.full]: full, [$style.small]: small, [$style.large]: large, [$style.transparent]: transparent, [$style.asLike]: asLike, [$style.iconOnly]: iconOnly, [$style.wait]: wait, [$style.active]: active }]"
|
||||
:to="to ?? '#'"
|
||||
:behavior="linkBehavior"
|
||||
@mousedown="onMousedown"
|
||||
@@ -36,6 +36,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, useTemplateRef } from 'vue';
|
||||
import type { MkABehavior } from '@/components/global/MkA.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
@@ -45,7 +46,7 @@ const props = defineProps<{
|
||||
inline?: boolean;
|
||||
link?: boolean;
|
||||
to?: string;
|
||||
linkBehavior?: null | 'window' | 'browser';
|
||||
linkBehavior?: MkABehavior;
|
||||
autofocus?: boolean;
|
||||
wait?: boolean;
|
||||
danger?: boolean;
|
||||
@@ -58,10 +59,11 @@ const props = defineProps<{
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
iconOnly?: boolean;
|
||||
active?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'click', payload: MouseEvent): void;
|
||||
(ev: 'click', payload: PointerEvent): void;
|
||||
}>();
|
||||
|
||||
const el = useTemplateRef('el');
|
||||
@@ -75,11 +77,11 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function distance(p, q): number {
|
||||
function distance(p: { x: number; y: number }, q: { x: number; y: number }): number {
|
||||
return Math.hypot(p.x - q.x, p.y - q.y);
|
||||
}
|
||||
|
||||
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
|
||||
function calcCircleScale(boxW: number, boxH: number, circleCenterX: number, circleCenterY: number): number {
|
||||
const origin = { x: circleCenterX, y: circleCenterY };
|
||||
const dist1 = distance({ x: 0, y: 0 }, origin);
|
||||
const dist2 = distance({ x: boxW, y: 0 }, origin);
|
||||
@@ -252,6 +254,10 @@ function onMousedown(evt: MouseEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: var(--MI_THEME-accent) !important;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const variable = computed(() => {
|
||||
}
|
||||
});
|
||||
|
||||
const loaded = !!window[variable.value];
|
||||
const loaded = !!(window as any)[variable.value];
|
||||
|
||||
const src = computed(() => {
|
||||
switch (props.provider) {
|
||||
@@ -98,7 +98,7 @@ const src = computed(() => {
|
||||
|
||||
const scriptId = computed(() => `script-${props.provider}`);
|
||||
|
||||
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
|
||||
const captcha = computed<Captcha>(() => (window as any)[variable.value] ?? {} as unknown as Captcha);
|
||||
|
||||
watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
|
||||
// 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { channel } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkChannelList from './MkChannelList.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { Paginator } from '@/utility/paginator.js';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
@@ -33,10 +32,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
args: {
|
||||
pagination: {
|
||||
endpoint: 'channels/search',
|
||||
limit: 10,
|
||||
},
|
||||
paginator: new Paginator('channels/search', {}),
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
|
||||
@@ -4,13 +4,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkPagination :pagination="pagination">
|
||||
<template #empty>
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" draggable="false"/>
|
||||
<div>{{ i18n.ts.notFound }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<MkPagination :paginator="paginator">
|
||||
<template #empty><MkResult type="empty"/></template>
|
||||
|
||||
<template #default="{ items }">
|
||||
<MkChannelPreview v-for="item in items" :key="item.id" class="_margin" :channel="extractor(item)"/>
|
||||
@@ -18,18 +13,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkPagination>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
<script lang="ts" setup generic="P extends IPaginator">
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { IPaginator, ExtractorFunction } from '@/utility/paginator.js';
|
||||
import MkChannelPreview from '@/components/MkChannelPreview.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
pagination: Paging;
|
||||
paginator: P;
|
||||
noGap?: boolean;
|
||||
extractor?: (item: any) => any;
|
||||
extractor?: ExtractorFunction<P, Misskey.entities.Channel>;
|
||||
}>(), {
|
||||
extractor: (item) => item,
|
||||
extractor: (item: any) => item as Misskey.entities.Channel,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -27,6 +27,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-if="$i != null && $i.id === channel.userId" style="color: var(--MI_THEME-warn)">
|
||||
<i class="ti ti-user-star ti-fw"></i>
|
||||
<span style="margin-left: 4px;">{{ i18n.ts.youAreAdmin }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article v-if="channel.description">
|
||||
@@ -48,6 +52,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ import { Chart } from 'chart.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApiGet } from '@/utility/misskey-api.js';
|
||||
import { store } from '@/store.js';
|
||||
import { useChartTooltip } from '@/use/use-chart-tooltip.js';
|
||||
import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
|
||||
import { chartVLine } from '@/utility/chart-vline.js';
|
||||
import { alpha } from '@/utility/color.js';
|
||||
import date from '@/filters/date.js';
|
||||
@@ -94,8 +94,8 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const legendEl = useTemplateRef('legendEl');
|
||||
|
||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = arr => arr.map(x => -x);
|
||||
const sum = (...arr: number[][]) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = (arr: number[]) => arr.map((x) => -x);
|
||||
|
||||
const colors = {
|
||||
blue: '#008FFB',
|
||||
@@ -108,7 +108,7 @@ const colors = {
|
||||
cyan: '#00e0e0',
|
||||
};
|
||||
const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
|
||||
const getColor = (i) => {
|
||||
const getColor = (i: number) => {
|
||||
return colorSets[i % colorSets.length];
|
||||
};
|
||||
|
||||
@@ -142,7 +142,7 @@ const getDate = (ago: number) => {
|
||||
return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
|
||||
};
|
||||
|
||||
const format = (arr) => {
|
||||
const format = (arr: number[]) => {
|
||||
return arr.map((v, i) => ({
|
||||
x: getDate(i).getTime(),
|
||||
y: v,
|
||||
@@ -371,7 +371,7 @@ const fetchApRequestChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
};
|
||||
|
||||
const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
|
||||
const fetchNotesChart = async (type: 'local' | 'remote' | 'combined'): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/notes', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
@@ -589,7 +589,10 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
|
||||
const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
|
||||
const host = props.args?.host;
|
||||
if (host == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'In',
|
||||
@@ -611,7 +614,10 @@ const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
|
||||
const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
|
||||
const host = props.args?.host;
|
||||
if (host == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Users',
|
||||
@@ -626,7 +632,10 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData
|
||||
};
|
||||
|
||||
const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
|
||||
const host = props.args?.host;
|
||||
if (host == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Notes',
|
||||
@@ -641,7 +650,10 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData
|
||||
};
|
||||
|
||||
const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
|
||||
const host = props.args?.host;
|
||||
if (host == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Following',
|
||||
@@ -664,7 +676,10 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> =
|
||||
};
|
||||
|
||||
const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
|
||||
const host = props.args?.host;
|
||||
if (host == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
@@ -680,7 +695,10 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char
|
||||
};
|
||||
|
||||
const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/instance', { host: props.args?.host, limit: props.limit, span: props.span });
|
||||
const host = props.args?.host;
|
||||
if (host == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/instance', { host: host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Drive files',
|
||||
@@ -695,7 +713,10 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char
|
||||
};
|
||||
|
||||
const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/user/notes', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
|
||||
const userId = props.args?.user?.id;
|
||||
if (userId == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/user/notes', { userId: userId, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [...(props.args?.withoutAll ? [] : [{
|
||||
name: 'All',
|
||||
@@ -727,7 +748,10 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
|
||||
const fetchPerUserPvChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/user/pv', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
|
||||
const userId = props.args?.user?.id;
|
||||
if (userId == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/user/pv', { userId: userId, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Unique PV (user)',
|
||||
@@ -754,7 +778,10 @@ const fetchPerUserPvChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
|
||||
const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
|
||||
const userId = props.args?.user?.id;
|
||||
if (userId == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Local',
|
||||
@@ -769,7 +796,10 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
|
||||
const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/user/following', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
|
||||
const userId = props.args?.user?.id;
|
||||
if (userId == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/user/following', { userId: userId, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Local',
|
||||
@@ -784,7 +814,10 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
|
||||
};
|
||||
|
||||
const fetchPerUserDriveChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await misskeyApiGet('charts/user/drive', { userId: props.args?.user?.id, limit: props.limit, span: props.span });
|
||||
const userId = props.args?.user?.id;
|
||||
if (userId == null) return { series: [] };
|
||||
|
||||
const raw = await misskeyApiGet('charts/user/drive', { userId: userId, limit: props.limit, span: props.span });
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
|
||||
@@ -25,12 +25,12 @@ defineProps<{
|
||||
showing: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
title?: string;
|
||||
title?: string | null;
|
||||
series?: {
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
text: string;
|
||||
}[];
|
||||
}[] | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { chatMessage } from '../../.storybook/fakes';
|
||||
import MkChatHistories from './MkChatHistories.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
|
||||
@@ -28,9 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</MkA>
|
||||
</div>
|
||||
<div v-if="!initializing && history.length == 0" class="_fullinfo">
|
||||
<div>{{ i18n.ts._chat.noHistory }}</div>
|
||||
</div>
|
||||
<MkResult v-if="!initializing && history.length == 0" type="empty" :text="i18n.ts._chat.noHistory"/>
|
||||
<MkLoading v-if="initializing"/>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkClickerGame from './MkClickerGame.vue';
|
||||
|
||||
@@ -20,9 +20,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import MkPlusOneEffect from '@/components/MkPlusOneEffect.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { useInterval } from '@@/js/use-interval.js';
|
||||
import * as game from '@/utility/clicker-game.js';
|
||||
import number from '@/filters/number.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
@@ -32,7 +32,7 @@ const cookies = computed(() => saveData.value?.cookies);
|
||||
const cps = ref(0);
|
||||
const prevCookies = ref(0);
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
function onClick(ev: PointerEvent) {
|
||||
const x = ev.clientX;
|
||||
const y = ev.clientY;
|
||||
const { dispose } = os.popup(MkPlusOneEffect, { x, y }, {
|
||||
|
||||
@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div :class="[$style.codeBlockRoot, { [$style.codeEditor]: codeEditor }, (darkMode ? $style.dark : $style.light)]" v-html="html"></div>
|
||||
<div
|
||||
:class="[$style.codeBlockRoot, {
|
||||
[$style.codeEditor]: codeEditor,
|
||||
[$style.outerStyle]: !codeEditor && withOuterStyle,
|
||||
[$style.dark]: darkMode,
|
||||
[$style.light]: !darkMode,
|
||||
}]" v-html="html"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -15,11 +21,15 @@ import type { BundledLanguage } from 'shiki/langs';
|
||||
import { getHighlighter, getTheme } from '@/utility/code-highlighter.js';
|
||||
import { store } from '@/store.js';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
code: string;
|
||||
lang?: string;
|
||||
codeEditor?: boolean;
|
||||
}>();
|
||||
withOuterStyle?: boolean;
|
||||
}>(), {
|
||||
codeEditor: false,
|
||||
withOuterStyle: true,
|
||||
});
|
||||
|
||||
const highlighter = await getHighlighter();
|
||||
const darkMode = store.r.darkMode;
|
||||
@@ -73,17 +83,13 @@ watch(() => props.lang, (to) => {
|
||||
|
||||
<style module lang="scss">
|
||||
.codeBlockRoot :global(.shiki) {
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
|
||||
|
||||
color: var(--shiki-fallback);
|
||||
background-color: var(--shiki-fallback-bg);
|
||||
|
||||
& span {
|
||||
color: var(--shiki-fallback);
|
||||
background-color: var(--shiki-fallback-bg);
|
||||
}
|
||||
|
||||
& pre,
|
||||
@@ -92,26 +98,40 @@ watch(() => props.lang, (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
.outerStyle.codeBlockRoot :global(.shiki) {
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
background-color: var(--shiki-fallback-bg);
|
||||
}
|
||||
|
||||
.light.codeBlockRoot :global(.shiki) {
|
||||
color: var(--shiki-light);
|
||||
background-color: var(--shiki-light-bg);
|
||||
|
||||
& span {
|
||||
color: var(--shiki-light);
|
||||
background-color: var(--shiki-light-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.light.outerStyle.codeBlockRoot :global(.shiki),
|
||||
.light.codeEditor.codeBlockRoot :global(.shiki) {
|
||||
background-color: var(--shiki-light-bg);
|
||||
}
|
||||
|
||||
.dark.codeBlockRoot :global(.shiki) {
|
||||
color: var(--shiki-dark);
|
||||
background-color: var(--shiki-dark-bg);
|
||||
|
||||
& span {
|
||||
color: var(--shiki-dark);
|
||||
background-color: var(--shiki-dark-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.dark.outerStyle.codeBlockRoot :global(.shiki),
|
||||
.dark.codeEditor.codeBlockRoot :global(.shiki) {
|
||||
background-color: var(--shiki-dark-bg);
|
||||
}
|
||||
|
||||
.codeBlockRoot.codeEditor {
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -5,15 +5,32 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div :class="$style.codeBlockRoot">
|
||||
<button v-if="copyButton" :class="$style.codeBlockCopyButton" class="_button" @click="copy">
|
||||
<button v-if="copyButton" :class="[$style.codeBlockCopyButton, { [$style.withOuterStyle]: withOuterStyle }]" class="_button" @click="copy">
|
||||
<i class="ti ti-copy"></i>
|
||||
</button>
|
||||
<Suspense>
|
||||
<template #fallback>
|
||||
<MkLoading/>
|
||||
<pre
|
||||
class="_selectable"
|
||||
:class="[$style.codeBlockFallbackRoot, {
|
||||
[$style.outerStyle]: withOuterStyle,
|
||||
}]"
|
||||
><code :class="$style.codeBlockFallbackCode">Loading...</code></pre>
|
||||
</template>
|
||||
<XCode v-if="show && lang" class="_selectable" :code="code" :lang="lang"/>
|
||||
<pre v-else-if="show" class="_selectable" :class="$style.codeBlockFallbackRoot"><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
|
||||
<XCode
|
||||
v-if="show && lang"
|
||||
class="_selectable"
|
||||
:code="code"
|
||||
:lang="lang"
|
||||
:withOuterStyle="withOuterStyle"
|
||||
/>
|
||||
<pre
|
||||
v-else-if="show"
|
||||
class="_selectable"
|
||||
:class="[$style.codeBlockFallbackRoot, {
|
||||
[$style.outerStyle]: withOuterStyle,
|
||||
}]"
|
||||
><code :class="$style.codeBlockFallbackCode">{{ code }}</code></pre>
|
||||
<button v-else :class="$style.codePlaceholderRoot" @click="show = true">
|
||||
<div :class="$style.codePlaceholderContainer">
|
||||
<div><i class="ti ti-code"></i> {{ i18n.ts.code }}</div>
|
||||
@@ -26,8 +43,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import * as os from '@/os.js';
|
||||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
@@ -36,10 +51,12 @@ const props = withDefaults(defineProps<{
|
||||
code: string;
|
||||
forceShow?: boolean;
|
||||
copyButton?: boolean;
|
||||
withOuterStyle?: boolean;
|
||||
lang?: string;
|
||||
}>(), {
|
||||
copyButton: true,
|
||||
forceShow: false,
|
||||
withOuterStyle: true,
|
||||
});
|
||||
|
||||
const show = ref(props.forceShow === true ? true : !prefer.s.dataSaver.code);
|
||||
@@ -58,10 +75,16 @@ function copy() {
|
||||
|
||||
.codeBlockCopyButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
opacity: 0.5;
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
&.withOuterStyle {
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -70,11 +93,17 @@ function copy() {
|
||||
.codeBlockFallbackRoot {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.outerStyle.codeBlockFallbackRoot {
|
||||
background: var(--MI_THEME-bg);
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.codeBlockFallbackCode {
|
||||
font-family: Consolas, Monaco, Andale Mono, Ubuntu Mono, monospace;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import MkCodeEditor from './MkCodeEditor.vue';
|
||||
const code = `for (let i, 100) {
|
||||
<: if (i % 15 == 0) "FizzBuzz"
|
||||
|
||||
@@ -40,7 +40,7 @@ import XCode from '@/components/MkCode.core.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string | null;
|
||||
lang: string;
|
||||
lang?: string;
|
||||
required?: boolean;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -51,7 +51,7 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'change', _ev: KeyboardEvent): void;
|
||||
(ev: 'change', _ev: InputEvent): void;
|
||||
(ev: 'keydown', _ev: KeyboardEvent): void;
|
||||
(ev: 'enter'): void;
|
||||
(ev: 'update:modelValue', value: string): void;
|
||||
@@ -63,15 +63,17 @@ const focused = ref(false);
|
||||
const changed = ref(false);
|
||||
const inputEl = useTemplateRef('inputEl');
|
||||
|
||||
const focus = () => inputEl.value?.focus();
|
||||
function focus() {
|
||||
inputEl.value?.focus();
|
||||
}
|
||||
|
||||
const onInput = (ev) => {
|
||||
v.value = ev.target?.value ?? v.value;
|
||||
function onInput(ev: InputEvent) {
|
||||
v.value = (inputEl.value?.value) ?? '';
|
||||
changed.value = true;
|
||||
emit('change', ev);
|
||||
};
|
||||
}
|
||||
|
||||
const onKeydown = (ev: KeyboardEvent) => {
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return;
|
||||
|
||||
emit('keydown', ev);
|
||||
@@ -102,12 +104,12 @@ const onKeydown = (ev: KeyboardEvent) => {
|
||||
});
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const updated = () => {
|
||||
function updated() {
|
||||
changed.value = false;
|
||||
emit('update:modelValue', v.value);
|
||||
};
|
||||
}
|
||||
|
||||
const debouncedUpdated = debounce(1000, updated);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import MkColorInput from './MkColorInput.vue';
|
||||
export const Default = {
|
||||
render(args) {
|
||||
|
||||
@@ -48,7 +48,6 @@ const props = withDefaults(defineProps<{
|
||||
thin?: boolean;
|
||||
naked?: boolean;
|
||||
foldable?: boolean;
|
||||
onUnfold?: () => boolean; // return false to prevent unfolding
|
||||
scrollable?: boolean;
|
||||
expanded?: boolean;
|
||||
maxHeight?: number | null;
|
||||
@@ -103,8 +102,6 @@ const omitObserver = new ResizeObserver((entries, observer) => {
|
||||
});
|
||||
|
||||
function showMore() {
|
||||
if (props.onUnfold && !props.onUnfold()) return;
|
||||
|
||||
ignoreOmit.value = true;
|
||||
omitted.value = false;
|
||||
}
|
||||
@@ -154,6 +151,10 @@ onUnmounted(() => {
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
> .content {
|
||||
background: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.scrollable {
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
import MkContextMenu from './MkContextMenu.vue';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import * as os from '@/os.js';
|
||||
export const Empty = {
|
||||
render(args) {
|
||||
@@ -25,7 +23,7 @@ export const Empty = {
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onContextmenu(ev: MouseEvent) {
|
||||
onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(args.items, ev);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
import { onMounted, onBeforeUnmount, useTemplateRef, ref } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import contains from '@/utility/contains.js';
|
||||
import { elementContains } from '@/utility/element-contains.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
ev: MouseEvent;
|
||||
ev: PointerEvent;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -75,8 +75,8 @@ onBeforeUnmount(() => {
|
||||
window.document.body.removeEventListener('mousedown', onMousedown);
|
||||
});
|
||||
|
||||
function onMousedown(evt: Event) {
|
||||
if (!contains(rootEl.value, evt.target) && (rootEl.value !== evt.target)) emit('closed');
|
||||
function onMousedown(evt: MouseEvent) {
|
||||
if (!elementContains(rootEl.value, evt.target as Element) && (rootEl.value !== evt.target)) emit('closed');
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkCropperDialog from './MkCropperDialog.vue';
|
||||
@@ -38,7 +38,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
args: {
|
||||
file: file(),
|
||||
imageFile: new File([], 'image.webp', { type: 'image/webp' }),
|
||||
aspectRatio: NaN,
|
||||
},
|
||||
parameters: {
|
||||
|
||||
@@ -15,132 +15,121 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.cropImage }}</template>
|
||||
<template #default="{ width, height }">
|
||||
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
|
||||
<Transition name="fade">
|
||||
<div v-if="loading" class="loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="container">
|
||||
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
||||
<div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
|
||||
<Transition name="fade">
|
||||
<div v-if="loading" class="loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="container">
|
||||
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, useTemplateRef, ref } from 'vue';
|
||||
<script lang="ts" setup generic="F extends File | Blob">
|
||||
import { onMounted, useTemplateRef, ref, onUnmounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import Cropper from 'cropperjs';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { apiUrl } from '@@/js/config.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { getProxiedImageUrl } from '@/utility/media-proxy.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
imageFile: F;
|
||||
aspectRatio: number | null;
|
||||
uploadFolder?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', cropped: Misskey.entities.DriveFile): void;
|
||||
(ev: 'ok', cropped: F): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
|
||||
const imgUrl = URL.createObjectURL(props.imageFile);
|
||||
const dialogEl = useTemplateRef('dialogEl');
|
||||
const imgEl = useTemplateRef('imgEl');
|
||||
let cropper: Cropper | null = null;
|
||||
const loading = ref(true);
|
||||
|
||||
const ok = async () => {
|
||||
const promise = new Promise<Misskey.entities.DriveFile>(async (res) => {
|
||||
const croppedImage = await cropper?.getCropperImage();
|
||||
const croppedSection = await cropper?.getCropperSelection();
|
||||
async function ok() {
|
||||
const promise = new Promise<Blob>(async (res) => {
|
||||
if (cropper == null) throw new Error('Cropper is not initialized');
|
||||
|
||||
const croppedImage = await cropper.getCropperImage()!;
|
||||
const croppedSection = await cropper.getCropperSelection()!;
|
||||
|
||||
// 拡大率を計算し、(ほぼ)元の大きさに戻す
|
||||
const zoomedRate = croppedImage.getBoundingClientRect().width / croppedImage.clientWidth;
|
||||
const widthToRender = croppedSection.getBoundingClientRect().width / zoomedRate;
|
||||
|
||||
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
|
||||
croppedCanvas?.toBlob(blob => {
|
||||
const croppedCanvas = await croppedSection.$toCanvas({ width: widthToRender });
|
||||
croppedCanvas.toBlob(blob => {
|
||||
if (!blob) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('name', `cropped_${props.file.name}`);
|
||||
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
|
||||
if (props.file.comment) { formData.append('comment', props.file.comment);}
|
||||
formData.append('i', $i!.token);
|
||||
if (props.uploadFolder) {
|
||||
formData.append('folderId', props.uploadFolder);
|
||||
} else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
|
||||
formData.append('folderId', prefer.s.uploadFolder);
|
||||
}
|
||||
|
||||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
res(f);
|
||||
});
|
||||
res(blob);
|
||||
});
|
||||
});
|
||||
|
||||
os.promiseDialog(promise);
|
||||
|
||||
const f = await promise;
|
||||
let finalFile: F;
|
||||
if (props.imageFile instanceof File) {
|
||||
finalFile = new File([f], props.imageFile.name, { type: f.type }) as F;
|
||||
} else {
|
||||
finalFile = f as F;
|
||||
}
|
||||
|
||||
emit('ok', f);
|
||||
dialogEl.value!.close();
|
||||
};
|
||||
emit('ok', finalFile);
|
||||
if (dialogEl.value != null) dialogEl.value.close();
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
function cancel() {
|
||||
emit('cancel');
|
||||
dialogEl.value!.close();
|
||||
};
|
||||
if (dialogEl.value != null) dialogEl.value.close();
|
||||
}
|
||||
|
||||
const onImageLoad = () => {
|
||||
function onImageLoad() {
|
||||
loading.value = false;
|
||||
|
||||
if (cropper) {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
cropper.getCropperSelection()!.$center();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
cropper = new Cropper(imgEl.value!, {
|
||||
if (imgEl.value == null) return; // TSを黙らすため
|
||||
|
||||
cropper = new Cropper(imgEl.value, {
|
||||
});
|
||||
|
||||
const computedStyle = getComputedStyle(window.document.documentElement);
|
||||
|
||||
const selection = cropper.getCropperSelection()!;
|
||||
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
|
||||
selection.aspectRatio = props.aspectRatio;
|
||||
selection.initialAspectRatio = props.aspectRatio;
|
||||
if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio;
|
||||
selection.initialAspectRatio = props.aspectRatio ?? 1;
|
||||
selection.outlined = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
cropper!.getCropperImage()!.$center('contain');
|
||||
if (cropper == null) return;
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 100);
|
||||
|
||||
// モーダルオープンアニメーションが終わったあとで再度調整
|
||||
window.setTimeout(() => {
|
||||
cropper!.getCropperImage()!.$center('contain');
|
||||
if (cropper == null) return;
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
URL.revokeObjectURL(imgUrl);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkModalWindow ref="dialogEl" @close="cancel()" @closed="emit('closed')">
|
||||
<template #header>:{{ emoji.name }}:</template>
|
||||
<template #default>
|
||||
<MkSpacer>
|
||||
<div class="_spacer">
|
||||
<div style="display: flex; flex-direction: column; gap: 1em;">
|
||||
<div :class="$style.emojiImgWrapper">
|
||||
<MkCustomEmoji :name="emoji.name" :normal="true" :useOriginalSize="true" style="height: 100%;"></MkCustomEmoji>
|
||||
@@ -50,7 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</template>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { expect, userEvent, within } from '@storybook/test';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
import MkCwButton from './MkCwButton.vue';
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<!-- TODO: 親からスタイルを当てにくいことや実装がトリッキーなことを鑑み廃止または使用の縮小(timeline-date-separate.tsを使う) -->
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, h, TransitionGroup, useCssModule } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { MisskeyEntity } from '@/types/date-separated-list.js';
|
||||
import MkAd from '@/components/global/MkAd.vue';
|
||||
import { isDebuggerEnabled, stackTraceInstances } from '@/debug.js';
|
||||
import * as os from '@/os.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getDateText } from '@/utility/timeline-date-separate.js';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<MisskeyEntity[]>,
|
||||
required: true,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'down',
|
||||
},
|
||||
reversed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
noGap: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
ad: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { slots, expose }) {
|
||||
const $style = useCssModule(); // カスタムレンダラなので使っても大丈夫
|
||||
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const renderChildrenImpl = () => props.items.map((item, i) => {
|
||||
if (!slots || !slots.default) return;
|
||||
|
||||
const el = slots.default({
|
||||
item: item,
|
||||
})[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 &&
|
||||
nextDate != null && (
|
||||
date.getFullYear() !== nextDate.getFullYear() ||
|
||||
date.getMonth() !== nextDate.getMonth() ||
|
||||
date.getDate() !== nextDate.getDate()
|
||||
)
|
||||
) {
|
||||
const separator = h('div', {
|
||||
class: $style['separator'],
|
||||
key: item.id + ':separator',
|
||||
}, h('p', {
|
||||
class: $style['date'],
|
||||
}, [
|
||||
h('span', {
|
||||
class: $style['date-1'],
|
||||
}, [
|
||||
h('i', {
|
||||
class: `ti ti-chevron-up ${$style['date-1-icon']}`,
|
||||
}),
|
||||
getDateText(date),
|
||||
]),
|
||||
h('span', {
|
||||
class: $style['date-2'],
|
||||
}, [
|
||||
getDateText(nextDate),
|
||||
h('i', {
|
||||
class: `ti ti-chevron-down ${$style['date-2-icon']}`,
|
||||
}),
|
||||
]),
|
||||
]));
|
||||
|
||||
return [el, separator];
|
||||
} else {
|
||||
if (props.ad && instance.ads.length > 0 && item._shouldInsertAd_) {
|
||||
return [h('div', {
|
||||
key: item.id + ':ad',
|
||||
class: $style['ad-wrapper'],
|
||||
}, [h(MkAd, {
|
||||
prefer: ['horizontal', 'horizontal-big'],
|
||||
})]), el];
|
||||
} else {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const renderChildren = () => {
|
||||
const children = renderChildrenImpl();
|
||||
if (isDebuggerEnabled(6864)) {
|
||||
const nodes = children.flatMap((node) => node ?? []);
|
||||
const keys = new Set(nodes.map((node) => node.key));
|
||||
if (keys.size !== nodes.length) {
|
||||
const id = crypto.randomUUID();
|
||||
const instances = stackTraceInstances();
|
||||
os.toast(instances.reduce((a, c) => `${a} at ${c.type.name}`, `[DEBUG_6864 (${id})]: ${nodes.length - keys.size} duplicated keys found`));
|
||||
console.warn({ id, debugId: 6864, stack: instances });
|
||||
}
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
function onBeforeLeave(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.top = `${el.offsetTop}px`;
|
||||
el.style.left = `${el.offsetLeft}px`;
|
||||
}
|
||||
|
||||
function onLeaveCancelled(el: Element) {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
el.style.top = '';
|
||||
el.style.left = '';
|
||||
}
|
||||
|
||||
// eslint-disable-next-line vue/no-setup-props-reactivity-loss
|
||||
const classes = {
|
||||
[$style['date-separated-list']]: true,
|
||||
[$style['date-separated-list-nogap']]: props.noGap,
|
||||
[$style['reversed']]: props.reversed,
|
||||
[$style['direction-down']]: props.direction === 'down',
|
||||
[$style['direction-up']]: props.direction === 'up',
|
||||
};
|
||||
|
||||
return () => prefer.s.animation ? h(TransitionGroup, {
|
||||
class: classes,
|
||||
name: 'list',
|
||||
tag: 'div',
|
||||
onBeforeLeave,
|
||||
onLeaveCancelled,
|
||||
}, { default: renderChildren }) : h('div', {
|
||||
class: classes,
|
||||
}, { default: renderChildren });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.date-separated-list {
|
||||
container-type: inline-size;
|
||||
|
||||
&:global {
|
||||
> .list-move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
> .list-enter-active {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
> *:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.date-separated-list-nogap) > *:not(:last-child) {
|
||||
margin-bottom: var(--MI-margin);
|
||||
}
|
||||
}
|
||||
|
||||
.date-separated-list-nogap {
|
||||
> * {
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.direction-up {
|
||||
&:global {
|
||||
> .list-enter-from,
|
||||
> .list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(64px);
|
||||
}
|
||||
}
|
||||
}
|
||||
.direction-down {
|
||||
&:global {
|
||||
> .list-enter-from,
|
||||
> .list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-64px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reversed {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.separator {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.date {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--MI_THEME-dateLabelFg);
|
||||
}
|
||||
|
||||
.date-1 {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.date-1-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.date-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.date-2-icon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.ad-wrapper {
|
||||
padding: 8px;
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--MI_THEME-bg) 8px, var(--MI_THEME-bg) 14px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
@@ -11,18 +11,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!input && !select"
|
||||
:class="[$style.icon, {
|
||||
[$style.type_success]: type === 'success',
|
||||
[$style.type_error]: type === 'error',
|
||||
[$style.type_warning]: type === 'warning',
|
||||
[$style.type_info]: type === 'info',
|
||||
}]"
|
||||
:class="[$style.icon]"
|
||||
>
|
||||
<i v-if="type === 'success'" :class="$style.iconInner" class="ti ti-check"></i>
|
||||
<i v-else-if="type === 'error'" :class="$style.iconInner" class="ti ti-circle-x"></i>
|
||||
<i v-else-if="type === 'warning'" :class="$style.iconInner" class="ti ti-alert-triangle"></i>
|
||||
<i v-else-if="type === 'info'" :class="$style.iconInner" class="ti ti-info-circle"></i>
|
||||
<i v-else-if="type === 'question'" :class="$style.iconInner" class="ti ti-help-circle"></i>
|
||||
<MkSystemIcon v-if="type === 'success'" :class="$style.iconInner" style="width: 45px;" type="success"/>
|
||||
<MkSystemIcon v-else-if="type === 'error'" :class="$style.iconInner" style="width: 45px;" type="error"/>
|
||||
<MkSystemIcon v-else-if="type === 'warning'" :class="$style.iconInner" style="width: 45px;" type="warn"/>
|
||||
<MkSystemIcon v-else-if="type === 'info'" :class="$style.iconInner" style="width: 45px;" type="info"/>
|
||||
<MkSystemIcon v-else-if="type === 'question'" :class="$style.iconInner" style="width: 45px;" type="question"/>
|
||||
<MkLoading v-else-if="type === 'waiting'" :class="$style.iconInner" :em="true"/>
|
||||
</div>
|
||||
<header v-if="title" :class="$style.title" class="_selectable"><Mfm :text="title"/></header>
|
||||
@@ -30,20 +25,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" :autocomplete="input.autocomplete" @keydown="onInputKeydown">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||
<template #caption>
|
||||
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"/>
|
||||
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/>
|
||||
<span v-if="okButtonDisabledReason === 'charactersExceeded'" v-text="i18n.tsx._dialog.charactersExceeded({ current: (inputValue as string)?.length ?? 0, max: input.maxLength ?? 'NaN' })"></span>
|
||||
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"></span>
|
||||
</template>
|
||||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
<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>
|
||||
<MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
|
||||
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
|
||||
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
|
||||
@@ -55,12 +41,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export type Result = string | number | true | null;
|
||||
export type MkDialogReturnType<T = Result> = { canceled: true, result: undefined } | { canceled: false, result: T };
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import type { MkSelectItem } from '@/components/MkSelect.vue';
|
||||
import type { OptionValue } from '@/types/option-value.js';
|
||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
type Input = {
|
||||
@@ -72,21 +66,11 @@ type Input = {
|
||||
maxLength?: number;
|
||||
};
|
||||
|
||||
type SelectItem = {
|
||||
value: any;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
items: (SelectItem | {
|
||||
sectionTitle: string;
|
||||
items: SelectItem[];
|
||||
})[];
|
||||
default: string | null;
|
||||
items: MkSelectItem[];
|
||||
default: OptionValue | null;
|
||||
};
|
||||
|
||||
type Result = string | number | true | null;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
|
||||
title?: string;
|
||||
@@ -113,14 +97,13 @@ const props = withDefaults(defineProps<{
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { canceled: true } | { canceled: false, result: Result }): void;
|
||||
(ev: 'done', v: MkDialogReturnType): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const modal = useTemplateRef('modal');
|
||||
|
||||
const inputValue = ref<string | number | null>(props.input?.default ?? null);
|
||||
const selectedValue = ref(props.select?.default ?? null);
|
||||
|
||||
const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
|
||||
if (props.input) {
|
||||
@@ -139,12 +122,20 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
|
||||
return null;
|
||||
});
|
||||
|
||||
const {
|
||||
def: selectDef,
|
||||
model: selectedValue,
|
||||
} = useMkSelect({
|
||||
items: computed(() => props.select?.items ?? []),
|
||||
initialValue: props.select?.default ?? null,
|
||||
});
|
||||
|
||||
// overload function を使いたいので lint エラーを無視する
|
||||
function done(canceled: true): void;
|
||||
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare
|
||||
|
||||
function done(canceled: boolean, result?: Result): void { // eslint-disable-line no-redeclare
|
||||
emit('done', { canceled, result } as { canceled: true } | { canceled: false, result: Result });
|
||||
emit('done', { canceled, result } as MkDialogReturnType);
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
@@ -202,22 +193,6 @@ function onInputKeydown(evt: KeyboardEvent) {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.type_info {
|
||||
color: #55c4dd;
|
||||
}
|
||||
|
||||
.type_success {
|
||||
color: var(--MI_THEME-success);
|
||||
}
|
||||
|
||||
.type_error {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
|
||||
.type_warning {
|
||||
color: var(--MI_THEME-warn);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: bold;
|
||||
|
||||
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
borderWidth ? { borderWidth: borderWidth } : {},
|
||||
borderColor ? { borderColor: borderColor } : {},
|
||||
]"
|
||||
/>
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { onBeforeUnmount } from 'vue';
|
||||
import MkDonation from './MkDonation.vue';
|
||||
|
||||
310
packages/frontend/src/components/MkDraggable.vue
Normal file
310
packages/frontend/src/components/MkDraggable.vue
Normal file
@@ -0,0 +1,310 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
:enterActiveClass="$style.transition_items_enterActive"
|
||||
:leaveActiveClass="$style.transition_items_leaveActive"
|
||||
:enterFromClass="$style.transition_items_enterFrom"
|
||||
:leaveToClass="$style.transition_items_leaveTo"
|
||||
:moveClass="$style.transition_items_move"
|
||||
:class="[$style.items, { [$style.dragging]: dragging, [$style.horizontal]: direction === 'horizontal', [$style.vertical]: direction === 'vertical', [$style.withGaps]: withGaps, [$style.canNest]: canNest }]"
|
||||
>
|
||||
<slot name="header"></slot>
|
||||
<div
|
||||
v-if="modelValue.length === 0"
|
||||
:class="$style.emptyDropArea"
|
||||
@dragover.prevent.stop="() => {}"
|
||||
@dragleave="() => {}"
|
||||
@drop.prevent.stop="onEmptyDrop($event)"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, i) in modelValue"
|
||||
:key="item.id"
|
||||
:class="$style.item"
|
||||
:draggable="!manualDragStart"
|
||||
@dragstart.stop="onDragstart($event, item)"
|
||||
>
|
||||
<div
|
||||
:class="[$style.forwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'forward' }]"
|
||||
@dragover.prevent.stop="onDragover($event, item, false)"
|
||||
@dragleave="onDragleave($event, item)"
|
||||
@drop.prevent.stop="onDrop($event, item, false)"
|
||||
></div>
|
||||
<div style="position: relative; z-index: 0;">
|
||||
<slot :item="item" :index="i" :dragStart="(ev) => onDragstart(ev, item)"></slot>
|
||||
</div>
|
||||
<div
|
||||
:class="[$style.backwardArea, { [$style.dropReady]: dropReadyArea[0] === item.id && dropReadyArea[1] === 'backward' }]"
|
||||
@dragover.prevent.stop="onDragover($event, item, true)"
|
||||
@dragleave="onDragleave($event, item)"
|
||||
@drop.prevent.stop="onDrop($event, item, true)"
|
||||
></div>
|
||||
</div>
|
||||
<slot name="footer"></slot>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 別々のコンポーネントインスタンス間でD&Dを融通するためにグローバルに状態を持っておく必要がある
|
||||
const dragging = ref(false);
|
||||
let dropCallback: ((targetInstanceId: string) => void) | null = null;
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup generic="T extends { id: string; }">
|
||||
import { nextTick } from 'vue';
|
||||
import { getDragData, setDragData } from '@/drag-and-drop.js';
|
||||
import { genId } from '@/utility/id.js';
|
||||
|
||||
const slots = defineSlots<{
|
||||
default(props: { item: T; index: number; dragStart: (ev: DragEvent) => void }): any;
|
||||
header(): any;
|
||||
footer(): any;
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: T[];
|
||||
direction: 'horizontal' | 'vertical';
|
||||
group?: string | null;
|
||||
manualDragStart?: boolean;
|
||||
withGaps?: boolean;
|
||||
canNest?: boolean;
|
||||
}>(), {
|
||||
group: null,
|
||||
manualDragStart: false,
|
||||
withGaps: false,
|
||||
canNest: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: T[]): void;
|
||||
}>();
|
||||
|
||||
const dropReadyArea = ref<[T['id'] | null, 'forward' | 'backward' | null]>([null, null]);
|
||||
const instanceId = genId();
|
||||
const group = props.group ?? instanceId;
|
||||
|
||||
function onDragstart(ev: DragEvent, item: T) {
|
||||
if (ev.dataTransfer == null) return;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
setDragData(ev, 'MkDraggable', { item, instanceId, group });
|
||||
|
||||
const target = ev.target as HTMLElement;
|
||||
target.addEventListener('dragend', (ev) => {
|
||||
dragging.value = false;
|
||||
dropReadyArea.value = [null, null];
|
||||
}, { once: true });
|
||||
|
||||
dropCallback = (targetInstanceId) => {
|
||||
if (targetInstanceId === instanceId) return;
|
||||
const newValue = props.modelValue.filter(x => x.id !== item.id);
|
||||
emit('update:modelValue', newValue);
|
||||
};
|
||||
|
||||
// Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう
|
||||
// SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately
|
||||
window.setTimeout(() => {
|
||||
dragging.value = true;
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent, item: T, backward: boolean) {
|
||||
nextTick(() => {
|
||||
dropReadyArea.value = [item.id, backward ? 'backward' : 'forward'];
|
||||
});
|
||||
}
|
||||
|
||||
function onDragleave(ev: DragEvent, item: T) {
|
||||
dropReadyArea.value = [null, null];
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent, item: T, backward: boolean) {
|
||||
const dragged = getDragData(ev, 'MkDraggable');
|
||||
dropReadyArea.value = [null, null];
|
||||
if (dragged == null || dragged.group !== group || dragged.item.id === item.id) return;
|
||||
dropCallback?.(instanceId);
|
||||
|
||||
const fromIndex = props.modelValue.findIndex(x => x.id === dragged.item.id);
|
||||
let toIndex = props.modelValue.findIndex(x => x.id === item.id);
|
||||
|
||||
const newValue = [...props.modelValue];
|
||||
if (fromIndex > -1) newValue.splice(fromIndex, 1);
|
||||
toIndex = newValue.findIndex(x => x.id === item.id);
|
||||
if (backward) toIndex += 1;
|
||||
newValue.splice(toIndex, 0, dragged.item as T);
|
||||
|
||||
emit('update:modelValue', newValue);
|
||||
}
|
||||
|
||||
function onEmptyDrop(ev: DragEvent) {
|
||||
const dragged = getDragData(ev, 'MkDraggable');
|
||||
if (dragged == null) return;
|
||||
dropCallback?.(instanceId);
|
||||
|
||||
emit('update:modelValue', [dragged.item as T]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_items_move,
|
||||
.transition_items_enterActive,
|
||||
.transition_items_leaveActive {
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.transition_items_enterFrom,
|
||||
.transition_items_leaveTo {
|
||||
opacity: 0;
|
||||
}
|
||||
.transition_items_leaveActive {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: left;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.items.horizontal {
|
||||
flex-direction: row;
|
||||
}
|
||||
.items.vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.items.vertical .item {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items.horizontal.withGaps {
|
||||
row-gap: var(--MI-margin);
|
||||
}
|
||||
|
||||
.items.horizontal.withGaps .item {
|
||||
padding-left: calc(var(--MI-margin) / 2);
|
||||
padding-right: calc(var(--MI-margin) / 2);
|
||||
}
|
||||
|
||||
.items.vertical.withGaps .item {
|
||||
padding-top: calc(var(--MI-margin) / 2);
|
||||
padding-bottom: calc(var(--MI-margin) / 2);
|
||||
}
|
||||
|
||||
.forwardArea, .backwardArea {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.items.dragging {
|
||||
.forwardArea, .backwardArea {
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.items.horizontal {
|
||||
.forwardArea {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.backwardArea {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.items.vertical {
|
||||
.forwardArea {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.backwardArea {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.items.canNest.horizontal {
|
||||
.forwardArea, .backwardArea {
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.items.canNest.vertical {
|
||||
.forwardArea, .backwardArea {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropReady::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
z-index: 99999;
|
||||
background: var(--MI_THEME-accent);
|
||||
border-radius: 999px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.items.horizontal {
|
||||
.forwardArea.dropReady::before {
|
||||
top: 0;
|
||||
left: -1px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.backwardArea.dropReady::before {
|
||||
top: 0;
|
||||
right: -1px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.items.vertical {
|
||||
.forwardArea.dropReady::before {
|
||||
top: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.backwardArea.dropReady::before {
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.items.horizontal .emptyDropArea {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.items.vertical .emptyDropArea {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import MkDrive_file from './MkDrive.file.vue';
|
||||
import { file } from '../../.storybook/fakes.js';
|
||||
|
||||
@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:class="[$style.root, { [$style.isSelected]: isSelected }]"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
@@ -46,24 +45,18 @@ import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/i.js';
|
||||
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
|
||||
const router = useRouter();
|
||||
import { setDragData } from '@/drag-and-drop.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
folder: Misskey.entities.DriveFolder | null;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(), {
|
||||
isSelected: false,
|
||||
selectMode: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', r: Misskey.entities.DriveFile): void;
|
||||
(ev: 'dragstart'): void;
|
||||
(ev: 'dragstart', dragEvent: DragEvent): void;
|
||||
(ev: 'dragend'): void;
|
||||
}>();
|
||||
|
||||
@@ -71,30 +64,18 @@ const isDragging = ref(false);
|
||||
|
||||
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
if (deviceKind === 'desktop') {
|
||||
router.push(`/my/drive/file/${props.file.id}`);
|
||||
} else {
|
||||
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
if (ev.dataTransfer) {
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
|
||||
setDragData(ev, 'driveFiles', [props.file]);
|
||||
}
|
||||
isDragging.value = true;
|
||||
|
||||
emit('dragstart');
|
||||
emit('dragstart', ev);
|
||||
}
|
||||
|
||||
function onDragend() {
|
||||
@@ -114,7 +95,7 @@ function onDragend() {
|
||||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b65a5;
|
||||
@@ -132,7 +113,7 @@ function onDragend() {
|
||||
&:active {
|
||||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
&::before,
|
||||
&::after {
|
||||
background: #0b588c;
|
||||
@@ -158,19 +139,19 @@ function onDragend() {
|
||||
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
|
||||
}
|
||||
|
||||
> .label {
|
||||
.label {
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
color: #fff;
|
||||
.name {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
color: #fff;
|
||||
.thumbnail {
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,8 +221,9 @@ function onDragend() {
|
||||
|
||||
.name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
margin: 8px 0 0 0;
|
||||
padding: 0 2px;
|
||||
font-size: 82%;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--MI_THEME-fg);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:class="[$style.root, { [$style.draghover]: draghover }]"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@mouseover="onMouseover"
|
||||
@mouseout="onMouseout"
|
||||
@@ -19,16 +18,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<p :class="$style.name">
|
||||
<template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
|
||||
<template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
|
||||
{{ folder.name }}
|
||||
</p>
|
||||
<p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
|
||||
<svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none">
|
||||
<path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/>
|
||||
</svg>
|
||||
<div :class="$style.name">{{ folder.name }}</div>
|
||||
<div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
</p>
|
||||
</div>
|
||||
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
|
||||
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
|
||||
<div :class="[$style.checkbox, { [$style.checked]: isSelected, 'ti ti-check': isSelected }]"></div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js';
|
||||
import { claimAchievement } from '@/utility/achievements.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
|
||||
import { selectDriveFolder } from '@/utility/drive.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
folder: Misskey.entities.DriveFolder;
|
||||
@@ -56,10 +57,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;
|
||||
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
|
||||
(ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'dragstart'): void;
|
||||
(ev: 'dragend'): void;
|
||||
}>();
|
||||
@@ -78,10 +76,6 @@ function checkboxClicked() {
|
||||
}
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
@@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) {
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
@@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) {
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit('upload', file, props.folder);
|
||||
}
|
||||
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit('removeFile', file.id);
|
||||
misskeyApi('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: props.folder.id,
|
||||
});
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFiles');
|
||||
if (droppedData != null) {
|
||||
misskeyApi('drive/files/move-bulk', {
|
||||
fileIds: droppedData.map(f => f.id),
|
||||
folderId: props.folder.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
|
||||
...x,
|
||||
folderId: props.folder.id,
|
||||
folder: props.folder,
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFolders');
|
||||
if (droppedData != null) {
|
||||
const droppedFolder = droppedData[0];
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.id === props.folder.id) return;
|
||||
// 移動先が自分自身ならreject
|
||||
if (droppedFolder.id === props.folder.id) return;
|
||||
|
||||
emit('removeFolder', folder.id);
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder.id,
|
||||
}).then(() => {
|
||||
// noop
|
||||
}).catch(err => {
|
||||
switch (err.code) {
|
||||
case 'RECURSIVE_NESTING':
|
||||
claimAchievement('driveFolderCircularReference');
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: droppedFolder.id,
|
||||
parentId: props.folder.id,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
|
||||
...x,
|
||||
parentId: props.folder.id,
|
||||
parent: props.folder,
|
||||
})));
|
||||
}).catch(err => {
|
||||
switch (err.code) {
|
||||
case 'RECURSIVE_NESTING':
|
||||
claimAchievement('driveFolderCircularReference');
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
@@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
|
||||
setDragData(ev, 'driveFolders', [props.folder]);
|
||||
isDragging.value = true;
|
||||
|
||||
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
|
||||
@@ -211,10 +211,6 @@ function onDragend() {
|
||||
emit('dragend');
|
||||
}
|
||||
|
||||
function go() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFolder,
|
||||
@@ -225,17 +221,28 @@ function rename() {
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: props.folder.id,
|
||||
name: name,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [{
|
||||
...props.folder,
|
||||
name: name,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function move() {
|
||||
os.selectDriveFolder(false).then(folder => {
|
||||
if (folder[0] && folder[0].id === props.folder.id) return;
|
||||
selectDriveFolder(null).then(({ canceled, folders }) => {
|
||||
if (canceled || (folders[0] && folders[0].id === props.folder.id)) return;
|
||||
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: props.folder.id,
|
||||
parentId: folder[0] ? folder[0].id : null,
|
||||
parentId: folders[0] ? folders[0].id : null,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [{
|
||||
...props.folder,
|
||||
parentId: folders[0] ? folders[0].id : null,
|
||||
parent: folders[0] ?? null,
|
||||
}]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -247,6 +254,7 @@ function deleteFolder() {
|
||||
if (prefer.s.uploadFolder === props.folder.id) {
|
||||
prefer.commit('uploadFolder', null);
|
||||
}
|
||||
globalEvents.emit('driveFoldersDeleted', [props.folder]);
|
||||
}).catch(err => {
|
||||
switch (err.id) {
|
||||
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
|
||||
@@ -269,13 +277,13 @@ function setAsUploadFolder() {
|
||||
prefer.commit('uploadFolder', props.folder.id);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
function onContextmenu(ev: PointerEvent) {
|
||||
let menu: MenuItem[];
|
||||
menu = [{
|
||||
text: i18n.ts.openInWindow,
|
||||
icon: 'ti ti-app-window',
|
||||
action: () => {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
|
||||
action: async () => {
|
||||
const { dispose } = await os.popupAsyncWithDialog(import('@/components/MkDriveWindow.vue').then(x => x.default), {
|
||||
initialFolder: props.folder,
|
||||
}, {
|
||||
closed: () => dispose(),
|
||||
@@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) {
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
height: 64px;
|
||||
background: var(--MI_THEME-driveFolderBg);
|
||||
border-radius: 4px;
|
||||
height: 90px;
|
||||
padding: 24px 16px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
|
||||
&.draghover {
|
||||
@@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.checkboxWrapper {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
@@ -353,16 +368,14 @@ function onContextmenu(ev: MouseEvent) {
|
||||
border-color: var(--MI_THEME-accent);
|
||||
background: var(--MI_THEME-accent);
|
||||
|
||||
&::after {
|
||||
content: "\ea5e";
|
||||
font-family: 'tabler-icons';
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -373,7 +386,6 @@ function onContextmenu(ev: MouseEvent) {
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@@ -384,7 +396,6 @@ function onContextmenu(ev: MouseEvent) {
|
||||
}
|
||||
|
||||
.upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template>
|
||||
<div
|
||||
:class="[$style.root, { [$style.draghover]: draghover }]"
|
||||
@click="onClick"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@@ -22,6 +21,8 @@ import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
|
||||
|
||||
const props = defineProps<{
|
||||
folder?: Misskey.entities.DriveFolder;
|
||||
@@ -29,27 +30,11 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'move', v?: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
|
||||
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
|
||||
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
|
||||
(ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const draghover = ref(false);
|
||||
|
||||
function onClick() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function onMouseout() {
|
||||
hover.value = false;
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
@@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) {
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
@@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) {
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit('upload', file, props.folder);
|
||||
}
|
||||
emit('upload', Array.from(ev.dataTransfer.files), props.folder);
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit('removeFile', file.id);
|
||||
misskeyApi('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFiles');
|
||||
if (droppedData != null) {
|
||||
misskeyApi('drive/files/move-bulk', {
|
||||
fileIds: droppedData.map(f => f.id),
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
|
||||
...x,
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
folder: props.folder ?? null,
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
// 移動先が自分自身ならreject
|
||||
if (props.folder && folder.id === props.folder.id) return;
|
||||
emit('removeFolder', folder.id);
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
{
|
||||
const droppedData = getDragData(ev, 'driveFolders');
|
||||
if (droppedData != null) {
|
||||
const droppedFolder = droppedData[0];
|
||||
// 移動先が自分自身ならreject
|
||||
if (props.folder && droppedFolder.id === props.folder.id) return;
|
||||
misskeyApi('drive/folders/update', {
|
||||
folderId: droppedFolder.id,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
}).then(() => {
|
||||
globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
|
||||
...x,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
parent: props.folder ?? null,
|
||||
})));
|
||||
});
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,5 +3,5 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
|
||||
import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue';
|
||||
void MkDriveSelectDialog;
|
||||
@@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:width="800"
|
||||
:height="500"
|
||||
:withOkButton="true"
|
||||
:okButtonDisabled="(type === 'file') && (selected.length === 0)"
|
||||
:okButtonDisabled="selected.length === 0"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
|
||||
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
|
||||
{{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }}
|
||||
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
|
||||
</template>
|
||||
<XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
|
||||
<MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDrive from '@/components/MkDrive.vue';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import number from '@/filters/number.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
type?: 'file' | 'folder';
|
||||
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
|
||||
multiple: boolean;
|
||||
}>(), {
|
||||
type: 'file',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
|
||||
const selected = ref<Misskey.entities.DriveFile[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
@@ -57,7 +55,7 @@ function cancel() {
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
|
||||
function onChangeSelection(v: Misskey.entities.DriveFile[]) {
|
||||
selected.value = v;
|
||||
}
|
||||
</script>
|
||||
@@ -11,15 +11,24 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
[$style.large]: large,
|
||||
}]"
|
||||
>
|
||||
<ImgWithBlurhash
|
||||
v-if="isThumbnailAvailable"
|
||||
<MkImgWithBlurhash
|
||||
v-if="isThumbnailAvailable && prefer.s.enableHighQualityImagePlaceholders"
|
||||
:hash="file.blurhash"
|
||||
:src="file.thumbnailUrl"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
:class="$style.thumbnail"
|
||||
:cover="fit !== 'contain'"
|
||||
:forceBlurhash="forceBlurhash"
|
||||
/>
|
||||
<img
|
||||
v-else-if="isThumbnailAvailable && file.thumbnailUrl != null"
|
||||
:src="file.thumbnailUrl"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
:class="$style.thumbnail"
|
||||
:style="{ objectFit: fit }"
|
||||
/>
|
||||
<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>
|
||||
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music" :class="$style.icon"></i>
|
||||
@@ -36,7 +45,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import MkImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
@@ -115,4 +125,8 @@ const isThumbnailAvailable = computed(() => {
|
||||
.large .icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="dialog"
|
||||
:width="800"
|
||||
:height="500"
|
||||
:withOkButton="true"
|
||||
:okButtonDisabled="selected.length === 0"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }}
|
||||
<span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
|
||||
</template>
|
||||
<MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder['id'] | null;
|
||||
multiple?: boolean;
|
||||
}>(), {
|
||||
initialFolder: null,
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: (Misskey.entities.DriveFolder | null)[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const selected = ref<(Misskey.entities.DriveFolder | null)[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('done');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(v: (Misskey.entities.DriveFolder | null)[]) {
|
||||
selected.value = v;
|
||||
}
|
||||
</script>
|
||||
@@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #header>
|
||||
{{ i18n.ts.drive }}
|
||||
</template>
|
||||
<XDrive :initialFolder="initialFolder"/>
|
||||
<MkDrive :initialFolder="initialFolder"/>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDrive from '@/components/MkDrive.vue';
|
||||
import MkDrive from '@/components/MkDrive.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder;
|
||||
initialFolder?: Misskey.entities.DriveFolder | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -23,11 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
: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"/>
|
||||
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
|
||||
<template #preview>
|
||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
|
||||
@@ -45,30 +42,29 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</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>
|
||||
</template>
|
||||
<template #controls>
|
||||
<div class="_spacer _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" :items="colorModeDef">
|
||||
<template #label>{{ i18n.ts.theme }}</template>
|
||||
</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>
|
||||
</template>
|
||||
</MkPreviewWithControls>
|
||||
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
|
||||
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
@@ -94,17 +90,15 @@ 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 MkPreviewWithControls from '@/components/MkPreviewWithControls.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 { useMkSelect } from '@/composables/use-mkselect.js';
|
||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
||||
|
||||
@@ -160,9 +154,20 @@ const embedPreviewUrl = computed(() => {
|
||||
|
||||
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 maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500);
|
||||
|
||||
const {
|
||||
model: colorMode,
|
||||
def: colorModeDef,
|
||||
} = useMkSelect({
|
||||
items: [
|
||||
{ value: 'auto', label: i18n.ts.syncDeviceDarkMode },
|
||||
{ value: 'light', label: i18n.ts.light },
|
||||
{ value: 'dark', label: i18n.ts.dark },
|
||||
],
|
||||
initialValue: props.params?.colorMode ?? 'auto',
|
||||
});
|
||||
|
||||
const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto');
|
||||
const rounded = ref(props.params?.rounded ?? true);
|
||||
const border = ref(props.params?.border ?? true);
|
||||
|
||||
@@ -297,20 +302,6 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.embedCodeGenInputRoot {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewRoot {
|
||||
position: relative;
|
||||
background-color: var(--MI_THEME-bg);
|
||||
background-size: auto auto;
|
||||
background-image: repeating-linear-gradient(135deg, transparent, transparent 6px, var(--MI_THEME-panel) 6px, var(--MI_THEME-panel) 12px);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.embedCodeGenPreviewWrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -358,11 +349,6 @@ onUnmounted(() => {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
.embedCodeGenSettings {
|
||||
padding: 24px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.embedCodeGenResultRoot {
|
||||
box-sizing: border-box;
|
||||
padding: 24px;
|
||||
@@ -403,11 +389,4 @@ onUnmounted(() => {
|
||||
.embedCodeGenResultButtons {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.embedCodeGenInputRoot {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,8 +62,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import { getEmojiName } from '@@/js/emojilist.js';
|
||||
import type { Ref } from 'vue';
|
||||
import type { CustomEmojiFolderTree } from '@@/js/emojilist.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { customEmojis } from '@/custom-emojis.js';
|
||||
@@ -78,7 +78,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: string, event: MouseEvent): void;
|
||||
(ev: 'chosen', v: string, event: PointerEvent): void;
|
||||
}>();
|
||||
|
||||
const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props.emojis.value);
|
||||
@@ -86,13 +86,13 @@ const emojis = computed(() => Array.isArray(props.emojis) ? props.emojis : props
|
||||
const shown = ref(!!props.initialShown);
|
||||
|
||||
/** @see MkEmojiPicker.vue */
|
||||
function computeButtonTitle(ev: MouseEvent): void {
|
||||
function computeButtonTitle(ev: PointerEvent): void {
|
||||
const elm = ev.target as HTMLElement;
|
||||
const emoji = elm.dataset.emoji as string;
|
||||
elm.title = getEmojiName(emoji);
|
||||
}
|
||||
|
||||
function nestedChosen(emoji: string, ev: MouseEvent) {
|
||||
function nestedChosen(emoji: string, ev: PointerEvent) {
|
||||
emit('chosen', emoji, ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { action } from 'storybook/actions';
|
||||
import { expect, userEvent, waitFor, within } from '@storybook/test';
|
||||
import type { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
@@ -64,6 +64,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkCustomEmoji v-if="!emoji.hasOwnProperty('char')" class="emoji" :name="getKey(emoji)" :normal="true"/>
|
||||
<MkEmoji v-else class="emoji" :emoji="getKey(emoji)" :normal="true"/>
|
||||
</button>
|
||||
<button v-tooltip="i18n.ts.settings" class="_button config" @click="settings"><i class="ti ti-settings"></i></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -139,6 +140,10 @@ import { customEmojiCategories, customEmojis, customEmojisMap } from '@/custom-e
|
||||
import { $i } from '@/i.js';
|
||||
import { checkReactionPermissions } from '@/utility/check-reaction-permissions.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { useRouter } from '@/router.js';
|
||||
import { haptic } from '@/utility/haptic.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showPinned?: boolean;
|
||||
@@ -147,7 +152,7 @@ const props = withDefaults(defineProps<{
|
||||
asDrawer?: boolean;
|
||||
asWindow?: boolean;
|
||||
asReactionPicker?: boolean; // 今は使われてないが将来的に使いそう
|
||||
targetNote?: Misskey.entities.Note;
|
||||
targetNote?: Misskey.entities.Note | null;
|
||||
}>(), {
|
||||
showPinned: true,
|
||||
});
|
||||
@@ -321,7 +326,7 @@ watch(q, () => {
|
||||
|
||||
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
|
||||
for (const emoji of emojis) {
|
||||
if (keywords.every(keyword => index[emoji.char].some(k => k.includes(keyword)))) {
|
||||
if (keywords.every(keyword => index[emoji.char]?.some(k => k.includes(keyword)))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
@@ -338,7 +343,7 @@ watch(q, () => {
|
||||
|
||||
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
|
||||
for (const emoji of emojis) {
|
||||
if (index[emoji.char].some(k => k.startsWith(newQ))) {
|
||||
if (index[emoji.char]?.some(k => k.startsWith(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
@@ -355,7 +360,7 @@ watch(q, () => {
|
||||
|
||||
for (const index of Object.values(store.s.additionalUnicodeEmojiIndexes)) {
|
||||
for (const emoji of emojis) {
|
||||
if (index[emoji.char].some(k => k.includes(newQ))) {
|
||||
if (index[emoji.char]?.some(k => k.includes(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
@@ -407,13 +412,13 @@ function getDef(emoji: string): string | Misskey.entities.EmojiSimple | UnicodeE
|
||||
}
|
||||
|
||||
/** @see MkEmojiPicker.section.vue */
|
||||
function computeButtonTitle(ev: MouseEvent): void {
|
||||
function computeButtonTitle(ev: PointerEvent): void {
|
||||
const elm = ev.target as HTMLElement;
|
||||
const emoji = elm.dataset.emoji as string;
|
||||
elm.title = getEmojiName(emoji);
|
||||
}
|
||||
|
||||
function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: MouseEvent) {
|
||||
function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef, ev?: PointerEvent) {
|
||||
const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
|
||||
if (el && prefer.s.animation) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -427,6 +432,8 @@ function chosen(emoji: string | Misskey.entities.EmojiSimple | UnicodeEmojiDef,
|
||||
const key = getKey(emoji);
|
||||
emit('chosen', key);
|
||||
|
||||
haptic();
|
||||
|
||||
// 最近使った絵文字更新
|
||||
if (!pinned.value?.includes(key)) {
|
||||
let recents = store.s.recentlyUsedEmojis;
|
||||
@@ -489,6 +496,11 @@ function done(query?: string): boolean | void {
|
||||
}
|
||||
}
|
||||
|
||||
function settings() {
|
||||
emit('esc');
|
||||
router.push('/settings/emoji-palette');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
focus();
|
||||
});
|
||||
@@ -518,47 +530,53 @@ defineExpose({
|
||||
--eachSize: 50px;
|
||||
}
|
||||
|
||||
&.s4 {
|
||||
--eachSize: 55px;
|
||||
}
|
||||
|
||||
&.s5 {
|
||||
--eachSize: 60px;
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 5;
|
||||
}
|
||||
|
||||
&.w2 {
|
||||
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 6;
|
||||
}
|
||||
|
||||
&.w3 {
|
||||
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 7;
|
||||
}
|
||||
|
||||
&.w4 {
|
||||
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 8;
|
||||
}
|
||||
|
||||
&.w5 {
|
||||
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
--columns: 9;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
|
||||
--rows: 4;
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--rows: 6;
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--rows: 8;
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
|
||||
--rows: 10;
|
||||
}
|
||||
|
||||
width: calc((var(--eachSize) * var(--columns)) + (#{$pad} * 2));
|
||||
height: calc((var(--eachSize) * var(--rows)) + (#{$pad} * 2));
|
||||
|
||||
&.asDrawer {
|
||||
width: 100% !important;
|
||||
|
||||
@@ -573,9 +591,17 @@ defineExpose({
|
||||
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
font-size: 30px;
|
||||
|
||||
> .config {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
> .item {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
@@ -607,7 +633,7 @@ defineExpose({
|
||||
::v-deep(section) {
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
font-size: 30px;
|
||||
|
||||
> .item {
|
||||
@@ -675,13 +701,8 @@ defineExpose({
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .group {
|
||||
&:not(.index) {
|
||||
padding: 4px 0 8px 0;
|
||||
@@ -720,6 +741,15 @@ defineExpose({
|
||||
position: relative;
|
||||
padding: $pad;
|
||||
|
||||
> .config {
|
||||
position: relative;
|
||||
padding: 0 3px;
|
||||
width: var(--eachSize);
|
||||
height: var(--eachSize);
|
||||
contain: strict;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .item {
|
||||
position: relative;
|
||||
padding: 0 3px;
|
||||
|
||||
@@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
:hasInteractionWithOtherFocusTrappedEls="true"
|
||||
:transparentBg="true"
|
||||
:manualShowing="manualShowing"
|
||||
:src="src"
|
||||
:anchorElement="anchorElement"
|
||||
@click="modal?.close()"
|
||||
@esc="modal?.close()"
|
||||
@opening="opening"
|
||||
@@ -44,11 +44,11 @@ import { prefer } from '@/preferences.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
src?: HTMLElement;
|
||||
anchorElement?: HTMLElement | null;
|
||||
showPinned?: boolean;
|
||||
pinnedEmojis?: string[],
|
||||
asReactionPicker?: boolean;
|
||||
targetNote?: Misskey.entities.Note;
|
||||
targetNote?: Misskey.entities.Note | null;
|
||||
choseAndClose?: boolean;
|
||||
}>(), {
|
||||
manualShowing: null,
|
||||
|
||||
@@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo>
|
||||
|
||||
<div v-if="isPlugin" class="_gaps_s">
|
||||
<div v-if="extension.type === 'plugin'" class="_gaps_s">
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #icon><i class="ti ti-info-circle"></i></template>
|
||||
<template #label>{{ i18n.ts.metadata }}</template>
|
||||
@@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #key>{{ i18n.ts.permission }}</template>
|
||||
<template #value>
|
||||
<ul v-if="extension.meta.permissions && extension.meta.permissions.length > 0" :class="$style.extInstallerKVList">
|
||||
<li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] }}</li>
|
||||
<li v-for="permission in extension.meta.permissions" :key="permission">{{ i18n.ts._permissions[permission] ?? permission }}</li>
|
||||
</ul>
|
||||
<template v-else>{{ i18n.ts.none }}</template>
|
||||
</template>
|
||||
@@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkCode :code="extension.raw"/>
|
||||
</MkFolder>
|
||||
</div>
|
||||
<div v-else-if="isTheme" class="_gaps_s">
|
||||
<div v-else-if="extension.type === 'theme'" class="_gaps_s">
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #icon><i class="ti ti-info-circle"></i></template>
|
||||
<template #label>{{ i18n.ts.metadata }}</template>
|
||||
@@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</FormSplit>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
|
||||
<template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template>
|
||||
<template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</MkFolder>
|
||||
@@ -91,7 +91,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkFolder>
|
||||
</div>
|
||||
|
||||
<slot name="additionalInfo"/>
|
||||
<slot name="additionalInfo"></slot>
|
||||
|
||||
<div class="_buttonsCenter">
|
||||
<MkButton danger rounded large @click="emits('cancel')"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
|
||||
@@ -101,6 +101,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
export type Extension = {
|
||||
type: 'plugin';
|
||||
raw: string;
|
||||
@@ -109,7 +111,7 @@ export type Extension = {
|
||||
version: string;
|
||||
author: string;
|
||||
description?: string;
|
||||
permissions?: string[];
|
||||
permissions?: (typeof Misskey.permissions)[number][];
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
} | {
|
||||
@@ -125,7 +127,6 @@ export type Extension = {
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import FormSection from '@/components/form/section.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
import MkCode from '@/components/MkCode.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
|
||||
@@ -15,12 +15,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.describeFile }}</template>
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<MkDriveFileThumbnail :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
|
||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 28px;">
|
||||
<MkDriveFileThumbnail v-if="file" :file="file" fit="contain" style="height: 100px; margin-bottom: 16px;"/>
|
||||
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
|
||||
<template #label>{{ i18n.ts.caption }}</template>
|
||||
</MkTextarea>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
@@ -33,8 +33,8 @@ import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
default: string;
|
||||
file?: Misskey.entities.DriveFile | null;
|
||||
default?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -44,7 +44,7 @@ const emit = defineEmits<{
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const caption = ref(props.default);
|
||||
const caption = ref(props.default ?? '');
|
||||
|
||||
async function ok() {
|
||||
emit('done', caption.value);
|
||||
|
||||
@@ -5,114 +5,122 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
|
||||
<MkA
|
||||
v-for="file in (items as Misskey.entities.DriveFile[])"
|
||||
:key="file.id"
|
||||
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`"
|
||||
:to="`/admin/file/${file.id}`"
|
||||
class="file _button"
|
||||
<MkPagination v-slot="{ items }" :paginator="paginator">
|
||||
<div
|
||||
:class="{
|
||||
[$style.grid]: viewMode === 'grid',
|
||||
[$style.list]: viewMode === 'list',
|
||||
'_gaps_s': viewMode === 'list',
|
||||
}"
|
||||
>
|
||||
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
|
||||
<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>
|
||||
<MkA
|
||||
v-for="file in items"
|
||||
:key="file.id"
|
||||
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}\nby ${file.user ? '@' + Misskey.acct.toString(file.user) : 'system'}`"
|
||||
:to="`/admin/file/${file.id}`"
|
||||
:class="[$style.file, '_button']"
|
||||
>
|
||||
<div v-if="file.isSensitive" :class="$style.sensitiveLabel">{{ i18n.ts.sensitive }}</div>
|
||||
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain" :highlightWhenSensitive="true"/>
|
||||
<div v-if="viewMode === 'list'" :class="$style.body">
|
||||
<div>
|
||||
<small style="opacity: 0.7;">{{ file.name }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<MkAcct v-if="file.user" :user="file.user"/>
|
||||
<div v-else>{{ i18n.ts.system }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="margin-right: 1em;">{{ file.type }}</span>
|
||||
<span>{{ bytes(file.size) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<MkAcct v-if="file.user" :user="file.user"/>
|
||||
<div v-else>{{ i18n.ts.system }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="margin-right: 1em;">{{ file.type }}</span>
|
||||
<span>{{ bytes(file.size) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</MkA>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import type { Paginator } from '@/utility/paginator.js';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { dateString } from '@/filters/date.js';
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: any;
|
||||
defineProps<{
|
||||
paginator: Paginator<'admin/drive/files'>;
|
||||
viewMode: 'grid' | 'list';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style lang="scss" module>
|
||||
@keyframes sensitive-blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.urempief {
|
||||
&.list {
|
||||
> .file {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
margin-left: 0.3em;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.list {
|
||||
> .file {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
grid-gap: 12px;
|
||||
> .file:hover {
|
||||
color: var(--MI_THEME-accent);
|
||||
}
|
||||
|
||||
> .file {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
> .file > .thumbnail {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
> .file > .body {
|
||||
margin-left: 0.3em;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
|
||||
> .sensitive-label {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 4px;
|
||||
background: #ff0000bf;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 85%;
|
||||
animation: sensitive-blink 1s infinite;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
grid-gap: 12px;
|
||||
|
||||
> .file {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.sensitiveLabel {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 4px;
|
||||
background: #ff0000bf;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 85%;
|
||||
animation: sensitive-blink 1s infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -31,9 +31,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { globalEvents } from '@/events.js';
|
||||
import { getBgColor } from '@/utility/get-bg-color.js';
|
||||
|
||||
const miLocalStoragePrefix = 'ui:folder:' as const;
|
||||
@@ -83,8 +84,19 @@ function afterLeave(el: Element) {
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function updateBgColor() {
|
||||
if (rootEl.value) {
|
||||
parentBg.value = getBgColor(rootEl.value.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
parentBg.value = getBgColor(rootEl.value?.parentElement);
|
||||
updateBgColor();
|
||||
globalEvents.on('themeChanging', updateBgColor);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
globalEvents.off('themeChanging', updateBgColor);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -19,13 +19,42 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
<div :class="$style.headerRight">
|
||||
<span :class="$style.headerRightText"><slot name="suffix"></slot></span>
|
||||
<i v-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-if="asPage" class="ti ti-chevron-right icon"></i>
|
||||
<i v-else-if="opened" class="ti ti-chevron-up icon"></i>
|
||||
<i v-else class="ti ti-chevron-down icon"></i>
|
||||
</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">
|
||||
<div v-if="asPage">
|
||||
<Teleport v-if="opened" defer :to="`#v-${pageId}-header`">
|
||||
<slot name="label"></slot>
|
||||
</Teleport>
|
||||
<Teleport v-if="opened" defer :to="`#v-${pageId}-body`">
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<div v-if="$slots.header" :class="$style.inBodyHeader">
|
||||
<slot name="header"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="withSpacer" class="_spacer" :style="{ '--MI_SPACER-min': props.spacerMin + 'px', '--MI_SPACER-max': props.spacerMax + 'px' }">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="$slots.footer" :class="$style.inBodyFooter">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<div v-else-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : undefined, overflow: maxHeight ? `auto` : undefined }" :aria-hidden="!opened">
|
||||
<Transition
|
||||
:enterActiveClass="prefer.s.animation ? $style.transition_toggle_enterActive : ''"
|
||||
:leaveActiveClass="prefer.s.animation ? $style.transition_toggle_leaveActive : ''"
|
||||
@@ -45,9 +74,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
|
||||
<div v-if="withSpacer" class="_spacer" :style="{ '--MI_SPACER-min': props.spacerMin + 'px', '--MI_SPACER-max': props.spacerMax + 'px' }">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
</div>
|
||||
<div v-else>
|
||||
<slot></slot>
|
||||
</div>
|
||||
@@ -67,9 +96,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import { nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
|
||||
import { prefer } from '@/preferences.js';
|
||||
import { getBgColor } from '@/utility/get-bg-color.js';
|
||||
import { pageFolderTeleportCount, popup } from '@/os.js';
|
||||
import MkFolderPage from '@/components/MkFolderPage.vue';
|
||||
import { deviceKind } from '@/utility/device-kind.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
defaultOpen?: boolean;
|
||||
@@ -77,21 +109,32 @@ const props = withDefaults(defineProps<{
|
||||
withSpacer?: boolean;
|
||||
spacerMin?: number;
|
||||
spacerMax?: number;
|
||||
canPage?: boolean;
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
maxHeight: null,
|
||||
withSpacer: true,
|
||||
spacerMin: 14,
|
||||
spacerMax: 22,
|
||||
canPage: true,
|
||||
});
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const bgSame = ref(false);
|
||||
const opened = ref(props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(props.defaultOpen);
|
||||
const emit = defineEmits<{
|
||||
(ev: 'opened'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const rootEl = useTemplateRef('rootEl');
|
||||
const asPage = props.canPage && deviceKind === 'smartphone' && prefer.s['experimental.enableFolderPageView'];
|
||||
const bgSame = ref(false);
|
||||
const opened = ref(asPage ? false : props.defaultOpen);
|
||||
const openedAtLeastOnce = ref(opened.value);
|
||||
|
||||
//#region interpolate-sizeに対応していないブラウザ向け(TODO: 主要ブラウザが対応したら消す)
|
||||
function enter(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = '0';
|
||||
el.offsetHeight; // reflow
|
||||
@@ -99,12 +142,16 @@ function enter(el: Element) {
|
||||
}
|
||||
|
||||
function afterEnter(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
el.style.height = '';
|
||||
}
|
||||
|
||||
function leave(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = `${elementHeight}px`;
|
||||
el.offsetHeight; // reflow
|
||||
@@ -112,11 +159,29 @@ function leave(el: Element) {
|
||||
}
|
||||
|
||||
function afterLeave(el: Element) {
|
||||
if (CSS.supports('interpolate-size', 'allow-keywords')) return;
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
|
||||
el.style.height = '';
|
||||
}
|
||||
//#endregion
|
||||
|
||||
let pageId = pageFolderTeleportCount.value;
|
||||
pageFolderTeleportCount.value += 1000;
|
||||
|
||||
async function toggle(ev: PointerEvent) {
|
||||
if (asPage && !opened.value) {
|
||||
pageId++;
|
||||
const { dispose } = await popup(MkFolderPage, {
|
||||
pageId,
|
||||
}, {
|
||||
closed: () => {
|
||||
opened.value = false;
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!opened.value) {
|
||||
openedAtLeastOnce.value = true;
|
||||
}
|
||||
@@ -132,14 +197,34 @@ onMounted(() => {
|
||||
const myBg = computedStyle.getPropertyValue('--MI_THEME-panel');
|
||||
bgSame.value = parentBg === myBg;
|
||||
});
|
||||
|
||||
watch(opened, (isOpened) => {
|
||||
if (isOpened) {
|
||||
emit('opened');
|
||||
} else {
|
||||
emit('closed');
|
||||
}
|
||||
}, { flush: 'post' });
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.transition_toggle_enterActive,
|
||||
.transition_toggle_leaveActive {
|
||||
overflow-y: clip;
|
||||
transition: opacity 0.3s, height 0.3s, transform 0.3s !important;
|
||||
overflow-y: hidden; // 子要素のmarginが突き出るため clip を使ってはいけない
|
||||
transition: opacity 0.3s, height 0.3s;
|
||||
}
|
||||
|
||||
@supports (interpolate-size: allow-keywords) {
|
||||
.transition_toggle_enterFrom,
|
||||
.transition_toggle_leaveTo {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.root {
|
||||
interpolate-size: allow-keywords; // heightのtransitionを動作させるために必要
|
||||
}
|
||||
}
|
||||
|
||||
.transition_toggle_enterFrom,
|
||||
.transition_toggle_leaveTo {
|
||||
opacity: 0;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user