merge upstream 2025-02-03
This commit is contained in:
commit
a4e86758c1
264 changed files with 15775 additions and 4919 deletions
154
packages/frontend/.storybook/fake-utils.ts
Normal file
154
packages/frontend/.storybook/fake-utils.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import seedrandom from 'seedrandom';
|
||||
|
||||
/**
|
||||
* AIで生成した無作為なファーストネーム
|
||||
*/
|
||||
export const firstNameDict = [
|
||||
'Ethan', 'Olivia', 'Jackson', 'Emma', 'Liam', 'Ava', 'Aiden', 'Sophia', 'Mason', 'Isabella',
|
||||
'Noah', 'Mia', 'Lucas', 'Harper', 'Caleb', 'Abigail', 'Samuel', 'Emily', 'Logan',
|
||||
'Madison', 'Benjamin', 'Chloe', 'Elijah', 'Grace', 'Alexander', 'Scarlett', 'William', 'Zoey', 'James', 'Lily',
|
||||
]
|
||||
|
||||
/**
|
||||
* AIで生成した無作為なラストネーム
|
||||
*/
|
||||
export const lastNameDict = [
|
||||
'Anderson', 'Johnson', 'Thompson', 'Davis', 'Rodriguez', 'Smith', 'Patel', 'Williams', 'Lee', 'Brown',
|
||||
'Garcia', 'Jackson', 'Martinez', 'Taylor', 'Harris', 'Nguyen', 'Miller', 'Jones', 'Wilson',
|
||||
'White', 'Thomas', 'Garcia', 'Martinez', 'Robinson', 'Turner', 'Lewis', 'Hall', 'King', 'Baker', 'Cooper',
|
||||
]
|
||||
|
||||
/**
|
||||
* AIで生成した無作為な国名
|
||||
*/
|
||||
export const countryDict = [
|
||||
'Japan', 'Canada', 'Brazil', 'Australia', 'Italy', 'SouthAfrica', 'Mexico', 'Sweden', 'Russia', 'India',
|
||||
'Germany', 'Argentina', 'South Korea', 'France', 'Nigeria', 'Turkey', 'Spain', 'Egypt', 'Thailand',
|
||||
'Vietnam', 'Kenya', 'Saudi Arabia', 'Netherlands', 'Colombia', 'Poland', 'Chile', 'Malaysia', 'Ukraine', 'New Zealand', 'Peru',
|
||||
]
|
||||
|
||||
export function text(length: number = 10, seed?: string): string {
|
||||
let result = "";
|
||||
|
||||
// シード値を使う場合、同じ数値が羅列されるが、ランダム文字列という意味では満たせていると思うのでこのまま使っておく
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
while (result.length < length) {
|
||||
result += rand.toString(36).substring(2);
|
||||
}
|
||||
|
||||
return result.substring(0, length);
|
||||
}
|
||||
|
||||
export function integer(min: number = 0, max: number = 9999, seed?: string): number {
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
return Math.floor(rand * (max - min)) + min;
|
||||
}
|
||||
|
||||
export function date(params?: {
|
||||
yearMin?: number,
|
||||
yearMax?: number,
|
||||
monthMin?: number,
|
||||
monthMax?: number,
|
||||
dayMin?: number,
|
||||
dayMax?: number,
|
||||
hourMin?: number,
|
||||
hourMax?: number,
|
||||
minuteMin?: number,
|
||||
minuteMax?: number,
|
||||
secondMin?: number,
|
||||
secondMax?: number,
|
||||
millisecondMin?: number,
|
||||
millisecondMax?: number,
|
||||
}, seed?: string): Date {
|
||||
const year = integer(params?.yearMin ?? 1970, params?.yearMax ?? (new Date()).getFullYear(), seed);
|
||||
const month = integer(params?.monthMin ?? 1, params?.monthMax ?? 12, seed);
|
||||
let day = integer(params?.dayMin ?? 1, params?.dayMax ?? 31, seed);
|
||||
if (month === 2) {
|
||||
day = Math.min(day, 28);
|
||||
} else if ([4, 6, 9, 11].includes(month)) {
|
||||
day = Math.min(day, 30);
|
||||
} else {
|
||||
day = Math.min(day, 31);
|
||||
}
|
||||
|
||||
const hour = integer(params?.hourMin ?? 0, params?.hourMax ?? 23, seed);
|
||||
const minute = integer(params?.minuteMin ?? 0, params?.minuteMax ?? 59, seed);
|
||||
const second = integer(params?.secondMin ?? 0, params?.secondMax ?? 59, seed);
|
||||
const millisecond = integer(params?.millisecondMin ?? 0, params?.millisecondMax ?? 999, seed);
|
||||
|
||||
return new Date(year, month - 1, day, hour, minute, second, millisecond);
|
||||
}
|
||||
|
||||
export function boolean(seed?: string): boolean {
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
return rand < 0.5;
|
||||
}
|
||||
|
||||
export function choose<T>(array: T[], seed?: string): T {
|
||||
const rand = seed ? seedrandom(seed)() : Math.random();
|
||||
return array[Math.floor(rand * array.length)];
|
||||
}
|
||||
|
||||
export function firstName(seed?: string): string {
|
||||
return choose(firstNameDict, seed);
|
||||
}
|
||||
|
||||
export function lastName(seed?: string): string {
|
||||
return choose(lastNameDict, seed);
|
||||
}
|
||||
|
||||
export function country(seed?: string): string {
|
||||
return choose(countryDict, seed);
|
||||
}
|
||||
|
||||
const TIME2000 = 946684800000;
|
||||
export function fakeId(seed?: string): string {
|
||||
let time = new Date().getTime();
|
||||
|
||||
time = time - TIME2000;
|
||||
if (time < 0) time = 0;
|
||||
|
||||
const timeStr = time.toString(36).padStart(8, '0');
|
||||
const noiseStr = text(2, seed);
|
||||
|
||||
return timeStr + noiseStr;
|
||||
}
|
||||
|
||||
export function imageDataUrl(options?: {
|
||||
size?: {
|
||||
width?: number,
|
||||
height?: number,
|
||||
},
|
||||
color?: {
|
||||
red?: number,
|
||||
green?: number,
|
||||
blue?: number,
|
||||
alpha?: number,
|
||||
}
|
||||
}, seed?: string): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = options?.size?.width ?? 100;
|
||||
canvas.height = options?.size?.height ?? 100;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Failed to get 2d context');
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
|
||||
const red = options?.color?.red ?? integer(0, 255, seed);
|
||||
const green = options?.color?.green ?? integer(0, 255, seed);
|
||||
const blue = options?.color?.blue ?? integer(0, 255, seed);
|
||||
const alpha = options?.color?.alpha ?? 1;
|
||||
ctx.arc(canvas.width / 2, canvas.height / 2, canvas.width / 2, 0, Math.PI * 2, true);
|
||||
ctx.fillStyle = `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
ctx.fill();
|
||||
|
||||
return canvas.toDataURL('image/png', 1.0);
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { AISCRIPT_VERSION } from '@syuilo/aiscript';
|
||||
import type { entities } from 'misskey-js'
|
||||
import { date, imageDataUrl, text } from "./fake-utils.js";
|
||||
|
||||
export function abuseUserReport() {
|
||||
return {
|
||||
|
|
@ -302,3 +303,93 @@ export function inviteCode(isUsed = false, hasExpiration = false, isExpired = fa
|
|||
used: isUsed,
|
||||
}
|
||||
}
|
||||
|
||||
export function role(params: {
|
||||
id?: string,
|
||||
name?: string,
|
||||
color?: string | null,
|
||||
iconUrl?: string | null,
|
||||
description?: string,
|
||||
isModerator?: boolean,
|
||||
isAdministrator?: boolean,
|
||||
displayOrder?: number,
|
||||
createdAt?: string,
|
||||
updatedAt?: string,
|
||||
target?: 'manual' | 'conditional',
|
||||
isPublic?: boolean,
|
||||
isExplorable?: boolean,
|
||||
asBadge?: boolean,
|
||||
canEditMembersByModerator?: boolean,
|
||||
usersCount?: number,
|
||||
}, seed?: string): entities.Role {
|
||||
const prefix = params.displayOrder ? params.displayOrder.toString().padStart(3, '0') + '-' : '';
|
||||
const genId = text(36, seed);
|
||||
const createdAt = params.createdAt ?? date({}, seed).toISOString();
|
||||
const updatedAt = params.updatedAt ?? date({}, seed).toISOString();
|
||||
|
||||
return {
|
||||
id: params.id ?? genId,
|
||||
name: params.name ?? `${prefix}TestRole-${genId}`,
|
||||
color: params.color ?? '#445566',
|
||||
iconUrl: params.iconUrl ?? null,
|
||||
description: params.description ?? '',
|
||||
isModerator: params.isModerator ?? false,
|
||||
isAdministrator: params.isAdministrator ?? false,
|
||||
displayOrder: params.displayOrder ?? 0,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
target: params.target ?? 'manual',
|
||||
isPublic: params.isPublic ?? true,
|
||||
isExplorable: params.isExplorable ?? true,
|
||||
asBadge: params.asBadge ?? true,
|
||||
canEditMembersByModerator: params.canEditMembersByModerator ?? false,
|
||||
usersCount: params.usersCount ?? 10,
|
||||
condFormula: {
|
||||
id: '',
|
||||
type: 'or',
|
||||
values: []
|
||||
},
|
||||
policies: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function emoji(params?: {
|
||||
id?: string,
|
||||
name?: string,
|
||||
host?: string,
|
||||
uri?: string,
|
||||
publicUrl?: string,
|
||||
originalUrl?: string,
|
||||
type?: string,
|
||||
aliases?: string[],
|
||||
category?: string,
|
||||
license?: string,
|
||||
isSensitive?: boolean,
|
||||
localOnly?: boolean,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction?: {id:string, name:string}[],
|
||||
updatedAt?: string,
|
||||
}, seed?: string): entities.EmojiDetailedAdmin {
|
||||
const _seed = seed ?? (params?.id ?? "DEFAULT_SEED");
|
||||
const id = params?.id ?? text(32, _seed);
|
||||
const name = params?.name ?? text(8, _seed);
|
||||
const updatedAt = params?.updatedAt ?? date({}, _seed).toISOString();
|
||||
|
||||
const image = imageDataUrl({}, _seed)
|
||||
|
||||
return {
|
||||
id: id,
|
||||
name: name,
|
||||
host: params?.host ?? null,
|
||||
uri: params?.uri ?? null,
|
||||
publicUrl: params?.publicUrl ?? image,
|
||||
originalUrl: params?.originalUrl ?? image,
|
||||
type: params?.type ?? 'image/png',
|
||||
aliases: params?.aliases ?? [`alias1-${name}`, `alias2-${name}`],
|
||||
category: params?.category ?? null,
|
||||
license: params?.license ?? null,
|
||||
isSensitive: params?.isSensitive ?? false,
|
||||
localOnly: params?.localOnly ?? false,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: params?.roleIdsThatCanBeUsedThisEmojiAsReaction ?? [],
|
||||
updatedAt: updatedAt,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,6 +416,10 @@ function toStories(component: string): Promise<string> {
|
|||
glob('src/components/MkUserSetupDialog.*.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'),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"watch": "vite",
|
||||
"dev": "vite --config vite.config.local-dev.ts --debug hmr",
|
||||
"build": "vite build",
|
||||
"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",
|
||||
|
|
@ -31,7 +30,7 @@
|
|||
"@twemoji/parser": "15.1.1",
|
||||
"@vitejs/plugin-vue": "5.2.0",
|
||||
"@vue/compiler-sfc": "3.5.12",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11",
|
||||
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
|
||||
"astring": "1.9.0",
|
||||
"broadcast-channel": "7.0.0",
|
||||
"buraha": "0.0.1",
|
||||
|
|
@ -59,7 +58,7 @@
|
|||
"misskey-reversi": "workspace:*",
|
||||
"moment": "^2.30.1",
|
||||
"photoswipe": "5.4.4",
|
||||
"punycode": "2.3.1",
|
||||
"punycode.js": "2.3.1",
|
||||
"rollup": "4.26.0",
|
||||
"sanitize-html": "2.13.1",
|
||||
"sass": "1.79.3",
|
||||
|
|
@ -108,7 +107,7 @@
|
|||
"@types/matter-js": "0.19.7",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "22.9.0",
|
||||
"@types/punycode": "2.1.4",
|
||||
"@types/punycode.js": "npm:@types/punycode@2.1.4",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/throttle-debounce": "5.0.2",
|
||||
|
|
|
|||
|
|
@ -1,90 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
await main();
|
||||
|
||||
import('@/_boot_.js');
|
||||
|
||||
/**
|
||||
* backend/src/server/web/boot.jsで差し込まれている起動処理のうち、最低限必要なものを模倣するための処理
|
||||
*/
|
||||
async function main() {
|
||||
const forceError = localStorage.getItem('forceError');
|
||||
if (forceError != null) {
|
||||
renderError('FORCED_ERROR', 'This error is forced by having forceError in local storage.');
|
||||
}
|
||||
|
||||
//#region Detect language & fetch translations
|
||||
|
||||
// dev-modeの場合は常に取り直す
|
||||
const supportedLangs = _LANGS_.map(it => it[0]);
|
||||
let lang: string | null | undefined = 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';
|
||||
}
|
||||
}
|
||||
|
||||
// TODO:今のままだと言語ファイル変更後はpnpm devをリスタートする必要があるので、chokidarを使ったり等で対応できるようにする
|
||||
const locale = _LANGS_FULL_.find(it => it[0] === lang);
|
||||
localStorage.setItem('lang', lang);
|
||||
localStorage.setItem('locale', JSON.stringify(locale[1]));
|
||||
localStorage.setItem('localeVersion', _VERSION_);
|
||||
//#endregion
|
||||
|
||||
//#region Theme
|
||||
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');
|
||||
}
|
||||
|
||||
const wallpaper = localStorage.getItem('wallpaper');
|
||||
if (wallpaper) {
|
||||
document.documentElement.style.backgroundImage = `url(${wallpaper})`;
|
||||
}
|
||||
|
||||
const customCss = localStorage.getItem('customCss');
|
||||
if (customCss && customCss.length > 0) {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = customCss;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
|
||||
function renderError(code: string, details?: string) {
|
||||
console.log(code, details);
|
||||
}
|
||||
|
|
@ -102,6 +102,9 @@ export async function removeAccount(idOrToken: Account['id']) {
|
|||
}
|
||||
|
||||
function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Promise<Account> {
|
||||
document.cookie = `token=; path=/; max-age=0${ location.protocol === 'https:' ? '; Secure' : ''}`;
|
||||
document.cookie = `token=; path=/queue; max-age=86400${ location.protocol === 'https:' ? '; SameSite=Strict; Secure' : ''}`; // bull dashboardの認証とかで使う
|
||||
|
||||
return new Promise((done, fail) => {
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
method: 'POST',
|
||||
|
|
@ -150,9 +153,9 @@ function fetchAccount(token: string, id?: string, forceShowDialog?: boolean): Pr
|
|||
} else if (res.error.id === 'd5826d14-3982-4d2e-8011-b9e9f02499ef') {
|
||||
// rate limited
|
||||
const timeToWait = res.error.info?.resetMs ?? 1000;
|
||||
window.setTimeout(timeToWait, () => {
|
||||
window.setTimeout(() => {
|
||||
fetchAccount(token, id, forceShowDialog).then(done, fail);
|
||||
});
|
||||
}, timeToWait);
|
||||
return;
|
||||
} else {
|
||||
await alert({
|
||||
|
|
@ -221,7 +224,6 @@ export async function login(token: Account['token'], redirect?: string) {
|
|||
throw reason;
|
||||
});
|
||||
miLocalStorage.setItem('account', JSON.stringify(me));
|
||||
document.cookie = `token=${token}; path=/; max-age=31536000${ location.protocol === 'https:' ? '; Secure' : ''}`; // bull dashboardの認証とかで使う
|
||||
await addAccount(me.id, token);
|
||||
|
||||
if (redirect) {
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ export async function common(createVue: () => App<Element>) {
|
|||
// タッチデバイスでCSSの:hoverを機能させる
|
||||
document.addEventListener('touchend', () => {}, { passive: true });
|
||||
|
||||
// URLに#pswpを含む場合は取り除く
|
||||
if (location.hash === '#pswp') {
|
||||
history.replaceState(null, '', location.href.replace('#pswp', ''));
|
||||
}
|
||||
|
||||
// 一斉リロード
|
||||
reloadChannel.addEventListener('message', path => {
|
||||
if (path !== null) location.href = path;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { createApp, defineAsyncComponent, markRaw } from 'vue';
|
|||
import { ui } from '@@/js/config.js';
|
||||
import { common } from './common.js';
|
||||
import type * as Misskey from 'misskey-js';
|
||||
import type { Component } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { alert, confirm, popup, post, toast } from '@/os.js';
|
||||
import { useStream } from '@/stream.js';
|
||||
|
|
@ -26,13 +27,38 @@ import { type Keymap, makeHotkey } from '@/scripts/hotkey.js';
|
|||
import { addCustomEmoji, removeCustomEmojis, updateCustomEmojis } from '@/custom-emojis.js';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
|
||||
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
|
||||
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
|
||||
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
|
||||
defineAsyncComponent(() => import('@/ui/universal.vue')),
|
||||
));
|
||||
const { isClientUpdated } = await common(() => {
|
||||
let uiStyle = ui;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
if (!$i) uiStyle = 'visitor';
|
||||
|
||||
if (searchParams.has('zen')) uiStyle = 'zen';
|
||||
if (uiStyle === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') uiStyle = 'zen';
|
||||
|
||||
if (searchParams.has('ui')) uiStyle = searchParams.get('ui');
|
||||
|
||||
let rootComponent: Component;
|
||||
switch (uiStyle) {
|
||||
case 'zen':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/zen.vue'));
|
||||
break;
|
||||
case 'deck':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/deck.vue'));
|
||||
break;
|
||||
case 'visitor':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/visitor.vue'));
|
||||
break;
|
||||
case 'classic':
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/classic.vue'));
|
||||
break;
|
||||
default:
|
||||
rootComponent = defineAsyncComponent(() => import('@/ui/universal.vue'));
|
||||
break;
|
||||
}
|
||||
|
||||
return createApp(rootComponent);
|
||||
});
|
||||
|
||||
reactionPicker.init();
|
||||
emojiPicker.init();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ 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="c.default ?? null" @update:modelValue="c.onChange">
|
||||
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @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>
|
||||
|
|
@ -77,8 +77,8 @@ import MkPostForm from '@/components/MkPostForm.vue';
|
|||
const props = withDefaults(defineProps<{
|
||||
component: AsUiComponent;
|
||||
components: Ref<AsUiComponent>[];
|
||||
size: 'small' | 'medium' | 'large';
|
||||
align: 'left' | 'center' | 'right';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}>(), {
|
||||
size: 'medium',
|
||||
align: 'left',
|
||||
|
|
@ -86,7 +86,7 @@ const props = withDefaults(defineProps<{
|
|||
|
||||
const c = props.component;
|
||||
|
||||
function g(id) {
|
||||
function g(id: string) {
|
||||
const v = props.components.find(x => x.value.id === id)?.value;
|
||||
if (v) return v;
|
||||
|
||||
|
|
@ -122,13 +122,22 @@ const containerStyle = computed(() => {
|
|||
|
||||
const valueForSwitch = ref('default' in c && typeof c.default === 'boolean' ? c.default : false);
|
||||
|
||||
function onSwitchUpdate(v) {
|
||||
function onSwitchUpdate(v: boolean) {
|
||||
valueForSwitch.value = v;
|
||||
if ('onChange' in c && c.onChange) {
|
||||
c.onChange(v as never);
|
||||
}
|
||||
}
|
||||
|
||||
const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null);
|
||||
|
||||
function onSelectUpdate(v) {
|
||||
valueForSelect.value = v;
|
||||
if ('onChange' in c && c.onChange) {
|
||||
c.onChange(v as never);
|
||||
}
|
||||
}
|
||||
|
||||
function openPostForm() {
|
||||
const form = (c as AsUiPostFormButton).form;
|
||||
if (!form) return;
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import { ref, shallowRef, computed, onMounted, onBeforeUnmount, watch, onUnmount
|
|||
import { defaultStore } from '@/store.js';
|
||||
|
||||
// APIs provided by Captcha services
|
||||
// see: https://docs.hcaptcha.com/configuration/#javascript-api
|
||||
// see: https://developers.google.com/recaptcha/docs/display?hl=ja
|
||||
// see: https://developers.cloudflare.com/turnstile/get-started/client-side-rendering/#explicitly-render-the-turnstile-widget
|
||||
export type Captcha = {
|
||||
render(container: string | Node, options: {
|
||||
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
|
||||
|
|
@ -56,6 +59,7 @@ declare global {
|
|||
const props = defineProps<{
|
||||
provider: CaptchaProvider;
|
||||
sitekey: string | null; // null will show error on request
|
||||
secretKey?: string | null;
|
||||
instanceUrl?: string | null;
|
||||
modelValue?: string | null;
|
||||
}>();
|
||||
|
|
@ -67,7 +71,7 @@ const emit = defineEmits<{
|
|||
const available = ref(false);
|
||||
|
||||
const captchaEl = shallowRef<HTMLDivElement | undefined>();
|
||||
|
||||
const captchaWidgetId = ref<string | undefined>(undefined);
|
||||
const testcaptchaInput = ref('');
|
||||
const testcaptchaPassed = ref(false);
|
||||
|
||||
|
|
@ -99,6 +103,15 @@ const scriptId = computed(() => `script-${props.provider}`);
|
|||
|
||||
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
|
||||
|
||||
watch(() => [props.instanceUrl, props.sitekey, props.secretKey], async () => {
|
||||
// 変更があったときはリフレッシュと再レンダリングをしておかないと、変更後の値で再検証が出来ない
|
||||
if (available.value) {
|
||||
callback(undefined);
|
||||
clearWidget();
|
||||
await requestRender();
|
||||
}
|
||||
});
|
||||
|
||||
if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha') {
|
||||
available.value = true;
|
||||
} else if (src.value !== null) {
|
||||
|
|
@ -111,14 +124,38 @@ if (loaded || props.provider === 'mcaptcha' || props.provider === 'testcaptcha')
|
|||
}
|
||||
|
||||
function reset() {
|
||||
if (captcha.value.reset) captcha.value.reset();
|
||||
if (captcha.value.reset && captchaWidgetId.value !== undefined) {
|
||||
try {
|
||||
captcha.value.reset(captchaWidgetId.value);
|
||||
} catch (error: unknown) {
|
||||
// ignore
|
||||
if (_DEV_) console.warn(error);
|
||||
}
|
||||
}
|
||||
testcaptchaPassed.value = false;
|
||||
testcaptchaInput.value = '';
|
||||
}
|
||||
|
||||
function remove() {
|
||||
if (captcha.value.remove && captchaWidgetId.value) {
|
||||
try {
|
||||
if (_DEV_) console.log('remove', props.provider, captchaWidgetId.value);
|
||||
captcha.value.remove(captchaWidgetId.value);
|
||||
} catch (error: unknown) {
|
||||
// ignore
|
||||
if (_DEV_) console.warn(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRender() {
|
||||
if (captcha.value.render && captchaEl.value instanceof Element) {
|
||||
captcha.value.render(captchaEl.value, {
|
||||
if (captcha.value.render && captchaEl.value instanceof Element && props.sitekey) {
|
||||
// reCAPTCHAのレンダリング重複判定を回避するため、captchaEl配下に仮のdivを用意する.
|
||||
// (同じdivに対して複数回renderを呼び出すとreCAPTCHAはエラーを返すので)
|
||||
const elem = document.createElement('div');
|
||||
captchaEl.value.appendChild(elem);
|
||||
|
||||
captchaWidgetId.value = captcha.value.render(elem, {
|
||||
sitekey: props.sitekey,
|
||||
theme: defaultStore.state.darkMode ? 'dark' : 'light',
|
||||
callback: callback,
|
||||
|
|
@ -146,6 +183,23 @@ async function requestRender() {
|
|||
}
|
||||
}
|
||||
|
||||
function clearWidget() {
|
||||
if (props.provider === 'mcaptcha') {
|
||||
const container = document.getElementById('mcaptcha__widget-container');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
} else {
|
||||
reset();
|
||||
remove();
|
||||
|
||||
if (captchaEl.value) {
|
||||
// レンダリング先のコンテナの中身を掃除し、フォームが増殖するのを抑止
|
||||
captchaEl.value.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function callback(response?: string) {
|
||||
emit('update:modelValue', typeof response === 'string' ? response : null);
|
||||
}
|
||||
|
|
@ -178,7 +232,7 @@ onUnmounted(() => {
|
|||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
reset();
|
||||
clearWidget();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
|
|
|
|||
|
|
@ -125,7 +125,9 @@ const bannerStyle = computed(() => {
|
|||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
max-width: calc(100% - 32px);
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<div v-show="showBody" ref="contentEl" :class="[$style.content, { [$style.omitted]: omitted }]">
|
||||
<slot></slot>
|
||||
<button v-if="omitted" :class="$style.fade" class="_button" @click="() => { ignoreOmit = true; omitted = false; }">
|
||||
<button v-if="omitted" :class="$style.fade" class="_button" @click="showMore">
|
||||
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{
|
|||
thin?: boolean;
|
||||
naked?: boolean;
|
||||
foldable?: boolean;
|
||||
onUnfold?: () => boolean; // return false to prevent unfolding
|
||||
scrollable?: boolean;
|
||||
expanded?: boolean;
|
||||
maxHeight?: number | null;
|
||||
|
|
@ -101,6 +102,13 @@ const omitObserver = new ResizeObserver((entries, observer) => {
|
|||
calcOmit();
|
||||
});
|
||||
|
||||
function showMore() {
|
||||
if (props.onUnfold && !props.onUnfold()) return;
|
||||
|
||||
ignoreOmit.value = true;
|
||||
omitted.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
watch(showBody, v => {
|
||||
if (!rootEl.value) return;
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { defineProps, shallowRef } from 'vue';
|
||||
import { shallowRef } from 'vue';
|
||||
import MkLink from '@/components/MkLink.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
|
|
|||
|
|
@ -5,13 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<div
|
||||
ref="thumbnail"
|
||||
:class="[
|
||||
$style.root,
|
||||
{ [$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive },
|
||||
]"
|
||||
v-panel
|
||||
:class="[$style.root, {
|
||||
[$style.sensitiveHighlight]: highlightWhenSensitive && file.isSensitive,
|
||||
[$style.large]: large,
|
||||
}]"
|
||||
>
|
||||
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
|
||||
<ImgWithBlurhash
|
||||
v-if="isThumbnailAvailable"
|
||||
:hash="file.blurhash"
|
||||
:src="file.thumbnailUrl"
|
||||
:alt="file.name"
|
||||
:title="file.name"
|
||||
:cover="fit !== 'contain'"
|
||||
:forceBlurhash="forceBlurhash"
|
||||
/>
|
||||
<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>
|
||||
|
|
@ -34,6 +42,8 @@ const props = defineProps<{
|
|||
file: Misskey.entities.DriveFile;
|
||||
fit: 'cover' | 'contain';
|
||||
highlightWhenSensitive?: boolean;
|
||||
forceBlurhash?: boolean;
|
||||
large?: boolean;
|
||||
}>();
|
||||
|
||||
const is = computed(() => {
|
||||
|
|
@ -60,7 +70,7 @@ const is = computed(() => {
|
|||
|
||||
const isThumbnailAvailable = computed(() => {
|
||||
return props.file.thumbnailUrl
|
||||
? (is.value === 'image' as const || is.value === 'video')
|
||||
? (is.value === 'image' || is.value === 'video')
|
||||
: false;
|
||||
});
|
||||
</script>
|
||||
|
|
@ -101,4 +111,8 @@ const isThumbnailAvailable = computed(() => {
|
|||
font-size: 32px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.large .icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
>
|
||||
<KeepAlive>
|
||||
<div v-show="opened">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="14" :marginMax="22">
|
||||
<MkSpacer v-if="withSpacer" :marginMin="spacerMin" :marginMax="spacerMax">
|
||||
<slot></slot>
|
||||
</MkSpacer>
|
||||
<div v-else>
|
||||
|
|
@ -64,10 +64,14 @@ const props = withDefaults(defineProps<{
|
|||
defaultOpen?: boolean;
|
||||
maxHeight?: number | null;
|
||||
withSpacer?: boolean;
|
||||
spacerMin?: number;
|
||||
spacerMax?: number;
|
||||
}>(), {
|
||||
defaultOpen: false,
|
||||
maxHeight: null,
|
||||
withSpacer: true,
|
||||
spacerMin: 14,
|
||||
spacerMax: 22,
|
||||
});
|
||||
|
||||
const rootEl = shallowRef<HTMLElement>();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.text">{{ i18n.tsx.thereAreNChanges({ n: form.modifiedCount.value }) }}</div>
|
||||
<div style="margin-left: auto;" class="_buttons">
|
||||
<MkButton danger rounded @click="form.discard"><i class="ti ti-x"></i> {{ i18n.ts.discard }}</MkButton>
|
||||
<MkButton primary rounded @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton primary rounded :disabled="!canSaving" @click="form.save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -18,7 +18,7 @@ import { } from 'vue';
|
|||
import MkButton from './MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
form: {
|
||||
modifiedCount: {
|
||||
value: number;
|
||||
|
|
@ -26,7 +26,10 @@ const props = defineProps<{
|
|||
discard: () => void;
|
||||
save: () => void;
|
||||
};
|
||||
}>();
|
||||
canSaving?: boolean;
|
||||
}>(), {
|
||||
canSaving: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.chart">
|
||||
<div class="selects">
|
||||
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
|
||||
<optgroup :label="i18n.ts.federation">
|
||||
<optgroup v-if="shouldShowFederation" :label="i18n.ts.federation">
|
||||
<option value="federation">{{ i18n.ts._charts.federation }}</option>
|
||||
<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
|
||||
</optgroup>
|
||||
|
|
@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<optgroup :label="i18n.ts.notes">
|
||||
<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option>
|
||||
<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option>
|
||||
<option value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
|
||||
<option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
|
||||
<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts.drive">
|
||||
|
|
@ -46,9 +46,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;">
|
||||
<option value="active-users">Active users</option>
|
||||
<option value="notes">Notes</option>
|
||||
<option value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
|
||||
<option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
|
||||
<option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
|
||||
<option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
|
||||
<option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
|
||||
<option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
|
||||
</MkSelect>
|
||||
<div class="_panel" :class="$style.heatmap">
|
||||
<MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/>
|
||||
|
|
@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</MkFoldableSection>
|
||||
|
||||
<MkFoldableSection class="item">
|
||||
<MkFoldableSection v-if="shouldShowFederation" class="item">
|
||||
<template #header>Federation</template>
|
||||
<div :class="$style.federation">
|
||||
<div class="pies">
|
||||
|
|
@ -84,13 +84,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { onMounted, ref, computed, shallowRef } from 'vue';
|
||||
import { Chart } from 'chart.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkChart from '@/components/MkChart.vue';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip.js';
|
||||
import { $i } from '@/account.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApiGet } from '@/scripts/misskey-api.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkHeatmap, { type HeatmapSource } from '@/components/MkHeatmap.vue';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
|
|
@ -100,6 +102,8 @@ import { initChart } from '@/scripts/init-chart.js';
|
|||
|
||||
initChart();
|
||||
|
||||
const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator);
|
||||
|
||||
const chartLimit = 500;
|
||||
const chartSpan = ref<'hour' | 'day'>('hour');
|
||||
const chartSrc = ref('active-users');
|
||||
|
|
|
|||
|
|
@ -4,19 +4,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" :style="bg">
|
||||
<div :class="$style.root" :style="themeColorStyle">
|
||||
<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
|
||||
<div :class="$style.name">{{ instance.name }}</div>
|
||||
<div :class="$style.name">{{ instanceName }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { instanceName } from '@@/js/config.js';
|
||||
import { instance as Instance } from '@/instance.js';
|
||||
import { computed, type CSSProperties } from 'vue';
|
||||
import { instanceName as localInstanceName } from '@@/js/config.js';
|
||||
import { instance as localInstance } from '@/instance.js';
|
||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy.js';
|
||||
|
||||
const props = defineProps<{
|
||||
host: string | null;
|
||||
instance?: {
|
||||
faviconUrl?: string | null
|
||||
name?: string | null
|
||||
|
|
@ -25,18 +26,28 @@ const props = defineProps<{
|
|||
}>();
|
||||
|
||||
// if no instance data is given, this is for the local instance
|
||||
const instance = props.instance ?? {
|
||||
name: instanceName,
|
||||
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement).content,
|
||||
};
|
||||
const instanceName = computed(() => props.host == null ? localInstanceName : props.instance?.name ?? props.host);
|
||||
|
||||
const faviconUrl = computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? '/favicon.ico');
|
||||
const faviconUrl = computed(() => {
|
||||
let imageSrc: string | null = null;
|
||||
if (props.host == null) {
|
||||
if (localInstance.iconUrl == null) {
|
||||
return '/favicon.ico';
|
||||
} else {
|
||||
imageSrc = localInstance.iconUrl;
|
||||
}
|
||||
} else {
|
||||
imageSrc = props.instance?.faviconUrl ?? null;
|
||||
}
|
||||
return getProxiedImageUrlNullable(imageSrc);
|
||||
});
|
||||
|
||||
const themeColor = instance.themeColor ?? '#777777';
|
||||
|
||||
const bg = {
|
||||
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
|
||||
};
|
||||
const themeColorStyle = computed<CSSProperties>(() => {
|
||||
const themeColor = (props.host == null ? localInstance.themeColor : props.instance?.themeColor) ?? '#777777';
|
||||
return {
|
||||
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toUnicode } from 'punycode';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
import { computed } from 'vue';
|
||||
import { host as localHost } from '@@/js/config.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
|
|
|||
|
|
@ -288,20 +288,23 @@ const align = () => {
|
|||
const onOpened = () => {
|
||||
emit('opened');
|
||||
|
||||
// NOTE: Chromatic テストの際に undefined になる場合がある
|
||||
if (content.value == null) return;
|
||||
// contentの子要素にアクセスするためレンダリングの完了を待つ必要がある(nextTickが必要)
|
||||
nextTick(() => {
|
||||
// NOTE: Chromatic テストの際に undefined になる場合がある
|
||||
if (content.value == null) return;
|
||||
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const el = content.value.children[0];
|
||||
el.addEventListener('mousedown', ev => {
|
||||
contentClicking = true;
|
||||
window.addEventListener('mouseup', ev => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
window.setTimeout(() => {
|
||||
contentClicking = false;
|
||||
}, 100);
|
||||
}, { passive: true, once: true });
|
||||
}, { passive: true });
|
||||
// モーダルコンテンツにマウスボタンが押され、コンテンツ外でマウスボタンが離されたときにモーダルバックグラウンドクリックと判定させないためにマウスイベントを監視しフラグ管理する
|
||||
const el = content.value.children[0];
|
||||
el.addEventListener('mousedown', ev => {
|
||||
contentClicking = true;
|
||||
window.addEventListener('mouseup', ev => {
|
||||
// click イベントより先に mouseup イベントが発生するかもしれないのでちょっと待つ
|
||||
window.setTimeout(() => {
|
||||
contentClicking = false;
|
||||
}, 100);
|
||||
}, { passive: true, once: true });
|
||||
}, { passive: true });
|
||||
});
|
||||
};
|
||||
|
||||
const onClosed = () => {
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkAvatar :class="$style.avatar" :user="appearNote.user" :link="!mock" :preview="!mock"/>
|
||||
<div :class="[$style.main, { [$style.clickToOpen]: defaultStore.state.clickToOpen }]" @click.stop="defaultStore.state.clickToOpen ? noteclick(appearNote.id) : undefined">
|
||||
<MkNoteHeader :note="appearNote" :mini="true" @click.stop/>
|
||||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
|
||||
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
|
||||
<div style="container-type: inline-size;">
|
||||
<bdi>
|
||||
<p v-if="appearNote.cw != null" :class="$style.cw">
|
||||
|
|
@ -100,7 +100,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files" @click.stop/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" @click.stop/>
|
||||
<MkPoll v-if="appearNote.poll" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :author="appearNote.user" :emojiUrls="appearNote.emojis" :class="$style.poll" @click.stop/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="false" :showAsQuote="true" :class="$style.urlPreview" @click.stop/>
|
||||
</div>
|
||||
|
|
@ -179,13 +179,23 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<I18n v-else-if="showSoftWordMutedWord !== true" :src="i18n.ts.userSaysSomething" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
</I18n>
|
||||
<I18n v-else :src="i18n.ts.userSaysSomethingAbout" tag="small">
|
||||
<template #name>
|
||||
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
|
||||
<MkUserName :user="appearNote.user"/>
|
||||
</MkA>
|
||||
</template>
|
||||
<template #word>
|
||||
{{ Array.isArray(muted) ? muted.map(words => Array.isArray(words) ? words.join() : words).slice(0, 3).join(' ') : muted }}
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!--
|
||||
|
|
@ -319,6 +329,7 @@ const isDeleted = ref(false);
|
|||
const renoted = ref(false);
|
||||
const muted = ref(checkMute(appearNote.value, $i?.mutedWords));
|
||||
const hardMuted = ref(props.withHardMute && checkMute(appearNote.value, $i?.hardMutedWords, true));
|
||||
const showSoftWordMutedWord = computed(() => defaultStore.state.showSoftWordMutedWord);
|
||||
const translation = ref<Misskey.entities.NotesTranslateResponse | null>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.value.user.instance);
|
||||
|
|
@ -343,13 +354,18 @@ const renoteTooltip = computeRenoteTooltip(renoted);
|
|||
|
||||
/* Overload FunctionにLintが対応していないのでコメントアウト
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: true): boolean;
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): boolean | 'sensitiveMute';
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly: false): Array<string | string[]> | false | 'sensitiveMute';
|
||||
*/
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): boolean | 'sensitiveMute' {
|
||||
function checkMute(noteToCheck: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null, checkOnly = false): Array<string | string[]> | false | 'sensitiveMute' {
|
||||
if (mutedWords != null) {
|
||||
if (checkWordMute(noteToCheck, $i, mutedWords)) return true;
|
||||
if (noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords)) return true;
|
||||
if (noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords)) return true;
|
||||
const result = checkWordMute(noteToCheck, $i, mutedWords);
|
||||
if (Array.isArray(result)) return result;
|
||||
|
||||
const replyResult = noteToCheck.reply && checkWordMute(noteToCheck.reply, $i, mutedWords);
|
||||
if (Array.isArray(replyResult)) return replyResult;
|
||||
|
||||
const renoteResult = noteToCheck.renote && checkWordMute(noteToCheck.renote, $i, mutedWords);
|
||||
if (Array.isArray(renoteResult)) return renoteResult;
|
||||
}
|
||||
|
||||
if (checkOnly) return false;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<img v-for="(role, i) in appearNote.user.badgeRoles" :key="i" v-tooltip="role.name" :class="$style.noteHeaderBadgeRole" :src="role.iconUrl!"/>
|
||||
</div>
|
||||
</div>
|
||||
<MkInstanceTicker v-if="showTicker" :instance="appearNote.user.instance"/>
|
||||
<MkInstanceTicker v-if="showTicker" :host="appearNote.user.host" :instance="appearNote.user.instance"/>
|
||||
</div>
|
||||
</header>
|
||||
<div :class="$style.noteContent">
|
||||
|
|
@ -115,7 +115,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div v-if="appearNote.files && appearNote.files.length > 0">
|
||||
<MkMediaList ref="galleryEl" :mediaList="appearNote.files"/>
|
||||
</div>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll"/>
|
||||
<MkPoll v-if="appearNote.poll" ref="pollViewer" :noteId="appearNote.id" :poll="appearNote.poll" :local="!appearNote.user.host" :class="$style.poll" :author="appearNote.user" :emojiUrls="appearNote.emojis"/>
|
||||
<div v-if="isEnabledUrlPreview">
|
||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" :compact="true" :detail="true" :showAsQuote="true" style="margin-top: 6px;"/>
|
||||
</div>
|
||||
|
|
|
|||
109
packages/frontend/src/components/MkNoteMediaGrid.vue
Normal file
109
packages/frontend/src/components/MkNoteMediaGrid.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<template v-for="file in note.files">
|
||||
<div
|
||||
v-if="(((
|
||||
(defaultStore.state.nsfw === 'force' || file.isSensitive) &&
|
||||
defaultStore.state.nsfw !== 'ignore'
|
||||
) || (defaultStore.state.dataSaver.media && file.type.startsWith('image/'))) &&
|
||||
!showingFiles.has(file.id)
|
||||
)"
|
||||
:class="[$style.filePreview, { [$style.square]: square }]"
|
||||
@click="showingFiles.add(file.id)"
|
||||
>
|
||||
<MkDriveFileThumbnail
|
||||
:file="file"
|
||||
fit="cover"
|
||||
:highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
|
||||
:forceBlurhash="true"
|
||||
:large="true"
|
||||
:class="$style.file"
|
||||
/>
|
||||
<div :class="$style.sensitive">
|
||||
<div>
|
||||
<div v-if="file.isSensitive"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.dataSaver.media && file.size ? ` (${bytes(file.size)})` : '' }}</div>
|
||||
<div v-else><i class="ti ti-photo"></i> {{ defaultStore.state.dataSaver.media && file.size ? bytes(file.size) : i18n.ts.image }}</div>
|
||||
<div>{{ i18n.ts.clickToShow }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MkA v-else :class="[$style.filePreview, { [$style.square]: square }]" :to="notePage(note)">
|
||||
<MkDriveFileThumbnail
|
||||
:file="file"
|
||||
fit="cover"
|
||||
:highlightWhenSensitive="defaultStore.state.highlightSensitiveMedia"
|
||||
:large="true"
|
||||
:class="$style.file"
|
||||
/>
|
||||
</MkA>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import bytes from '@/filters/bytes.js';
|
||||
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
|
||||
defineProps<{
|
||||
note: Misskey.entities.Note;
|
||||
square?: boolean;
|
||||
}>();
|
||||
|
||||
const showingFiles = ref<Set<string>>(new Set());
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.square {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.filePreview {
|
||||
position: relative;
|
||||
height: 128px;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
overflow: clip;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
&.square {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.file {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(var(--MI-radius) / 2);
|
||||
}
|
||||
|
||||
.sensitive {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
124
packages/frontend/src/components/MkPagingButtons.vue
Normal file
124
packages/frontend/src/components/MkPagingButtons.vue
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkButton primary :disabled="min === current" @click="onToPrevButtonClicked"><</MkButton>
|
||||
|
||||
<div :class="$style.buttons">
|
||||
<div v-if="prevDotVisible" :class="$style.headTailButtons">
|
||||
<MkButton @click="onToHeadButtonClicked">{{ min }}</MkButton>
|
||||
<span class="ti ti-dots"/>
|
||||
</div>
|
||||
|
||||
<MkButton
|
||||
v-for="i in buttonRanges" :key="i"
|
||||
:disabled="current === i"
|
||||
@click="onNumberButtonClicked(i)"
|
||||
>
|
||||
{{ i }}
|
||||
</MkButton>
|
||||
|
||||
<div v-if="nextDotVisible" :class="$style.headTailButtons">
|
||||
<span class="ti ti-dots"/>
|
||||
<MkButton @click="onToTailButtonClicked">{{ max }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkButton primary :disabled="max === current" @click="onToNextButtonClicked">></MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { computed, toRefs } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const min = 1;
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'pageChanged', pageNumber: number): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
current: number;
|
||||
max: number;
|
||||
buttonCount: number;
|
||||
}>();
|
||||
|
||||
const { current, max } = toRefs(props);
|
||||
|
||||
const buttonCount = computed(() => Math.min(max.value, props.buttonCount));
|
||||
const buttonCountHalf = computed(() => Math.floor(buttonCount.value / 2));
|
||||
const buttonCountStart = computed(() => Math.min(Math.max(min, current.value - buttonCountHalf.value), max.value - buttonCount.value + 1));
|
||||
const buttonRanges = computed(() => Array.from({ length: buttonCount.value }, (_, i) => buttonCountStart.value + i));
|
||||
|
||||
const prevDotVisible = computed(() => (current.value - 1 > buttonCountHalf.value) && (max.value > buttonCount.value));
|
||||
const nextDotVisible = computed(() => (current.value < max.value - buttonCountHalf.value) && (max.value > buttonCount.value));
|
||||
|
||||
if (_DEV_) {
|
||||
console.log('[MkPagingButtons]', current.value, max.value, buttonCount.value, buttonCountHalf.value);
|
||||
console.log('[MkPagingButtons]', current.value < max.value - buttonCountHalf.value);
|
||||
console.log('[MkPagingButtons]', max.value > buttonCount.value);
|
||||
}
|
||||
|
||||
function onNumberButtonClicked(pageNumber: number) {
|
||||
emit('pageChanged', pageNumber);
|
||||
}
|
||||
|
||||
function onToHeadButtonClicked() {
|
||||
emit('pageChanged', min);
|
||||
}
|
||||
|
||||
function onToPrevButtonClicked() {
|
||||
const newPageNumber = current.value <= min ? min : current.value - 1;
|
||||
emit('pageChanged', newPageNumber);
|
||||
}
|
||||
|
||||
function onToNextButtonClicked() {
|
||||
const newPageNumber = current.value >= max.value ? max.value : current.value + 1;
|
||||
emit('pageChanged', newPageNumber);
|
||||
}
|
||||
|
||||
function onToTailButtonClicked() {
|
||||
emit('pageChanged', max.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
|
||||
button {
|
||||
border-radius: 9999px;
|
||||
min-width: 2.5em;
|
||||
min-height: 2.5em;
|
||||
max-width: 2.5em;
|
||||
max-height: 2.5em;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.headTailButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div :class="$style.bg" :style="{ 'width': `${showResult ? (choice.votes / total * 100) : 0}%` }"></div>
|
||||
<span :class="$style.fg">
|
||||
<template v-if="choice.isVoted"><i class="ti ti-check" style="margin-right: 4px; color: var(--MI_THEME-accent);"></i></template>
|
||||
<Mfm :text="choice.text" :plain="true"/>
|
||||
<Mfm :text="choice.text" :plain="true" :author="author" :emojiUrls="emojiUrls"/>
|
||||
<span v-if="showResult" style="margin-left: 4px; opacity: 0.7;">({{ i18n.tsx._poll.votesCount({ n: choice.votes }) }})</span>
|
||||
</span>
|
||||
</li>
|
||||
|
|
@ -48,6 +48,8 @@ const props = defineProps<{
|
|||
poll: NonNullable<Misskey.entities.Note['poll']>;
|
||||
readOnly?: boolean;
|
||||
local?: boolean;
|
||||
emojiUrls?: Record<string, string>;
|
||||
author?: Misskey.entities.UserLite;
|
||||
}>();
|
||||
|
||||
const remaining = ref(-1);
|
||||
|
|
|
|||
|
|
@ -46,14 +46,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template v-if="posted"></template>
|
||||
<template v-else-if="posting"><MkEllipsis/></template>
|
||||
<template v-else>{{ submitText }}</template>
|
||||
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renote ? 'ti ti-quote' : 'ti ti-send'"></i>
|
||||
<i style="margin-left: 6px;" :class="posted ? 'ti ti-check' : reply ? 'ti ti-arrow-back-up' : renoteTargetNote ? 'ti ti-quote' : 'ti ti-send'"></i>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<MkNoteSimple v-if="reply" :class="$style.targetNote" :hideFiles="true" :note="reply"/>
|
||||
<MkNoteSimple v-if="renote" :class="$style.targetNote" :hideFiles="true" :note="renote"/>
|
||||
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null"><i class="ti ti-x"></i></button></div>
|
||||
<MkNoteSimple v-if="renoteTargetNote" :class="$style.targetNote" :hideFiles="true" :note="renoteTargetNote"/>
|
||||
<div v-if="quoteId" :class="$style.withQuote"><i class="ti ti-quote"></i> {{ i18n.ts.quoteAttached }}<button @click="quoteId = null; renoteTargetNote = null;"><i class="ti ti-x"></i></button></div>
|
||||
<div v-if="visibility === 'specified'" :class="$style.toSpecified">
|
||||
<span style="margin-right: 8px;">{{ i18n.ts.recipient }}</span>
|
||||
<div :class="$style.visibleUsers">
|
||||
|
|
@ -106,13 +106,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw } from 'vue';
|
||||
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, toRaw, type ShallowRef } from 'vue';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import insertTextAtCursor from 'insert-text-at-cursor';
|
||||
import { toASCII } from 'punycode/';
|
||||
import { toASCII } from 'punycode.js';
|
||||
import { host, url } from '@@/js/config.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { PostFormProps } from '@/types/post-form.js';
|
||||
import MkNoteSimple from '@/components/MkNoteSimple.vue';
|
||||
import MkNotePreview from '@/components/MkNotePreview.vue';
|
||||
import XPostFormAttaches from '@/components/MkPostFormAttaches.vue';
|
||||
|
|
@ -136,7 +137,6 @@ import { miLocalStorage } from '@/local-storage.js';
|
|||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { emojiPicker } from '@/scripts/emoji-picker.js';
|
||||
import { mfmFunctionPicker } from '@/scripts/mfm-function-picker.js';
|
||||
import type { PostFormProps } from '@/types/post-form.js';
|
||||
import MkScheduleEditor from '@/components/MkScheduleEditor.vue';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
|
@ -202,12 +202,13 @@ const justEndedComposition = ref(false);
|
|||
const scheduleNote = ref<{
|
||||
scheduledAt: number | null;
|
||||
} | null>(null);
|
||||
const renoteTargetNote: ShallowRef<PostFormProps['renote'] | null> = shallowRef(props.renote);
|
||||
|
||||
const draftKey = computed((): string => {
|
||||
let key = props.channel ? `channel:${props.channel.id}` : '';
|
||||
|
||||
if (props.renote) {
|
||||
key += `renote:${props.renote.id}`;
|
||||
if (renoteTargetNote.value) {
|
||||
key += `renote:${renoteTargetNote.value.id}`;
|
||||
} else if (props.reply) {
|
||||
key += `reply:${props.reply.id}`;
|
||||
} else {
|
||||
|
|
@ -218,7 +219,7 @@ const draftKey = computed((): string => {
|
|||
});
|
||||
|
||||
const placeholder = computed((): string => {
|
||||
if (props.renote) {
|
||||
if (renoteTargetNote.value) {
|
||||
return i18n.ts._postForm.quotePlaceholder;
|
||||
} else if (props.reply) {
|
||||
return i18n.ts._postForm.replyPlaceholder;
|
||||
|
|
@ -238,7 +239,7 @@ const placeholder = computed((): string => {
|
|||
});
|
||||
|
||||
const submitText = computed((): string => {
|
||||
return props.renote
|
||||
return renoteTargetNote.value
|
||||
? i18n.ts.quote
|
||||
: props.reply
|
||||
? i18n.ts.reply
|
||||
|
|
@ -262,11 +263,12 @@ const canPost = computed((): boolean => {
|
|||
1 <= textLength.value ||
|
||||
1 <= files.value.length ||
|
||||
poll.value != null ||
|
||||
props.renote != null ||
|
||||
renoteTargetNote.value != null ||
|
||||
quoteId.value != null
|
||||
) &&
|
||||
(textLength.value <= maxTextLength.value) &&
|
||||
(cwLength.value <= maxCwLength.value) &&
|
||||
(files.value.length <= 16) &&
|
||||
(!poll.value || poll.value.choices.length >= 2);
|
||||
});
|
||||
|
||||
|
|
@ -624,7 +626,7 @@ async function onPaste(ev: ClipboardEvent) {
|
|||
|
||||
const paste = ev.clipboardData.getData('text');
|
||||
|
||||
if (!props.renote && !quoteId.value && paste.startsWith(url + '/notes/')) {
|
||||
if (!renoteTargetNote.value && !quoteId.value && paste.startsWith(url + '/notes/')) {
|
||||
ev.preventDefault();
|
||||
|
||||
os.confirm({
|
||||
|
|
@ -840,7 +842,7 @@ async function post(ev?: MouseEvent) {
|
|||
text: text.value === '' ? null : text.value,
|
||||
fileIds: files.value.length > 0 ? files.value.map(f => f.id) : undefined,
|
||||
replyId: props.reply ? props.reply.id : undefined,
|
||||
renoteId: props.renote ? props.renote.id : quoteId.value ? quoteId.value : undefined,
|
||||
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
|
||||
channelId: props.channel ? props.channel.id : undefined,
|
||||
poll: poll.value,
|
||||
cw: useCw.value ? cw.value ?? '' : null,
|
||||
|
|
@ -930,7 +932,7 @@ async function post(ev?: MouseEvent) {
|
|||
claimAchievement('brainDiver');
|
||||
}
|
||||
|
||||
if (props.renote && (props.renote.userId === $i.id) && text.length > 0) {
|
||||
if (renoteTargetNote.value && (renoteTargetNote.value.userId === $i.id) && text.length > 0) {
|
||||
claimAchievement('selfQuote');
|
||||
}
|
||||
|
||||
|
|
@ -1140,7 +1142,7 @@ onMounted(() => {
|
|||
users.forEach(u => pushVisibleUser(u));
|
||||
});
|
||||
}
|
||||
quoteId.value = init.renote ? init.renote.id : null;
|
||||
quoteId.value = renoteTargetNote.value ? renoteTargetNote.value.id : null;
|
||||
reactionAcceptance.value = init.reactionAcceptance;
|
||||
if (init.isSchedule) {
|
||||
scheduleNote.value = {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</template>
|
||||
</Sortable>
|
||||
<p :class="$style.remain">{{ 16 - props.modelValue.length }}/16</p>
|
||||
<p :class="[$style.remain, {
|
||||
[$style.exceeded]: props.modelValue.length > 16,
|
||||
}]">{{ 16 - props.modelValue.length }}/16</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -239,5 +241,9 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 90%;
|
||||
|
||||
&.exceeded {
|
||||
color: var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
132
packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
Normal file
132
packages/frontend/src/components/MkRemoteEmojiEditDialog.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkWindow
|
||||
ref="windowEl"
|
||||
:initialWidth="400"
|
||||
:initialHeight="500"
|
||||
:canResize="true"
|
||||
@close="windowEl?.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>:{{ name }}:</template>
|
||||
|
||||
<div style="display: flex; flex-direction: column; min-height: 100%;">
|
||||
<MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;">
|
||||
<div class="_gaps_m">
|
||||
<div v-if="imgUrl != null" :class="$style.imgs">
|
||||
<div style="background: #000;" :class="$style.imgContainer">
|
||||
<img :src="imgUrl" :class="$style.img" :alt="name"/>
|
||||
</div>
|
||||
<div style="background: #222;" :class="$style.imgContainer">
|
||||
<img :src="imgUrl" :class="$style.img" :alt="name"/>
|
||||
</div>
|
||||
<div style="background: #ddd;" :class="$style.imgContainer">
|
||||
<img :src="imgUrl" :class="$style.img" :alt="name"/>
|
||||
</div>
|
||||
<div style="background: #fff;" :class="$style.imgContainer">
|
||||
<img :src="imgUrl" :class="$style.img" :alt="name"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.id }}</template>
|
||||
<template #value>{{ name }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.host }}</template>
|
||||
<template #value>{{ host }}</template>
|
||||
</MkKeyValue>
|
||||
<MkKeyValue>
|
||||
<template #key>{{ i18n.ts.license }}</template>
|
||||
<template #value>{{ license }}</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<div :class="$style.footer">
|
||||
<MkButton primary rounded style="margin: 0 auto;" @click="done">
|
||||
<i class="ti ti-plus"></i> {{ i18n.ts.import }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
const props = defineProps<{
|
||||
emoji: {
|
||||
id: string,
|
||||
name: string,
|
||||
host: string,
|
||||
license: string | null,
|
||||
url: string
|
||||
},
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
// 必要なら戻り値を増やす
|
||||
(ev: 'done'): void,
|
||||
(ev: 'closed'): void
|
||||
}>();
|
||||
|
||||
const windowEl = ref<InstanceType<typeof MkWindow> | null>(null);
|
||||
|
||||
const name = computed(() => props.emoji.name);
|
||||
const host = computed(() => props.emoji.host);
|
||||
const license = computed(() => props.emoji.license);
|
||||
const imgUrl = computed(() => props.emoji.url);
|
||||
|
||||
async function done() {
|
||||
await os.apiWithDialog('admin/emoji/copy', {
|
||||
emojiId: props.emoji.id,
|
||||
});
|
||||
|
||||
emit('done');
|
||||
windowEl.value?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.imgs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.imgContainer {
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.img {
|
||||
display: block;
|
||||
height: 64px;
|
||||
width: 64px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: sticky;
|
||||
z-index: 10000;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
padding: 12px;
|
||||
border-top: solid 0.5px var(--MI_THEME-divider);
|
||||
background: var(--MI_THEME-acrylicBg);
|
||||
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
|
||||
backdrop-filter: var(--MI-blur, blur(15px));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { role } from '../../.storybook/fakes.js';
|
||||
import { commonHandlers } from '../../.storybook/mocks.js';
|
||||
import MkRoleSelectDialog from '@/components/MkRoleSelectDialog.vue';
|
||||
|
||||
const roles = [
|
||||
role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'), role({ displayOrder: 1 }, '1'),
|
||||
role({ displayOrder: 2 }, '2'), role({ displayOrder: 2 }, '2'), role({ displayOrder: 3 }, '3'), role({ displayOrder: 3 }, '3'),
|
||||
role({ displayOrder: 4 }, '4'), role({ displayOrder: 5 }, '5'), role({ displayOrder: 6 }, '6'), role({ displayOrder: 7 }, '7'),
|
||||
role({ displayOrder: 999, name: 'privateRole', isPublic: false }, '999'),
|
||||
];
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkRoleSelectDialog,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkRoleSelectDialog v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
initialRoleIds: undefined,
|
||||
infoMessage: undefined,
|
||||
title: undefined,
|
||||
publicOnly: true,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/admin/roles/list', ({ params }) => {
|
||||
return HttpResponse.json(roles);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [() => ({
|
||||
template: '<div style="width:100cqmin"><story/></div>',
|
||||
})],
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const InitialIds = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialRoleIds: [roles[0].id, roles[1].id, roles[4].id, roles[6].id, roles[8].id, roles[10].id],
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const InfoMessage = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
infoMessage: 'This is a message.',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const Title = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
title: 'Select roles',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const Full = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialRoleIds: roles.map(it => it.id),
|
||||
infoMessage: InfoMessage.args.infoMessage,
|
||||
title: Title.args.title,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
|
||||
export const FullWithPrivate = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
initialRoleIds: roles.map(it => it.id),
|
||||
infoMessage: InfoMessage.args.infoMessage,
|
||||
title: Title.args.title,
|
||||
publicOnly: false,
|
||||
},
|
||||
} satisfies StoryObj<typeof MkRoleSelectDialog>;
|
||||
200
packages/frontend/src/components/MkRoleSelectDialog.vue
Normal file
200
packages/frontend/src/components/MkRoleSelectDialog.vue
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkModalWindow
|
||||
ref="windowEl"
|
||||
:withOkButton="false"
|
||||
:okButtonDisabled="false"
|
||||
:width="400"
|
||||
:height="500"
|
||||
@close="onCloseModalWindow"
|
||||
@closed="console.log('MkRoleSelectDialog: closed') ; $emit('dispose')"
|
||||
>
|
||||
<template #header>{{ title }}</template>
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else class="_gaps" :class="$style.root">
|
||||
<div :class="$style.header">
|
||||
<MkButton rounded @click="addRole"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRoles.length > 0" class="_gaps" :class="$style.roleItemArea">
|
||||
<div v-for="role in selectedRoles" :key="role.id" :class="$style.roleItem">
|
||||
<MkRolePreview :class="$style.role" :role="role" :forModeration="true" :detailed="false" style="pointer-events: none;"/>
|
||||
<button class="_button" :class="$style.roleUnAssign" @click="removeRole(role.id)"><i class="ti ti-x"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.roleItemArea" style="text-align: center">
|
||||
{{ i18n.ts._roleSelectDialog.notSelected }}
|
||||
</div>
|
||||
|
||||
<MkInfo v-if="infoMessage">{{ infoMessage }}</MkInfo>
|
||||
|
||||
<div :class="$style.buttons">
|
||||
<MkButton primary @click="onOkClicked">{{ i18n.ts.ok }}</MkButton>
|
||||
<MkButton @click="onCancelClicked">{{ i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkModalWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkRolePreview from '@/components/MkRolePreview.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkSpacer from '@/components/global/MkSpacer.vue';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', value: Misskey.entities.Role[]),
|
||||
(ev: 'close'),
|
||||
(ev: 'dispose'),
|
||||
}>();
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
initialRoleIds?: string[],
|
||||
infoMessage?: string,
|
||||
title?: string,
|
||||
publicOnly: boolean,
|
||||
}>(), {
|
||||
initialRoleIds: undefined,
|
||||
infoMessage: undefined,
|
||||
title: undefined,
|
||||
publicOnly: true,
|
||||
});
|
||||
|
||||
const { initialRoleIds, infoMessage, title, publicOnly } = toRefs(props);
|
||||
|
||||
const windowEl = ref<InstanceType<typeof MkModalWindow>>();
|
||||
const roles = ref<Misskey.entities.Role[]>([]);
|
||||
const selectedRoleIds = ref<string[]>(initialRoleIds.value ?? []);
|
||||
const fetching = ref(false);
|
||||
|
||||
const selectedRoles = computed(() => {
|
||||
const r = roles.value.filter(role => selectedRoleIds.value.includes(role.id));
|
||||
r.sort((a, b) => {
|
||||
if (a.displayOrder !== b.displayOrder) {
|
||||
return b.displayOrder - a.displayOrder;
|
||||
}
|
||||
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
return r;
|
||||
});
|
||||
|
||||
async function fetchRoles() {
|
||||
fetching.value = true;
|
||||
const result = await misskeyApi('admin/roles/list', {});
|
||||
roles.value = result.filter(it => publicOnly.value ? it.isPublic : true);
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
async function addRole() {
|
||||
const items = roles.value
|
||||
.filter(r => r.isPublic)
|
||||
.filter(r => !selectedRoleIds.value.includes(r.id))
|
||||
.map(r => ({ text: r.name, value: r }));
|
||||
|
||||
const { canceled, result: role } = await os.select({ items });
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedRoleIds.value.push(role.id);
|
||||
}
|
||||
|
||||
async function removeRole(roleId: string) {
|
||||
selectedRoleIds.value = selectedRoleIds.value.filter(x => x !== roleId);
|
||||
}
|
||||
|
||||
function onOkClicked() {
|
||||
emit('done', selectedRoles.value);
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
function onCancelClicked() {
|
||||
emit('close');
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
function onCloseModalWindow() {
|
||||
emit('close');
|
||||
windowEl.value?.close();
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
max-height: 410px;
|
||||
height: 410px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.roleItemArea {
|
||||
background-color: var(--MI_THEME-acrylicBg);
|
||||
border-radius: var(--MI-radius);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.roleItem {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.role {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.roleUnAssign {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-left: 8px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.addRoleButton {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
max-width: 32px;
|
||||
max-height: 32px;
|
||||
margin-left: 8px;
|
||||
align-self: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -54,7 +54,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
|
||||
import { query, extractDomain } from '@@/js/url.js';
|
||||
import { host as configHost } from '@@/js/config.js';
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as config from '@@/js/config.js';
|
||||
import MkButton from './MkButton.vue';
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkSpacer :marginMin="20" :marginMax="28">
|
||||
<div class="_gaps_m">
|
||||
<div v-if="instance.disableRegistration">
|
||||
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
|
||||
<div v-if="instance.disableRegistration || instance.federation !== 'all'" class="_gaps_s">
|
||||
<MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
|
||||
<MkInfo v-if="instance.federation === 'specified'" warn>{{ i18n.ts.federationSpecified }}</MkInfo>
|
||||
<MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
|
|
|
|||
11
packages/frontend/src/components/MkSortOrderEditor.define.ts
Normal file
11
packages/frontend/src/components/MkSortOrderEditor.define.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type SortOrderDirection = '+' | '-'
|
||||
|
||||
export type SortOrder<T extends string> = {
|
||||
key: T;
|
||||
direction: SortOrderDirection;
|
||||
}
|
||||
118
packages/frontend/src/components/MkSortOrderEditor.vue
Normal file
118
packages/frontend/src/components/MkSortOrderEditor.vue
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.sortOrderArea">
|
||||
<div :class="$style.sortOrderAreaTags">
|
||||
<MkTagItem
|
||||
v-for="order in currentOrders"
|
||||
:key="order.key"
|
||||
:iconClass="order.direction === '+' ? 'ti ti-arrow-up' : 'ti ti-arrow-down'"
|
||||
:exButtonIconClass="'ti ti-x'"
|
||||
:content="order.key"
|
||||
:class="$style.sortOrderTag"
|
||||
@click="onToggleSortOrderButtonClicked(order)"
|
||||
@exButtonClick="onRemoveSortOrderButtonClicked(order)"
|
||||
/>
|
||||
</div>
|
||||
<MkButton :class="$style.sortOrderAddButton" @click="onAddSortOrderButtonClicked">
|
||||
<span class="ti ti-plus"></span>
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
import { toRefs } from 'vue';
|
||||
import MkTagItem from '@/components/MkTagItem.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import * as os from '@/os.js';
|
||||
import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update', sortOrders: SortOrder<T>[]): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
baseOrderKeyNames: T[];
|
||||
currentOrders: SortOrder<T>[];
|
||||
}>();
|
||||
|
||||
const { currentOrders } = toRefs(props);
|
||||
|
||||
function onToggleSortOrderButtonClicked(order: SortOrder<T>) {
|
||||
switch (order.direction) {
|
||||
case '+':
|
||||
order.direction = '-';
|
||||
break;
|
||||
case '-':
|
||||
order.direction = '+';
|
||||
break;
|
||||
}
|
||||
|
||||
emitOrder(currentOrders.value);
|
||||
}
|
||||
|
||||
function onAddSortOrderButtonClicked(ev: MouseEvent) {
|
||||
const menuItems: MenuItem[] = props.baseOrderKeyNames
|
||||
.filter(baseKey => !currentOrders.value.map(it => it.key).includes(baseKey))
|
||||
.map(it => {
|
||||
return {
|
||||
text: it,
|
||||
action: () => {
|
||||
emitOrder([...currentOrders.value, { key: it, direction: '+' }]);
|
||||
},
|
||||
};
|
||||
});
|
||||
os.contextMenu(menuItems, ev);
|
||||
}
|
||||
|
||||
function onRemoveSortOrderButtonClicked(order: SortOrder<T>) {
|
||||
emitOrder(currentOrders.value.filter(it => it.key !== order.key));
|
||||
}
|
||||
|
||||
function emitOrder(sortOrders: SortOrder<T>[]) {
|
||||
emit('update', sortOrders);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.sortOrderArea {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.sortOrderAreaTags {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sortOrderAddButton {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-width: 2.0em;
|
||||
min-height: 2.0em;
|
||||
max-width: 2.0em;
|
||||
max-height: 2.0em;
|
||||
padding: 8px;
|
||||
margin-left: auto;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--MI_THEME-buttonBg);
|
||||
}
|
||||
|
||||
.sortOrderTag {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -27,7 +27,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</details>
|
||||
<details v-if="note.poll">
|
||||
<summary>{{ i18n.ts.poll }}</summary>
|
||||
<MkPoll :noteId="note.id" :poll="note.poll"/>
|
||||
<MkPoll :noteId="note.id" :poll="note.poll" :author="note.user" :emojiUrls="note.emojis"/>
|
||||
</details>
|
||||
<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click.stop="collapsed = false">
|
||||
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
|
||||
|
|
@ -42,11 +42,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { ref, computed, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { shouldCollapsed } from '@@/js/collapsed.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import * as os from '@/os.js';
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export type SuperMenuDef = {
|
|||
active?: boolean;
|
||||
action: (ev: MouseEvent) => void;
|
||||
} | {
|
||||
type: 'link';
|
||||
type?: 'link';
|
||||
to: string;
|
||||
icon?: string;
|
||||
text: string;
|
||||
|
|
|
|||
70
packages/frontend/src/components/MkTagItem.stories.impl.ts
Normal file
70
packages/frontend/src/components/MkTagItem.stories.impl.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
/* eslint-disable import/no-default-export */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import MkTagItem from './MkTagItem.vue';
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkTagItem: MkTagItem,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
click: action('click'),
|
||||
exButtonClick: action('exButtonClick'),
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkTagItem v-bind="props" v-on="events"></MkTagItem>',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
content: 'name',
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
|
||||
export const Icon = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
iconClass: 'ti ti-arrow-up',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
|
||||
export const ExButton = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
exButtonIconClass: 'ti ti-x',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
|
||||
export const IconExButton = {
|
||||
...Default,
|
||||
args: {
|
||||
...Default.args,
|
||||
iconClass: 'ti ti-arrow-up',
|
||||
exButtonIconClass: 'ti ti-x',
|
||||
},
|
||||
} satisfies StoryObj<typeof MkTagItem>;
|
||||
76
packages/frontend/src/components/MkTagItem.vue
Normal file
76
packages/frontend/src/components/MkTagItem.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div :class="$style.root" @click="(ev) => emit('click', ev)">
|
||||
<span v-if="iconClass" :class="[$style.icon, iconClass]"></span>
|
||||
<span :class="$style.content">{{ content }}</span>
|
||||
<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
|
||||
<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'click', payload: MouseEvent): void;
|
||||
(ev: 'exButtonClick', payload: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
iconClass?: string;
|
||||
content: string;
|
||||
exButtonIconClass?: string
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$buttonSize : 1.8em;
|
||||
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
padding: 4px 6px;
|
||||
gap: 3px;
|
||||
|
||||
background-color: var(--MI_THEME-buttonBg);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--MI_THEME-buttonHoverBg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.70em;
|
||||
}
|
||||
|
||||
.exButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
max-height: $buttonSize;
|
||||
max-width: $buttonSize;
|
||||
min-height: $buttonSize;
|
||||
min-width: $buttonSize;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.65em;
|
||||
}
|
||||
|
||||
.exButtonIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.80em;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header>{{ i18n.ts.selectUser }}</template>
|
||||
<div>
|
||||
<div :class="$style.form">
|
||||
<MkInput v-if="localOnly" v-model="username" :autofocus="true" @update:modelValue="search">
|
||||
<MkInput v-if="computedLocalOnly" v-model="username" :autofocus="true" @update:modelValue="search">
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
|
|
@ -61,7 +61,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, shallowRef } from 'vue';
|
||||
import { onMounted, ref, computed, shallowRef } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
|
|
@ -70,6 +70,7 @@ import { misskeyApi } from '@/scripts/misskey-api.js';
|
|||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/account.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { host as currentHost, hostname } from '@@/js/config.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
|
@ -86,6 +87,8 @@ const props = withDefaults(defineProps<{
|
|||
localOnly: false,
|
||||
});
|
||||
|
||||
const computedLocalOnly = computed(() => props.localOnly || instance.federation === 'none');
|
||||
|
||||
const username = ref('');
|
||||
const host = ref('');
|
||||
const users = ref<Misskey.entities.UserLite[]>([]);
|
||||
|
|
@ -101,7 +104,7 @@ function search() {
|
|||
|
||||
misskeyApi('users/search-by-username-and-host', {
|
||||
username: username.value,
|
||||
host: props.localOnly ? '.' : host.value,
|
||||
host: computedLocalOnly.value ? '.' : host.value,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(_users => {
|
||||
|
|
@ -143,7 +146,7 @@ onMounted(() => {
|
|||
}).then(foundUsers => {
|
||||
let _users = foundUsers;
|
||||
_users = _users.filter((u) => {
|
||||
if (props.localOnly) {
|
||||
if (computedLocalOnly.value) {
|
||||
return u.host == null;
|
||||
} else {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -18,8 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="sanitizeHtml(instance.description) || i18n.ts.headlineMisskey"></div>
|
||||
</div>
|
||||
<div v-if="instance.disableRegistration" :class="$style.mainWarn">
|
||||
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
|
||||
<div v-if="instance.disableRegistration || instance.federation !== 'all'" :class="$style.mainWarn" class="_gaps_s">
|
||||
<MkInfo v-if="instance.disableRegistration" warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
|
||||
<MkInfo v-if="instance.federation === 'specified'" warn>{{ i18n.ts.federationSpecified }}</MkInfo>
|
||||
<MkInfo v-else-if="instance.federation === 'none'" warn>{{ i18n.ts.federationDisabled }}</MkInfo>
|
||||
</div>
|
||||
<div v-if="instance.approvalRequiredForSignup" :class="$style.mainWarn">
|
||||
<MkInfo warn>{{ i18n.ts.approvalRequiredToRegister }}</MkInfo>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<header :class="$style.editHeader">
|
||||
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
|
||||
<template #label>{{ i18n.ts.selectWidget }}</template>
|
||||
<option v-for="widget in widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
|
||||
<option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
|
||||
</MkSelect>
|
||||
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
<MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton>
|
||||
|
|
@ -34,7 +34,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
</Sortable>
|
||||
</template>
|
||||
<component :is="`widget-${widget.name}`" v-for="widget in widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
|
||||
<component :is="`widget-${widget.name}`" v-for="widget in _widgets" v-else :key="widget.id" :ref="el => widgetRefs[widget.id] = el" :class="$style.widget" :widget="widget" @updateProps="updateWidget(widget.id, $event)" @contextmenu.stop="onContextmenu(widget, $event)"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -50,13 +50,14 @@ export type DefaultStoredWidget = {
|
|||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { defineAsyncComponent, ref, computed } from 'vue';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { widgets as widgetDefs } from '@/widgets/index.js';
|
||||
import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { isLink } from '@@/js/is-link.js';
|
||||
|
||||
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
|
||||
|
|
@ -66,6 +67,16 @@ const props = defineProps<{
|
|||
edit: boolean;
|
||||
}>();
|
||||
|
||||
const _widgetDefs = computed(() => {
|
||||
if (instance.federation === 'none') {
|
||||
return widgetDefs.filter(x => !federationWidgets.includes(x));
|
||||
} else {
|
||||
return widgetDefs;
|
||||
}
|
||||
});
|
||||
|
||||
const _widgets = computed(() => props.widgets.filter(x => _widgetDefs.value.includes(x.name)));
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'updateWidgets', widgets: Widget[]): void;
|
||||
(ev: 'addWidget', widget: Widget): void;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
import { host as hostRaw } from '@@/js/config.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
|
||||
|
|
|
|||
|
|
@ -57,13 +57,16 @@ import { scrollToTop } from '@@/js/scroll.js';
|
|||
import { globalEvents } from '@/events.js';
|
||||
import { injectReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||
import { $i, openAccountMenu as openAccountMenu_ } from '@/account.js';
|
||||
import { PageHeaderItem } from '@/types/page-header.js';
|
||||
import type { PageHeaderItem } from '@/types/page-header.js';
|
||||
import type { PageMetadata } from '@/scripts/page-metadata.js';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
overridePageMetadata?: PageMetadata;
|
||||
tabs?: Tab[];
|
||||
tab?: string;
|
||||
actions?: PageHeaderItem[] | null;
|
||||
thin?: boolean;
|
||||
hideTitle?: boolean;
|
||||
displayMyAvatar?: boolean;
|
||||
displayBackButton?: boolean;
|
||||
}>(), {
|
||||
|
|
@ -76,9 +79,10 @@ const emit = defineEmits<{
|
|||
|
||||
const displayBackButton = props.displayBackButton && history.state.key !== 'index' && history.length > 1 && inject('shouldBackButton', true);
|
||||
|
||||
const pageMetadata = injectReactiveMetadata();
|
||||
const injectedPageMetadata = injectReactiveMetadata();
|
||||
const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value);
|
||||
|
||||
const hideTitle = inject('shouldOmitHeaderTitle', false);
|
||||
const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
|
||||
const thin_ = props.thin || inject('shouldHeaderThin', false);
|
||||
|
||||
const el = shallowRef<HTMLElement | undefined>(undefined);
|
||||
|
|
@ -87,7 +91,7 @@ const narrow = ref(false);
|
|||
const hasTabs = computed(() => props.tabs.length > 0);
|
||||
const hasActions = computed(() => props.actions && props.actions.length > 0);
|
||||
const show = computed(() => {
|
||||
return !hideTitle || hasTabs.value || hasActions.value;
|
||||
return !hideTitle.value || hasTabs.value || hasActions.value;
|
||||
});
|
||||
|
||||
const preventDrag = (ev: TouchEvent) => {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { toUnicode as decodePunycode } from 'punycode/';
|
||||
import { toUnicode as decodePunycode } from 'punycode.js';
|
||||
import { url as local } from '@@/js/config.js';
|
||||
import * as os from '@/os.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
|
|
|
|||
35
packages/frontend/src/components/grid/MkCellTooltip.vue
Normal file
35
packages/frontend/src/components/grid/MkCellTooltip.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkTooltip ref="tooltip" :showing="showing" :targetElement="targetElement" :maxWidth="250" @closed="emit('closed')">
|
||||
<div :class="$style.root">
|
||||
{{ content }}
|
||||
</div>
|
||||
</MkTooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkTooltip from '@/components/MkTooltip.vue';
|
||||
|
||||
defineProps<{
|
||||
showing: boolean;
|
||||
content: string;
|
||||
targetElement: HTMLElement;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
font-size: 0.9em;
|
||||
text-align: left;
|
||||
text-wrap: normal;
|
||||
}
|
||||
</style>
|
||||
418
packages/frontend/src/components/grid/MkDataCell.vue
Normal file
418
packages/frontend/src/components/grid/MkDataCell.vue
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="cell.row.using"
|
||||
ref="rootEl"
|
||||
class="mk_grid_td"
|
||||
:class="$style.cell"
|
||||
:style="{ maxWidth: cellWidth, minWidth: cellWidth }"
|
||||
:tabindex="-1"
|
||||
data-grid-cell
|
||||
:data-grid-cell-row="cell.row.index"
|
||||
:data-grid-cell-col="cell.column.index"
|
||||
@keydown="onCellKeyDown"
|
||||
@dblclick.prevent="onCellDoubleClick"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
$style.root,
|
||||
[(cell.violation.valid || cell.selected) ? {} : $style.error],
|
||||
[cell.selected ? $style.selected : {}],
|
||||
// 行が選択されているときは範囲選択色の適用を行側に任せる
|
||||
[(cell.ranged && !cell.row.ranged) ? $style.ranged : {}],
|
||||
[needsContentCentering ? $style.center : {}],
|
||||
]"
|
||||
>
|
||||
<div v-if="!editing" :class="[$style.contentArea]" :style="cellType === 'boolean' ? 'justify-content: center' : ''">
|
||||
<div ref="contentAreaEl" :class="$style.content">
|
||||
<div v-if="cellType === 'text'">
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-if="cellType === 'number'">
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-if="cellType === 'date'">
|
||||
{{ cell.value }}
|
||||
</div>
|
||||
<div v-else-if="cellType === 'boolean'">
|
||||
<div :class="[$style.bool, {
|
||||
[$style.boolTrue]: cell.value === true,
|
||||
'ti ti-check': cell.value === true,
|
||||
}]"></div>
|
||||
</div>
|
||||
<div v-else-if="cellType === 'image'">
|
||||
<img
|
||||
:src="cell.value"
|
||||
:alt="cell.value"
|
||||
:class="$style.viewImage"
|
||||
@load="emitContentSizeChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else ref="inputAreaEl" :class="$style.inputArea">
|
||||
<input
|
||||
v-if="cellType === 'text'"
|
||||
type="text"
|
||||
:class="$style.editingInput"
|
||||
:value="editingValue"
|
||||
@input="onInputText"
|
||||
@mousedown.stop
|
||||
@contextmenu.stop
|
||||
/>
|
||||
<input
|
||||
v-if="cellType === 'number'"
|
||||
type="number"
|
||||
:class="$style.editingInput"
|
||||
:value="editingValue"
|
||||
@input="onInputText"
|
||||
@mousedown.stop
|
||||
@contextmenu.stop
|
||||
/>
|
||||
<input
|
||||
v-if="cellType === 'date'"
|
||||
type="date"
|
||||
:class="$style.editingInput"
|
||||
:value="editingValue"
|
||||
@input="onInputText"
|
||||
@mousedown.stop
|
||||
@contextmenu.stop
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, shallowRef, toRefs, watch } from 'vue';
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import { useTooltip } from '@/scripts/use-tooltip.js';
|
||||
import * as os from '@/os.js';
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { equalCellAddress, getCellAddress } from '@/components/grid/grid-utils.js';
|
||||
import { GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
cell: GridCell,
|
||||
rowSetting: GridRowSetting,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
|
||||
const { cell, bus } = toRefs(props);
|
||||
|
||||
const rootEl = shallowRef<InstanceType<typeof HTMLTableCellElement>>();
|
||||
const contentAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
|
||||
const inputAreaEl = shallowRef<InstanceType<typeof HTMLDivElement>>();
|
||||
|
||||
/** 値が編集中かどうか */
|
||||
const editing = ref<boolean>(false);
|
||||
/** 編集中の値. {@link beginEditing}と{@link endEditing}内、および各inputタグやそのコールバックからの操作のみを想定する */
|
||||
const editingValue = ref<CellValue>(undefined);
|
||||
|
||||
const cellWidth = computed(() => cell.value.column.width);
|
||||
const cellType = computed(() => cell.value.column.setting.type);
|
||||
const needsContentCentering = computed(() => {
|
||||
switch (cellType.value) {
|
||||
case 'boolean':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(() => [cell.value.value], () => {
|
||||
// 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
|
||||
nextTick(emitContentSizeChanged);
|
||||
}, { immediate: true });
|
||||
|
||||
watch(() => cell.value.selected, () => {
|
||||
if (cell.value.selected) {
|
||||
requestFocus();
|
||||
}
|
||||
});
|
||||
|
||||
function onCellDoubleClick(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'dblclick': {
|
||||
beginEditing(ev.target as HTMLElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideMouseDown(ev: MouseEvent) {
|
||||
const isOutside = ev.target instanceof Node && !rootEl.value?.contains(ev.target);
|
||||
if (isOutside || !equalCellAddress(cell.value.address, getCellAddress(ev.target as HTMLElement))) {
|
||||
endEditing(true, false);
|
||||
}
|
||||
}
|
||||
|
||||
function onCellKeyDown(ev: KeyboardEvent) {
|
||||
if (!editing.value) {
|
||||
ev.preventDefault();
|
||||
switch (ev.code) {
|
||||
case 'NumpadEnter':
|
||||
case 'Enter':
|
||||
case 'F2': {
|
||||
beginEditing(ev.target as HTMLElement);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
switch (ev.code) {
|
||||
case 'Escape': {
|
||||
endEditing(false, true);
|
||||
break;
|
||||
}
|
||||
case 'NumpadEnter':
|
||||
case 'Enter': {
|
||||
if (!ev.isComposing) {
|
||||
endEditing(true, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInputText(ev: Event) {
|
||||
editingValue.value = (ev.target as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
function onForceRefreshContentSize() {
|
||||
emitContentSizeChanged();
|
||||
}
|
||||
|
||||
function registerOutsideMouseDown() {
|
||||
unregisterOutsideMouseDown();
|
||||
addEventListener('mousedown', onOutsideMouseDown);
|
||||
}
|
||||
|
||||
function unregisterOutsideMouseDown() {
|
||||
removeEventListener('mousedown', onOutsideMouseDown);
|
||||
}
|
||||
|
||||
async function beginEditing(target: HTMLElement) {
|
||||
if (editing.value || !cell.value.selected || !cell.value.column.setting.editable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (cell.value.column.setting.customValueEditor) {
|
||||
emit('operation:beginEdit', cell.value);
|
||||
const newValue = await cell.value.column.setting.customValueEditor(
|
||||
cell.value.row,
|
||||
cell.value.column,
|
||||
cell.value.value,
|
||||
target,
|
||||
);
|
||||
emit('operation:endEdit', cell.value);
|
||||
|
||||
if (newValue !== cell.value.value) {
|
||||
emitValueChange(newValue);
|
||||
}
|
||||
|
||||
requestFocus();
|
||||
} else {
|
||||
switch (cellType.value) {
|
||||
case 'number':
|
||||
case 'date':
|
||||
case 'text': {
|
||||
editingValue.value = cell.value.value;
|
||||
editing.value = true;
|
||||
registerOutsideMouseDown();
|
||||
emit('operation:beginEdit', cell.value);
|
||||
|
||||
await nextTick(() => {
|
||||
// inputの展開後にフォーカスを当てたい
|
||||
if (inputAreaEl.value) {
|
||||
(inputAreaEl.value.querySelector('*') as HTMLElement).focus();
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'boolean': {
|
||||
// とくに特殊なUIは設けず、トグルするだけ
|
||||
emitValueChange(!cell.value.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function endEditing(applyValue: boolean, requireFocus: boolean) {
|
||||
if (!editing.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = editingValue.value;
|
||||
editingValue.value = undefined;
|
||||
|
||||
emit('operation:endEdit', cell.value);
|
||||
unregisterOutsideMouseDown();
|
||||
|
||||
if (applyValue && newValue !== cell.value.value) {
|
||||
emitValueChange(newValue);
|
||||
}
|
||||
|
||||
editing.value = false;
|
||||
|
||||
if (requireFocus) {
|
||||
requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
function requestFocus() {
|
||||
nextTick(() => {
|
||||
rootEl.value?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function emitValueChange(newValue: CellValue) {
|
||||
const _cell = cell.value;
|
||||
emit('change:value', _cell, newValue);
|
||||
}
|
||||
|
||||
function emitContentSizeChanged() {
|
||||
emit('change:contentSize', cell.value, {
|
||||
width: contentAreaEl.value?.clientWidth ?? 0,
|
||||
height: contentAreaEl.value?.clientHeight ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
useTooltip(rootEl, (showing) => {
|
||||
if (cell.value.violation.valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = cell.value.violation.violations.filter(it => !it.valid).map(it => it.result.message).join('\n');
|
||||
const result = os.popup(defineAsyncComponent(() => import('@/components/grid/MkCellTooltip.vue')), {
|
||||
showing,
|
||||
content,
|
||||
targetElement: rootEl.value!,
|
||||
}, {
|
||||
closed: () => {
|
||||
result.dispose();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$cellHeight: 28px;
|
||||
|
||||
.cell {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
height: $cellHeight;
|
||||
max-height: $cellHeight;
|
||||
min-height: $cellHeight;
|
||||
cursor: cell;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
|
||||
// selected適用時に中身がズレてしまうので、透明の線をあらかじめ引いておきたい
|
||||
border: solid 0.5px transparent;
|
||||
|
||||
&.selected {
|
||||
border: solid 0.5px var(--MI_THEME-accentLighten);
|
||||
}
|
||||
|
||||
&.ranged {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border: solid 0.5px var(--MI_THEME-error);
|
||||
}
|
||||
}
|
||||
|
||||
.contentArea, .inputArea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.viewImage {
|
||||
width: auto;
|
||||
max-height: $cellHeight;
|
||||
height: $cellHeight;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bool {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--MI_THEME-panel);
|
||||
border: solid 2px var(--MI_THEME-divider);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.boolTrue {
|
||||
border-color: var(--MI_THEME-accent);
|
||||
background: var(--MI_THEME-accent);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: var(--MI_THEME-fgOnAccent);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.editingInput {
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: $cellHeight - 2;
|
||||
max-height: $cellHeight - 2;
|
||||
height: $cellHeight - 2;
|
||||
outline: none;
|
||||
border: none;
|
||||
font-family: 'Hiragino Maru Gothic Pro', "BIZ UDGothic", Roboto, HelveticaNeue, Arial, sans-serif;
|
||||
}
|
||||
|
||||
</style>
|
||||
72
packages/frontend/src/components/grid/MkDataRow.vue
Normal file
72
packages/frontend/src/components/grid/MkDataRow.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mk_grid_tr"
|
||||
:class="[
|
||||
$style.row,
|
||||
row.ranged ? $style.ranged : {},
|
||||
...(row.additionalStyles ?? []).map(it => it.className ?? {}),
|
||||
]"
|
||||
:style="[
|
||||
...(row.additionalStyles ?? []).map(it => it.style ?? {}),
|
||||
]"
|
||||
:data-grid-row="row.index"
|
||||
>
|
||||
<MkNumberCell
|
||||
v-if="setting.showNumber"
|
||||
:content="(row.index + 1).toString()"
|
||||
:row="row"
|
||||
/>
|
||||
<MkDataCell
|
||||
v-for="cell in cells"
|
||||
:key="cell.address.col"
|
||||
:vIf="cell.column.setting.type !== 'hidden'"
|
||||
:cell="cell"
|
||||
:rowSetting="setting"
|
||||
:bus="bus"
|
||||
@operation:beginEdit="(sender) => emit('operation:beginEdit', sender)"
|
||||
@operation:endEdit="(sender) => emit('operation:endEdit', sender)"
|
||||
@change:value="(sender, newValue) => emit('change:value', sender, newValue)"
|
||||
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import MkDataCell from '@/components/grid/MkDataCell.vue';
|
||||
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridRow, GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginEdit', sender: GridCell): void;
|
||||
(ev: 'operation:endEdit', sender: GridCell): void;
|
||||
(ev: 'change:value', sender: GridCell, newValue: CellValue): void;
|
||||
(ev: 'change:contentSize', sender: GridCell, newSize: Size): void;
|
||||
}>();
|
||||
defineProps<{
|
||||
row: GridRow,
|
||||
cells: GridCell[],
|
||||
setting: GridRowSetting,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
|
||||
&.ranged {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
223
packages/frontend/src/components/grid/MkGrid.stories.impl.ts
Normal file
223
packages/frontend/src/components/grid/MkGrid.stories.impl.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { ref } from 'vue';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import { boolean, choose, country, date, firstName, integer, lastName, text } from '../../../.storybook/fake-utils.js';
|
||||
import MkGrid from './MkGrid.vue';
|
||||
import { GridContext, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import { DataSource, GridSetting } from '@/components/grid/grid.js';
|
||||
import { GridColumnSetting } from '@/components/grid/column.js';
|
||||
|
||||
function d(p: {
|
||||
check?: boolean,
|
||||
name?: string,
|
||||
email?: string,
|
||||
age?: number,
|
||||
birthday?: string,
|
||||
gender?: string,
|
||||
country?: string,
|
||||
reportCount?: number,
|
||||
createdAt?: string,
|
||||
}, seed: string) {
|
||||
const prefix = text(10, seed);
|
||||
|
||||
return {
|
||||
check: p.check ?? boolean(seed),
|
||||
name: p.name ?? `${firstName(seed)} ${lastName(seed)}`,
|
||||
email: p.email ?? `${prefix}@example.com`,
|
||||
age: p.age ?? integer(20, 80, seed),
|
||||
birthday: date({}, seed).toISOString(),
|
||||
gender: p.gender ?? choose(['male', 'female', 'other', 'unknown'], seed),
|
||||
country: p.country ?? country(seed),
|
||||
reportCount: p.reportCount ?? integer(0, 9999, seed),
|
||||
createdAt: p.createdAt ?? date({}, seed).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultCols: GridColumnSetting[] = [
|
||||
{ bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50 },
|
||||
{ bindTo: 'name', title: 'Name', type: 'text', width: 'auto' },
|
||||
{ bindTo: 'email', title: 'Email', type: 'text', width: 'auto' },
|
||||
{ bindTo: 'age', title: 'Age', type: 'number', width: 50 },
|
||||
{ bindTo: 'birthday', title: 'Birthday', type: 'date', width: 'auto' },
|
||||
{ bindTo: 'gender', title: 'Gender', type: 'text', width: 80 },
|
||||
{ bindTo: 'country', title: 'Country', type: 'text', width: 120 },
|
||||
{ bindTo: 'reportCount', title: 'ReportCount', type: 'number', width: 'auto' },
|
||||
{ bindTo: 'createdAt', title: 'CreatedAt', type: 'date', width: 'auto' },
|
||||
];
|
||||
|
||||
function createArgs(overrides?: { settings?: Partial<GridSetting>, data?: DataSource[] }) {
|
||||
const refData = ref<ReturnType<typeof d>[]>([]);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
refData.value.push(d({}, i.toString()));
|
||||
}
|
||||
|
||||
return {
|
||||
settings: {
|
||||
row: overrides?.settings?.row,
|
||||
cols: [
|
||||
...defaultCols.filter(col => overrides?.settings?.cols?.every(c => c.bindTo !== col.bindTo) ?? true),
|
||||
...overrides?.settings?.cols ?? [],
|
||||
],
|
||||
cells: overrides?.settings?.cells,
|
||||
},
|
||||
data: refData.value,
|
||||
};
|
||||
}
|
||||
|
||||
function createRender(params: { settings: GridSetting, data: DataSource[] }) {
|
||||
return {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkGrid,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
data: args.data,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...args,
|
||||
};
|
||||
},
|
||||
events() {
|
||||
return {
|
||||
event: (event: GridEvent, context: GridContext) => {
|
||||
switch (event.type) {
|
||||
case 'cell-value-change': {
|
||||
args.data[event.row.index][event.column.setting.bindTo] = event.newValue;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<div style="padding:20px"><MkGrid v-bind="props" v-on="events" /></div>',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
...params,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof MkGrid>;
|
||||
}
|
||||
|
||||
export const Default = createRender(createArgs());
|
||||
|
||||
export const NoNumber = createRender(createArgs({
|
||||
settings: {
|
||||
row: {
|
||||
showNumber: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const NoSelectable = createRender(createArgs({
|
||||
settings: {
|
||||
row: {
|
||||
selectable: false,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const Editable = createRender(createArgs({
|
||||
settings: {
|
||||
cols: defaultCols.map(col => ({ ...col, editable: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
export const AdditionalRowStyle = createRender(createArgs({
|
||||
settings: {
|
||||
cols: defaultCols.map(col => ({ ...col, editable: true })),
|
||||
row: {
|
||||
styleRules: [
|
||||
{
|
||||
condition: ({ row }) => AdditionalRowStyle.args.data[row.index].check as boolean,
|
||||
applyStyle: {
|
||||
style: {
|
||||
backgroundColor: 'lightgray',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
export const ContextMenu = createRender(createArgs({
|
||||
settings: {
|
||||
cols: [
|
||||
{
|
||||
bindTo: 'check', icon: 'ti-check', type: 'boolean', width: 50, contextMenuFactory: (col, context) => [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Check All',
|
||||
action: () => {
|
||||
for (const d of ContextMenu.args.data) {
|
||||
d.check = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Uncheck All',
|
||||
action: () => {
|
||||
for (const d of ContextMenu.args.data) {
|
||||
d.check = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
row: {
|
||||
contextMenuFactory: (row, context) => [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
action: () => {
|
||||
const idxes = context.rangedRows.map(r => r.index);
|
||||
const newData = ContextMenu.args.data.filter((d, i) => !idxes.includes(i));
|
||||
|
||||
ContextMenu.args.data.splice(0);
|
||||
ContextMenu.args.data.push(...newData);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
cells: {
|
||||
contextMenuFactory: (col, row, value, context) => [
|
||||
{
|
||||
type: 'button',
|
||||
text: 'Delete',
|
||||
action: () => {
|
||||
for (const cell of context.rangedCells) {
|
||||
ContextMenu.args.data[cell.row.index][cell.column.setting.bindTo] = undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}));
|
||||
1374
packages/frontend/src/components/grid/MkGrid.vue
Normal file
1374
packages/frontend/src/components/grid/MkGrid.vue
Normal file
File diff suppressed because it is too large
Load diff
216
packages/frontend/src/components/grid/MkHeaderCell.vue
Normal file
216
packages/frontend/src/components/grid/MkHeaderCell.vue
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="mk_grid_th"
|
||||
:class="$style.cell"
|
||||
:style="[{ maxWidth: column.width, minWidth: column.width, width: column.width }]"
|
||||
data-grid-cell
|
||||
:data-grid-cell-row="-1"
|
||||
:data-grid-cell-col="column.index"
|
||||
>
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.left"></div>
|
||||
<div :class="$style.wrapper">
|
||||
<div ref="contentEl" :class="$style.contentArea">
|
||||
<span v-if="column.setting.icon" class="ti" :class="column.setting.icon" style="line-height: normal"></span>
|
||||
<span v-else>{{ text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="$style.right"
|
||||
@mousedown="onHandleMouseDown"
|
||||
@dblclick="onHandleDoubleClick"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, watch } from 'vue';
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:endWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:widthLargest', sender: GridColumn): void;
|
||||
(ev: 'change:width', sender: GridColumn, width: string): void;
|
||||
(ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
|
||||
}>();
|
||||
const props = defineProps<{
|
||||
column: GridColumn,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
|
||||
const { column, bus } = toRefs(props);
|
||||
|
||||
const rootEl = ref<InstanceType<typeof HTMLTableCellElement>>();
|
||||
const contentEl = ref<InstanceType<typeof HTMLDivElement>>();
|
||||
|
||||
const resizing = ref<boolean>(false);
|
||||
|
||||
const text = computed(() => {
|
||||
const result = column.value.setting.title ?? column.value.setting.bindTo;
|
||||
return result.length > 0 ? result : ' ';
|
||||
});
|
||||
|
||||
watch(column, () => {
|
||||
// 中身がセットされた直後はサイズが分からないので、次のタイミングで更新する
|
||||
nextTick(emitContentSizeChanged);
|
||||
}, { immediate: true });
|
||||
|
||||
function onHandleDoubleClick(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'dblclick': {
|
||||
emit('operation:widthLargest', column.value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleMouseDown(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'mousedown': {
|
||||
if (!resizing.value) {
|
||||
registerHandleMouseUp();
|
||||
registerHandleMouseMove();
|
||||
resizing.value = true;
|
||||
emit('operation:beginWidthChange', column.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleMouseMove(ev: MouseEvent) {
|
||||
if (!rootEl.value) {
|
||||
// 型ガード
|
||||
return;
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'mousemove': {
|
||||
if (resizing.value) {
|
||||
const bounds = rootEl.value.getBoundingClientRect();
|
||||
const clientWidth = rootEl.value.clientWidth;
|
||||
const clientRight = bounds.left + clientWidth;
|
||||
const nextWidth = clientWidth + (ev.clientX - clientRight);
|
||||
emit('change:width', column.value, `${nextWidth}px`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onHandleMouseUp(ev: MouseEvent) {
|
||||
switch (ev.type) {
|
||||
case 'mouseup': {
|
||||
if (resizing.value) {
|
||||
unregisterHandleMouseUp();
|
||||
unregisterHandleMouseMove();
|
||||
resizing.value = false;
|
||||
emit('operation:endWidthChange', column.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onForceRefreshContentSize() {
|
||||
emitContentSizeChanged();
|
||||
}
|
||||
|
||||
function registerHandleMouseMove() {
|
||||
unregisterHandleMouseMove();
|
||||
addEventListener('mousemove', onHandleMouseMove);
|
||||
}
|
||||
|
||||
function unregisterHandleMouseMove() {
|
||||
removeEventListener('mousemove', onHandleMouseMove);
|
||||
}
|
||||
|
||||
function registerHandleMouseUp() {
|
||||
unregisterHandleMouseUp();
|
||||
addEventListener('mouseup', onHandleMouseUp);
|
||||
}
|
||||
|
||||
function unregisterHandleMouseUp() {
|
||||
removeEventListener('mouseup', onHandleMouseUp);
|
||||
}
|
||||
|
||||
function emitContentSizeChanged() {
|
||||
const clientWidth = contentEl.value?.clientWidth ?? 0;
|
||||
const clientHeight = contentEl.value?.clientHeight ?? 0;
|
||||
emit('change:contentSize', column.value, {
|
||||
// バーの横幅も考慮したいので、+3px
|
||||
width: clientWidth + 3 + 3,
|
||||
height: clientHeight,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
bus.value.on('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
bus.value.off('forceRefreshContentSize', onForceRefreshContentSize);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$handleWidth: 5px;
|
||||
$cellHeight: 28px;
|
||||
|
||||
.cell {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
height: $cellHeight;
|
||||
max-height: $cellHeight;
|
||||
min-height: $cellHeight;
|
||||
|
||||
.wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: hidden;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
display: flex;
|
||||
padding: 6px 4px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
// rightのぶんだけズレるのでそれを相殺するためのネガティブマージン
|
||||
margin-left: -$handleWidth;
|
||||
margin-right: auto;
|
||||
width: $handleWidth;
|
||||
min-width: $handleWidth;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
// 判定を罫線の上に重ねたいのでネガティブマージンを使う
|
||||
margin-right: -$handleWidth;
|
||||
width: $handleWidth;
|
||||
min-width: $handleWidth;
|
||||
cursor: w-resize;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
60
packages/frontend/src/components/grid/MkHeaderRow.vue
Normal file
60
packages/frontend/src/components/grid/MkHeaderRow.vue
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mk_grid_tr"
|
||||
:class="$style.root"
|
||||
:data-grid-row="-1"
|
||||
>
|
||||
<MkNumberCell
|
||||
v-if="gridSetting.showNumber"
|
||||
content="#"
|
||||
:top="true"
|
||||
/>
|
||||
<MkHeaderCell
|
||||
v-for="column in columns"
|
||||
:key="column.index"
|
||||
:column="column"
|
||||
:bus="bus"
|
||||
@operation:beginWidthChange="(sender) => emit('operation:beginWidthChange', sender)"
|
||||
@operation:endWidthChange="(sender) => emit('operation:endWidthChange', sender)"
|
||||
@operation:widthLargest="(sender) => emit('operation:widthLargest', sender)"
|
||||
@change:width="(sender, width) => emit('change:width', sender, width)"
|
||||
@change:contentSize="(sender, newSize) => emit('change:contentSize', sender, newSize)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GridEventEmitter, Size } from '@/components/grid/grid.js';
|
||||
import MkHeaderCell from '@/components/grid/MkHeaderCell.vue';
|
||||
import MkNumberCell from '@/components/grid/MkNumberCell.vue';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'operation:beginWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:endWidthChange', sender: GridColumn): void;
|
||||
(ev: 'operation:widthLargest', sender: GridColumn): void;
|
||||
(ev: 'operation:selectionColumn', sender: GridColumn): void;
|
||||
(ev: 'change:width', sender: GridColumn, width: string): void;
|
||||
(ev: 'change:contentSize', sender: GridColumn, newSize: Size): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
columns: GridColumn[],
|
||||
gridSetting: GridRowSetting,
|
||||
bus: GridEventEmitter,
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
61
packages/frontend/src/components/grid/MkNumberCell.vue
Normal file
61
packages/frontend/src/components/grid/MkNumberCell.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mk_grid_th"
|
||||
:class="[$style.cell]"
|
||||
:tabindex="-1"
|
||||
data-grid-cell
|
||||
:data-grid-cell-row="row?.index ?? -1"
|
||||
:data-grid-cell-col="-1"
|
||||
>
|
||||
<div :class="[$style.root]">
|
||||
{{ content }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
|
||||
defineProps<{
|
||||
content: string,
|
||||
row?: GridRow,
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
$cellHeight: 28px;
|
||||
$cellWidth: 34px;
|
||||
|
||||
.cell {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
height: $cellHeight;
|
||||
max-height: $cellHeight;
|
||||
min-height: $cellHeight;
|
||||
min-width: $cellWidth;
|
||||
width: $cellWidth;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0 8px;
|
||||
height: 100%;
|
||||
border: solid 0.5px transparent;
|
||||
|
||||
&.selected {
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
packages/frontend/src/components/grid/cell-validators.ts
Normal file
110
packages/frontend/src/components/grid/cell-validators.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
export type ValidatorParams = {
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
value: CellValue;
|
||||
allCells: GridCell[];
|
||||
};
|
||||
|
||||
export type ValidatorResult = {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type GridCellValidator = {
|
||||
name?: string;
|
||||
ignoreViolation?: boolean;
|
||||
validate: (params: ValidatorParams) => ValidatorResult;
|
||||
}
|
||||
|
||||
export type ValidateViolation = {
|
||||
valid: boolean;
|
||||
params: ValidatorParams;
|
||||
violations: ValidateViolationItem[];
|
||||
}
|
||||
|
||||
export type ValidateViolationItem = {
|
||||
valid: boolean;
|
||||
validator: GridCellValidator;
|
||||
result: ValidatorResult;
|
||||
}
|
||||
|
||||
export function cellValidation(allCells: GridCell[], cell: GridCell, newValue: CellValue): ValidateViolation {
|
||||
const { column, row } = cell;
|
||||
const validators = column.setting.validators ?? [];
|
||||
|
||||
const params: ValidatorParams = {
|
||||
column,
|
||||
row,
|
||||
value: newValue,
|
||||
allCells,
|
||||
};
|
||||
|
||||
const violations: ValidateViolationItem[] = validators.map(validator => {
|
||||
const result = validator.validate(params);
|
||||
return {
|
||||
valid: result.valid,
|
||||
validator,
|
||||
result,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
valid: violations.every(v => v.result.valid),
|
||||
params,
|
||||
violations,
|
||||
};
|
||||
}
|
||||
|
||||
class ValidatorPreset {
|
||||
required(): GridCellValidator {
|
||||
return {
|
||||
name: 'required',
|
||||
validate: ({ value }): ValidatorResult => {
|
||||
return {
|
||||
valid: value !== null && value !== undefined && value !== '',
|
||||
message: i18n.ts._gridComponent._error.requiredValue,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
regex(pattern: RegExp): GridCellValidator {
|
||||
return {
|
||||
name: 'regex',
|
||||
validate: ({ value }): ValidatorResult => {
|
||||
return {
|
||||
valid: (typeof value !== 'string') || pattern.test(value.toString() ?? ''),
|
||||
message: i18n.tsx._gridComponent._error.patternNotMatch({ pattern: pattern.source }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
unique(): GridCellValidator {
|
||||
return {
|
||||
name: 'unique',
|
||||
validate: ({ column, row, value, allCells }): ValidatorResult => {
|
||||
const bindTo = column.setting.bindTo;
|
||||
const isUnique = allCells
|
||||
.filter(it => it.column.setting.bindTo === bindTo && it.row.index !== row.index)
|
||||
.every(cell => cell.value !== value);
|
||||
return {
|
||||
valid: isUnique,
|
||||
message: i18n.ts._gridComponent._error.notUnique,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const validators = new ValidatorPreset();
|
||||
88
packages/frontend/src/components/grid/cell.ts
Normal file
88
packages/frontend/src/components/grid/cell.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ValidateViolation } from '@/components/grid/cell-validators.js';
|
||||
import { Size } from '@/components/grid/grid.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
|
||||
export type CellValue = string | boolean | number | undefined | null | Array<unknown> | NonNullable<unknown>;
|
||||
|
||||
export type CellAddress = {
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
export const CELL_ADDRESS_NONE: CellAddress = {
|
||||
row: -1,
|
||||
col: -1,
|
||||
};
|
||||
|
||||
export type GridCell = {
|
||||
address: CellAddress;
|
||||
value: CellValue;
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
selected: boolean;
|
||||
ranged: boolean;
|
||||
contentSize: Size;
|
||||
setting: GridCellSetting;
|
||||
violation: ValidateViolation;
|
||||
}
|
||||
|
||||
export type GridCellContextMenuFactory = (col: GridColumn, row: GridRow, value: CellValue, context: GridContext) => MenuItem[];
|
||||
|
||||
export type GridCellSetting = {
|
||||
contextMenuFactory?: GridCellContextMenuFactory;
|
||||
}
|
||||
|
||||
export function createCell(
|
||||
column: GridColumn,
|
||||
row: GridRow,
|
||||
value: CellValue,
|
||||
setting: GridCellSetting,
|
||||
): GridCell {
|
||||
const newValue = (row.using && column.setting.valueTransformer)
|
||||
? column.setting.valueTransformer(row, column, value)
|
||||
: value;
|
||||
|
||||
return {
|
||||
address: { row: row.index, col: column.index },
|
||||
value: newValue,
|
||||
column,
|
||||
row,
|
||||
selected: false,
|
||||
ranged: false,
|
||||
contentSize: { width: 0, height: 0 },
|
||||
violation: {
|
||||
valid: true,
|
||||
params: {
|
||||
column,
|
||||
row,
|
||||
value,
|
||||
allCells: [],
|
||||
},
|
||||
violations: [],
|
||||
},
|
||||
setting,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetCell(cell: GridCell): void {
|
||||
cell.selected = false;
|
||||
cell.ranged = false;
|
||||
cell.violation = {
|
||||
valid: true,
|
||||
params: {
|
||||
column: cell.column,
|
||||
row: cell.row,
|
||||
value: cell.value,
|
||||
allCells: [],
|
||||
},
|
||||
violations: [],
|
||||
};
|
||||
}
|
||||
53
packages/frontend/src/components/grid/column.ts
Normal file
53
packages/frontend/src/components/grid/column.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { GridCellValidator } from '@/components/grid/cell-validators.js';
|
||||
import { Size, SizeStyle } from '@/components/grid/grid.js';
|
||||
import { calcCellWidth } from '@/components/grid/grid-utils.js';
|
||||
import { CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
|
||||
export type ColumnType = 'text' | 'number' | 'date' | 'boolean' | 'image' | 'hidden';
|
||||
|
||||
export type CustomValueEditor = (row: GridRow, col: GridColumn, value: CellValue, cellElement: HTMLElement) => Promise<CellValue>;
|
||||
export type CellValueTransformer = (row: GridRow, col: GridColumn, value: CellValue) => CellValue;
|
||||
export type GridColumnContextMenuFactory = (col: GridColumn, context: GridContext) => MenuItem[];
|
||||
|
||||
export type GridColumnSetting = {
|
||||
bindTo: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
type: ColumnType;
|
||||
width: SizeStyle;
|
||||
editable?: boolean;
|
||||
validators?: GridCellValidator[];
|
||||
customValueEditor?: CustomValueEditor;
|
||||
valueTransformer?: CellValueTransformer;
|
||||
contextMenuFactory?: GridColumnContextMenuFactory;
|
||||
events?: {
|
||||
copy?: (value: CellValue) => string;
|
||||
paste?: (text: string) => CellValue;
|
||||
delete?: (cell: GridCell, context: GridContext) => void;
|
||||
}
|
||||
};
|
||||
|
||||
export type GridColumn = {
|
||||
index: number;
|
||||
setting: GridColumnSetting;
|
||||
width: string;
|
||||
contentSize: Size;
|
||||
}
|
||||
|
||||
export function createColumn(setting: GridColumnSetting, index: number): GridColumn {
|
||||
return {
|
||||
index,
|
||||
setting,
|
||||
width: calcCellWidth(setting.width),
|
||||
contentSize: { width: 0, height: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
46
packages/frontend/src/components/grid/grid-event.ts
Normal file
46
packages/frontend/src/components/grid/grid-event.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridState } from '@/components/grid/grid.js';
|
||||
import { ValidateViolation } from '@/components/grid/cell-validators.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
|
||||
export type GridContext = {
|
||||
selectedCell?: GridCell;
|
||||
rangedCells: GridCell[];
|
||||
rangedRows: GridRow[];
|
||||
randedBounds: {
|
||||
leftTop: CellAddress;
|
||||
rightBottom: CellAddress;
|
||||
};
|
||||
availableBounds: {
|
||||
leftTop: CellAddress;
|
||||
rightBottom: CellAddress;
|
||||
};
|
||||
state: GridState;
|
||||
rows: GridRow[];
|
||||
columns: GridColumn[];
|
||||
};
|
||||
|
||||
export type GridEvent =
|
||||
GridCellValueChangeEvent |
|
||||
GridCellValidationEvent
|
||||
;
|
||||
|
||||
export type GridCellValueChangeEvent = {
|
||||
type: 'cell-value-change';
|
||||
column: GridColumn;
|
||||
row: GridRow;
|
||||
oldValue: CellValue;
|
||||
newValue: CellValue;
|
||||
};
|
||||
|
||||
export type GridCellValidationEvent = {
|
||||
type: 'cell-validation';
|
||||
violation?: ValidateViolation;
|
||||
all: ValidateViolation[];
|
||||
};
|
||||
215
packages/frontend/src/components/grid/grid-utils.ts
Normal file
215
packages/frontend/src/components/grid/grid-utils.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { isRef, Ref } from 'vue';
|
||||
import { DataSource, SizeStyle } from '@/components/grid/grid.js';
|
||||
import { CELL_ADDRESS_NONE, CellAddress, CellValue, GridCell } from '@/components/grid/cell.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { GridColumn, GridColumnSetting } from '@/components/grid/column.js';
|
||||
|
||||
export function isCellElement(elem: HTMLElement): boolean {
|
||||
return elem.hasAttribute('data-grid-cell');
|
||||
}
|
||||
|
||||
export function isRowElement(elem: HTMLElement): boolean {
|
||||
return elem.hasAttribute('data-grid-row');
|
||||
}
|
||||
|
||||
export function calcCellWidth(widthSetting: SizeStyle): string {
|
||||
switch (widthSetting) {
|
||||
case undefined:
|
||||
case 'auto': {
|
||||
return 'auto';
|
||||
}
|
||||
default: {
|
||||
return `${widthSetting}px`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCellRowByAttribute(elem: HTMLElement): number {
|
||||
const row = elem.getAttribute('data-grid-cell-row');
|
||||
if (row === null) {
|
||||
throw new Error('data-grid-cell-row attribute not found');
|
||||
}
|
||||
return Number(row);
|
||||
}
|
||||
|
||||
function getCellColByAttribute(elem: HTMLElement): number {
|
||||
const col = elem.getAttribute('data-grid-cell-col');
|
||||
if (col === null) {
|
||||
throw new Error('data-grid-cell-col attribute not found');
|
||||
}
|
||||
return Number(col);
|
||||
}
|
||||
|
||||
export function getCellAddress(elem: HTMLElement, parentNodeCount = 10): CellAddress {
|
||||
let node = elem;
|
||||
for (let i = 0; i < parentNodeCount; i++) {
|
||||
if (!node.parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCellElement(node) && isRowElement(node.parentElement)) {
|
||||
const row = getCellRowByAttribute(node);
|
||||
const col = getCellColByAttribute(node);
|
||||
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
return CELL_ADDRESS_NONE;
|
||||
}
|
||||
|
||||
export function getCellElement(elem: HTMLElement, parentNodeCount = 10): HTMLElement | null {
|
||||
let node = elem;
|
||||
for (let i = 0; i < parentNodeCount; i++) {
|
||||
if (isCellElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (!node.parentElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function equalCellAddress(a: CellAddress, b: CellAddress): boolean {
|
||||
return a.row === b.row && a.col === b.col;
|
||||
}
|
||||
|
||||
/**
|
||||
* グリッドの選択範囲の内容をタブ区切り形式テキストに変換してクリップボードにコピーする。
|
||||
*/
|
||||
export function copyGridDataToClipboard(
|
||||
gridItems: Ref<DataSource[]> | DataSource[],
|
||||
context: GridContext,
|
||||
) {
|
||||
const items = isRef(gridItems) ? gridItems.value : gridItems;
|
||||
const lines = Array.of<string>();
|
||||
const bounds = context.randedBounds;
|
||||
|
||||
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
|
||||
const rowItems = Array.of<string>();
|
||||
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
|
||||
const { bindTo, events } = context.columns[col].setting;
|
||||
const value = items[row][bindTo];
|
||||
const transformValue = events?.copy
|
||||
? events.copy(value)
|
||||
: typeof value === 'object' || Array.isArray(value)
|
||||
? JSON.stringify(value)
|
||||
: value?.toString() ?? '';
|
||||
rowItems.push(transformValue);
|
||||
}
|
||||
lines.push(rowItems.join('\t'));
|
||||
}
|
||||
|
||||
const text = lines.join('\n');
|
||||
copyToClipboard(text);
|
||||
|
||||
if (_DEV_) {
|
||||
console.log(`Copied to clipboard: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* クリップボードからタブ区切りテキストとして値を読み取り、グリッドの選択範囲に貼り付けるためのユーティリティ関数。
|
||||
* …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
|
||||
*/
|
||||
export async function pasteToGridFromClipboard(
|
||||
context: GridContext,
|
||||
callback: (row: GridRow, col: GridColumn, parsedValue: CellValue) => void,
|
||||
) {
|
||||
function parseValue(value: string, setting: GridColumnSetting): CellValue {
|
||||
if (setting.events?.paste) {
|
||||
return setting.events.paste(value);
|
||||
} else {
|
||||
switch (setting.type) {
|
||||
case 'number': {
|
||||
return Number(value);
|
||||
}
|
||||
case 'boolean': {
|
||||
return value === 'true';
|
||||
}
|
||||
default: {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clipBoardText = await navigator.clipboard.readText();
|
||||
if (_DEV_) {
|
||||
console.log(`Paste from clipboard: ${clipBoardText}`);
|
||||
}
|
||||
|
||||
const bounds = context.randedBounds;
|
||||
const lines = clipBoardText.replace(/\r/g, '')
|
||||
.split('\n')
|
||||
.map(it => it.split('\t'));
|
||||
|
||||
if (lines.length === 1 && lines[0].length === 1) {
|
||||
// 単独文字列の場合は選択範囲全体に同じテキストを貼り付ける
|
||||
const ranges = context.rangedCells;
|
||||
for (const cell of ranges) {
|
||||
if (cell.column.setting.editable) {
|
||||
callback(cell.row, cell.column, parseValue(lines[0][0], cell.column.setting));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 表形式文字列の場合は表形式にパースし、選択範囲に合うように貼り付ける
|
||||
const offsetRow = bounds.leftTop.row;
|
||||
const offsetCol = bounds.leftTop.col;
|
||||
const { columns, rows } = context;
|
||||
for (let row = bounds.leftTop.row; row <= bounds.rightBottom.row; row++) {
|
||||
const rowIdx = row - offsetRow;
|
||||
if (lines.length <= rowIdx) {
|
||||
// クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
|
||||
break;
|
||||
}
|
||||
|
||||
const items = lines[rowIdx];
|
||||
for (let col = bounds.leftTop.col; col <= bounds.rightBottom.col; col++) {
|
||||
const colIdx = col - offsetCol;
|
||||
if (items.length <= colIdx) {
|
||||
// クリップボードから読んだ二次元配列よりも選択範囲の方が大きい場合、貼り付け操作を打ち切る
|
||||
break;
|
||||
}
|
||||
|
||||
if (columns[col].setting.editable) {
|
||||
callback(rows[row], columns[col], parseValue(items[colIdx], columns[col].setting));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* グリッドの選択範囲にあるデータを削除するためのユーティリティ関数。
|
||||
* …と言いつつも、使用箇所により反映方法に差があるため更新操作はコールバック関数に任せている。
|
||||
*/
|
||||
export function removeDataFromGrid(
|
||||
context: GridContext,
|
||||
callback: (cell: GridCell) => void,
|
||||
) {
|
||||
for (const cell of context.rangedCells) {
|
||||
const { editable, events } = cell.column.setting;
|
||||
if (editable) {
|
||||
if (events?.delete) {
|
||||
events.delete(cell, context);
|
||||
} else {
|
||||
callback(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
packages/frontend/src/components/grid/grid.ts
Normal file
49
packages/frontend/src/components/grid/grid.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { CellValue, GridCellSetting } from '@/components/grid/cell.js';
|
||||
import { GridColumnSetting } from '@/components/grid/column.js';
|
||||
import { GridRowSetting } from '@/components/grid/row.js';
|
||||
|
||||
export type GridSetting = {
|
||||
root?: {
|
||||
noOverflowStyle?: boolean;
|
||||
rounded?: boolean;
|
||||
outerBorder?: boolean;
|
||||
};
|
||||
row?: GridRowSetting;
|
||||
cols: GridColumnSetting[];
|
||||
cells?: GridCellSetting;
|
||||
};
|
||||
|
||||
export type DataSource = Record<string, CellValue>;
|
||||
|
||||
export type GridState =
|
||||
'normal' |
|
||||
'cellSelecting' |
|
||||
'cellEditing' |
|
||||
'colResizing' |
|
||||
'colSelecting' |
|
||||
'rowSelecting' |
|
||||
'hidden'
|
||||
;
|
||||
|
||||
export type Size = {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type SizeStyle = number | 'auto' | undefined;
|
||||
|
||||
export type AdditionalStyle = {
|
||||
className?: string;
|
||||
style?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
export class GridEventEmitter extends EventEmitter<{
|
||||
'forceRefreshContentSize': void;
|
||||
}> {
|
||||
}
|
||||
68
packages/frontend/src/components/grid/row.ts
Normal file
68
packages/frontend/src/components/grid/row.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { AdditionalStyle } from '@/components/grid/grid.js';
|
||||
import { GridCell } from '@/components/grid/cell.js';
|
||||
import { GridColumn } from '@/components/grid/column.js';
|
||||
import { MenuItem } from '@/types/menu.js';
|
||||
import { GridContext } from '@/components/grid/grid-event.js';
|
||||
|
||||
export const defaultGridRowSetting: Required<GridRowSetting> = {
|
||||
showNumber: true,
|
||||
selectable: true,
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [],
|
||||
contextMenuFactory: () => [],
|
||||
events: {},
|
||||
};
|
||||
|
||||
export type GridRowStyleRuleConditionParams = {
|
||||
row: GridRow,
|
||||
targetCols: GridColumn[],
|
||||
cells: GridCell[]
|
||||
};
|
||||
|
||||
export type GridRowStyleRule = {
|
||||
condition: (params: GridRowStyleRuleConditionParams) => boolean;
|
||||
applyStyle: AdditionalStyle;
|
||||
}
|
||||
|
||||
export type GridRowContextMenuFactory = (row: GridRow, context: GridContext) => MenuItem[];
|
||||
|
||||
export type GridRowSetting = {
|
||||
showNumber?: boolean;
|
||||
selectable?: boolean;
|
||||
minimumDefinitionCount?: number;
|
||||
styleRules?: GridRowStyleRule[];
|
||||
contextMenuFactory?: GridRowContextMenuFactory;
|
||||
events?: {
|
||||
delete?: (rows: GridRow[]) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export type GridRow = {
|
||||
index: number;
|
||||
ranged: boolean;
|
||||
using: boolean;
|
||||
setting: GridRowSetting;
|
||||
additionalStyles: AdditionalStyle[];
|
||||
}
|
||||
|
||||
export function createRow(index: number, using: boolean, setting: GridRowSetting): GridRow {
|
||||
return {
|
||||
index,
|
||||
ranged: false,
|
||||
using: using,
|
||||
setting,
|
||||
additionalStyles: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function resetRow(row: GridRow): void {
|
||||
row.ranged = false;
|
||||
row.using = false;
|
||||
row.additionalStyles = [];
|
||||
}
|
||||
|
||||
52
packages/frontend/src/components/hook/useLoading.ts
Normal file
52
packages/frontend/src/components/hook/useLoading.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { computed, h, ref } from 'vue';
|
||||
import MkLoading from '@/components/global/MkLoading.vue';
|
||||
|
||||
export const useLoading = (props?: {
|
||||
static?: boolean;
|
||||
inline?: boolean;
|
||||
colored?: boolean;
|
||||
mini?: boolean;
|
||||
em?: boolean;
|
||||
}) => {
|
||||
const showingCnt = ref(0);
|
||||
|
||||
const show = () => {
|
||||
showingCnt.value++;
|
||||
};
|
||||
|
||||
const close = (force?: boolean) => {
|
||||
if (force) {
|
||||
showingCnt.value = 0;
|
||||
} else {
|
||||
showingCnt.value = Math.max(0, showingCnt.value - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const scope = <T>(fn: () => T) => {
|
||||
show();
|
||||
|
||||
const result = fn();
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(() => close());
|
||||
} else {
|
||||
close();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const showing = computed(() => showingCnt.value > 0);
|
||||
const component = computed(() => showing.value ? h(MkLoading, props) : null);
|
||||
|
||||
return {
|
||||
show,
|
||||
close,
|
||||
scope,
|
||||
component,
|
||||
showing,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<!--
|
||||
開発モードのviteはこのファイルを起点にサーバーを起動します。
|
||||
このファイルに書かれた [t]js のリンクと (s)cssのリンクと、その依存関係にあるファイルはビルドされます
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>[DEV] Loading...</title>
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self' https://newassets.hcaptcha.com/ https://challenges.cloudflare.com/ http://localhost:7493/;
|
||||
worker-src 'self' blob:;
|
||||
script-src 'self' 'unsafe-eval' https://*.hcaptcha.com https://challenges.cloudflare.com https://esm.sh https://cdn.jsdelivr.net https://raw.esm.sh;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data: blob: www.google.com xn--931a.moe localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 activitypub.software secure.gravatar.com avatars.githubusercontent.com;
|
||||
media-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000;
|
||||
connect-src 'self' localhost:3000 localhost:5173 127.0.0.1:5173 127.0.0.1:3000 https://newassets.hcaptcha.com https://api.listenbrainz.org https://api.friendlycaptcha.com https://raw.esm.sh;
|
||||
frame-src *;"
|
||||
/>
|
||||
<meta property="og:site_name" content="[DEV BUILD] Sharkey" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="theme-color-orig" content="#86b300">
|
||||
<link rel='stylesheet' href='/assets/phosphor-icons/bold/style.css'>
|
||||
<link rel='stylesheet' href='/static-assets/fonts/sharkey-icons/style.css'>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="sharkey_app"></div>
|
||||
<script type="module" src="./_dev_boot_.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -11,6 +11,7 @@ import * as Misskey from 'misskey-js';
|
|||
import type { ComponentProps as CP } from 'vue-component-type-helpers';
|
||||
import type { Form, GetFormResultType } from '@/scripts/form.js';
|
||||
import type { MenuItem } from '@/types/menu.js';
|
||||
import type { PostFormProps } from '@/types/post-form.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
@ -28,15 +29,15 @@ import { pleaseLogin } from '@/scripts/please-login.js';
|
|||
import { showMovedDialog } from '@/scripts/show-moved-dialog.js';
|
||||
import { getHTMLElementOrNull } from '@/scripts/get-dom-node-or-null.js';
|
||||
import { focusParent } from '@/scripts/focus.js';
|
||||
import type { PostFormProps } from '@/types/post-form.js';
|
||||
|
||||
export const openingWindowsCount = ref(0);
|
||||
|
||||
export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>;
|
||||
export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>(
|
||||
endpoint: E,
|
||||
data: P,
|
||||
token?: string | null | undefined,
|
||||
customErrors?: Record<string, { title?: string; text: string; }>,
|
||||
customErrors?: ApiWithDialogCustomErrors,
|
||||
) => {
|
||||
const promise = misskeyApi(endpoint, data, token);
|
||||
promiseDialog(promise, null, async (err) => {
|
||||
|
|
@ -610,6 +611,27 @@ export async function selectDriveFolder(multiple: boolean): Promise<Misskey.enti
|
|||
});
|
||||
}
|
||||
|
||||
export async function selectRole(params: {
|
||||
initialRoleIds?: string[],
|
||||
title?: string,
|
||||
infoMessage?: string,
|
||||
publicOnly?: boolean,
|
||||
}): Promise<
|
||||
{ canceled: true; result: undefined; } |
|
||||
{ canceled: false; result: Misskey.entities.Role[] }
|
||||
> {
|
||||
return new Promise((resolve) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkRoleSelectDialog.vue')), params, {
|
||||
done: roles => {
|
||||
resolve({ canceled: false, result: roles });
|
||||
},
|
||||
close: () => {
|
||||
resolve({ canceled: true, result: undefined });
|
||||
},
|
||||
}, 'dispose');
|
||||
});
|
||||
}
|
||||
|
||||
export async function pickEmoji(src: HTMLElement, opts: ComponentProps<typeof MkEmojiPickerDialog>): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
const { dispose } = popup(MkEmojiPickerDialog, {
|
||||
|
|
|
|||
0
packages/frontend/src/pages/about-misskey.vue
Normal file
0
packages/frontend/src/pages/about-misskey.vue
Normal file
|
|
@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSpacer v-else-if="tab === 'emojis'" :contentMax="1000" :marginMin="20">
|
||||
<XEmojis/>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else-if="tab === 'federation'" :contentMax="1000" :marginMin="20">
|
||||
<MkSpacer v-else-if="instance.federation !== 'none' && tab === 'federation'" :contentMax="1000" :marginMin="20">
|
||||
<XFederation/>
|
||||
</MkSpacer>
|
||||
<MkSpacer v-else-if="tab === 'charts'" :contentMax="1000" :marginMin="20">
|
||||
|
|
@ -25,6 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { claimAchievement } from '@/scripts/achievements.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
|
|
@ -51,22 +52,34 @@ watch(tab, () => {
|
|||
|
||||
const headerActions = computed(() => []);
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'overview',
|
||||
title: i18n.ts.overview,
|
||||
}, {
|
||||
key: 'emojis',
|
||||
title: i18n.ts.customEmojis,
|
||||
icon: 'ph-smiley ph-bold ph-lg',
|
||||
}, {
|
||||
key: 'federation',
|
||||
title: i18n.ts.federation,
|
||||
icon: 'ti ti-whirl',
|
||||
}, {
|
||||
key: 'charts',
|
||||
title: i18n.ts.charts,
|
||||
icon: 'ti ti-chart-line',
|
||||
}]);
|
||||
const headerTabs = computed(() => {
|
||||
const items = [];
|
||||
|
||||
items.push({
|
||||
key: 'overview',
|
||||
title: i18n.ts.overview,
|
||||
}, {
|
||||
key: 'emojis',
|
||||
title: i18n.ts.customEmojis,
|
||||
icon: 'ph-smiley ph-bold ph-lg',
|
||||
});
|
||||
|
||||
if (instance.federation !== 'none') {
|
||||
items.push({
|
||||
key: 'federation',
|
||||
title: i18n.ts.federation,
|
||||
icon: 'ti ti-whirl',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'charts',
|
||||
title: i18n.ts.charts,
|
||||
icon: 'ti ti-chart-line',
|
||||
});
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
definePageMetadata(() => ({
|
||||
title: i18n.ts.instanceInfo,
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template v-else-if="botProtectionForm.savedState.provider === 'fc'" #suffix>FriendlyCaptcha</template>
|
||||
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
|
||||
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
|
||||
<template v-if="botProtectionForm.modified.value" #footer>
|
||||
<MkFormFooter :form="botProtectionForm"/>
|
||||
<template #footer>
|
||||
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
|
||||
</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkRadios v-model="botProtectionForm.state.provider">
|
||||
<option :value="null">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
|
||||
<option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
|
||||
<option value="hcaptcha">hCaptcha</option>
|
||||
<option value="mcaptcha">mCaptcha</option>
|
||||
<option value="recaptcha">reCAPTCHA</option>
|
||||
|
|
@ -30,71 +30,126 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</MkRadios>
|
||||
|
||||
<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="hcaptcha" :sitekey="botProtectionForm.state.hcaptchaSiteKey || '10000000-ffff-ffff-ffff-000000000001'"/>
|
||||
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="hcaptcha"
|
||||
:sitekey="botProtectionForm.state.hcaptchaSiteKey"
|
||||
:secretKey="botProtectionForm.state.hcaptchaSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
|
||||
<div>
|
||||
<span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl">
|
||||
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce>
|
||||
<template #prefix><i class="ti ti-link"></i></template>
|
||||
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
|
||||
</MkInput>
|
||||
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="mcaptcha" :sitekey="botProtectionForm.state.mcaptchaSiteKey" :instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"/>
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="mcaptcha"
|
||||
:sitekey="botProtectionForm.state.mcaptchaSiteKey"
|
||||
:secretKey="botProtectionForm.state.mcaptchaSecretKey"
|
||||
:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"
|
||||
/>
|
||||
</FormSlot>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'recaptcha'">
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="recaptcha" :sitekey="botProtectionForm.state.recaptchaSiteKey"/>
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="recaptcha"
|
||||
:sitekey="botProtectionForm.state.recaptchaSiteKey"
|
||||
:secretKey="botProtectionForm.state.recaptchaSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
|
||||
<div>
|
||||
<span>ref: </span>
|
||||
<a
|
||||
href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do"
|
||||
target="_blank"
|
||||
>reCAPTCHA FAQ</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'turnstile'">
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
|
||||
</MkInput>
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="turnstile" :sitekey="botProtectionForm.state.turnstileSiteKey || '1x00000000000000000000AA'"/>
|
||||
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="turnstile"
|
||||
:sitekey="botProtectionForm.state.turnstileSiteKey"
|
||||
:secretKey="botProtectionForm.state.turnstileSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>
|
||||
{{ i18n.ts._captcha.testSiteKeyMessage }}
|
||||
</div>
|
||||
<div>
|
||||
<span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'fc'">
|
||||
<MkInput v-model="botProtectionForm.state.fcSiteKey">
|
||||
<MkInput v-model="botProtectionForm.state.fcSiteKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="botProtectionForm.state.fcSecretKey">
|
||||
<MkInput v-model="botProtectionForm.state.fcSecretKey" debounce>
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
|
||||
</MkInput>
|
||||
|
|
@ -102,12 +157,32 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="fc" :sitekey="botProtectionForm.state.fcSiteKey"/>
|
||||
</FormSlot>
|
||||
<FormSlot v-if="botProtectionForm.state.fcSiteKey">
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha
|
||||
v-model="captchaResult"
|
||||
provider="fc"
|
||||
:sitekey="botProtectionForm.state.fcSiteKey"
|
||||
:secretKey="botProtectionForm.state.fcSecretKey"
|
||||
/>
|
||||
</FormSlot>
|
||||
<MkInfo>
|
||||
<div :class="$style.captchaInfoMsg">
|
||||
<div>
|
||||
{{ i18n.ts._captcha.testSiteKeyMessage }}
|
||||
</div>
|
||||
<div>
|
||||
<span>ref: </span><a href="https://docs.friendlycaptcha.com/#/installation?id=_3-verifying-the-captcha-solution-on-the-server" target="_blank">FriendlyCaptcha Docs</a>
|
||||
</div>
|
||||
</div>
|
||||
</MkInfo>
|
||||
</template>
|
||||
|
||||
<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
|
||||
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
|
||||
<FormSlot>
|
||||
<template #label>{{ i18n.ts.preview }}</template>
|
||||
<MkCaptcha provider="testcaptcha"/>
|
||||
<template #label>{{ i18n.ts._captcha.verify }}</template>
|
||||
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
|
||||
</FormSlot>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -115,7 +190,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref } from 'vue';
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
|
|
@ -127,56 +203,113 @@ import { useForm } from '@/scripts/use-form.js';
|
|||
import MkFormFooter from '@/components/MkFormFooter.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { ApiWithDialogCustomErrors } from '@/os.js';
|
||||
|
||||
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));
|
||||
|
||||
const meta = await misskeyApi('admin/meta');
|
||||
const errorHandler: ApiWithDialogCustomErrors = {
|
||||
// 検証リクエストそのものに失敗
|
||||
'0f4fe2f1-2c15-4d6e-b714-efbfcde231cd': {
|
||||
title: i18n.ts._captcha._error._requestFailed.title,
|
||||
text: i18n.ts._captcha._error._requestFailed.text,
|
||||
},
|
||||
// 検証リクエストの結果が不正
|
||||
'c41c067f-24f3-4150-84b2-b5a3ae8c2214': {
|
||||
title: i18n.ts._captcha._error._verificationFailed.title,
|
||||
text: i18n.ts._captcha._error._verificationFailed.text,
|
||||
},
|
||||
// 不明なエラー
|
||||
'f868d509-e257-42a9-99c1-42614b031a97': {
|
||||
title: i18n.ts._captcha._error._unknown.title,
|
||||
text: i18n.ts._captcha._error._unknown.text,
|
||||
},
|
||||
};
|
||||
|
||||
const captchaResult = ref<string | null>(null);
|
||||
|
||||
const meta = await misskeyApi('admin/captcha/current');
|
||||
const botProtectionForm = useForm({
|
||||
provider: meta.enableHcaptcha
|
||||
? 'hcaptcha'
|
||||
: meta.enableRecaptcha
|
||||
? 'recaptcha'
|
||||
: meta.enableTurnstile
|
||||
? 'turnstile'
|
||||
: meta.enableMcaptcha
|
||||
? 'mcaptcha'
|
||||
: meta.enableFC
|
||||
? 'fc'
|
||||
: meta.enableTestcaptcha
|
||||
? 'testcaptcha'
|
||||
: null,
|
||||
hcaptchaSiteKey: meta.hcaptchaSiteKey,
|
||||
hcaptchaSecretKey: meta.hcaptchaSecretKey,
|
||||
mcaptchaSiteKey: meta.mcaptchaSiteKey,
|
||||
mcaptchaSecretKey: meta.mcaptchaSecretKey,
|
||||
mcaptchaInstanceUrl: meta.mcaptchaInstanceUrl,
|
||||
recaptchaSiteKey: meta.recaptchaSiteKey,
|
||||
recaptchaSecretKey: meta.recaptchaSecretKey,
|
||||
turnstileSiteKey: meta.turnstileSiteKey,
|
||||
turnstileSecretKey: meta.turnstileSecretKey,
|
||||
fcSiteKey: meta.fcSiteKey,
|
||||
fcSecretKey: meta.fcSecretKey,
|
||||
provider: meta.provider,
|
||||
hcaptchaSiteKey: meta.hcaptcha.siteKey,
|
||||
hcaptchaSecretKey: meta.hcaptcha.secretKey,
|
||||
mcaptchaSiteKey: meta.mcaptcha.siteKey,
|
||||
mcaptchaSecretKey: meta.mcaptcha.secretKey,
|
||||
mcaptchaInstanceUrl: meta.mcaptcha.instanceUrl,
|
||||
recaptchaSiteKey: meta.recaptcha.siteKey,
|
||||
recaptchaSecretKey: meta.recaptcha.secretKey,
|
||||
turnstileSiteKey: meta.turnstile.siteKey,
|
||||
turnstileSecretKey: meta.turnstile.secretKey,
|
||||
fcSiteKey: meta.fc.siteKey,
|
||||
fcSecretKey: meta.fc.secretKey,
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
enableHcaptcha: state.provider === 'hcaptcha',
|
||||
hcaptchaSiteKey: state.hcaptchaSiteKey,
|
||||
hcaptchaSecretKey: state.hcaptchaSecretKey,
|
||||
enableMcaptcha: state.provider === 'mcaptcha',
|
||||
mcaptchaSiteKey: state.mcaptchaSiteKey,
|
||||
mcaptchaSecretKey: state.mcaptchaSecretKey,
|
||||
mcaptchaInstanceUrl: state.mcaptchaInstanceUrl,
|
||||
enableRecaptcha: state.provider === 'recaptcha',
|
||||
recaptchaSiteKey: state.recaptchaSiteKey,
|
||||
recaptchaSecretKey: state.recaptchaSecretKey,
|
||||
enableTurnstile: state.provider === 'turnstile',
|
||||
turnstileSiteKey: state.turnstileSiteKey,
|
||||
turnstileSecretKey: state.turnstileSecretKey,
|
||||
enableFC: state.provider === 'fc',
|
||||
fcSiteKey: state.fcSiteKey,
|
||||
fcSecretKey: state.fcSecretKey,
|
||||
enableTestcaptcha: state.provider === 'testcaptcha',
|
||||
});
|
||||
fetchInstance(true);
|
||||
const provider = state.provider;
|
||||
if (provider === 'none') {
|
||||
await os.apiWithDialog(
|
||||
'admin/captcha/save',
|
||||
{ provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'] },
|
||||
undefined,
|
||||
errorHandler,
|
||||
);
|
||||
} else {
|
||||
const sitekey = provider === 'hcaptcha'
|
||||
? state.hcaptchaSiteKey
|
||||
: provider === 'mcaptcha'
|
||||
? state.mcaptchaSiteKey
|
||||
: provider === 'recaptcha'
|
||||
? state.recaptchaSiteKey
|
||||
: provider === 'turnstile'
|
||||
? state.turnstileSiteKey
|
||||
: provider === 'fc'
|
||||
? state.fcSiteKey
|
||||
: null;
|
||||
const secret = provider === 'hcaptcha'
|
||||
? state.hcaptchaSecretKey
|
||||
: provider === 'mcaptcha'
|
||||
? state.mcaptchaSecretKey
|
||||
: provider === 'recaptcha'
|
||||
? state.recaptchaSecretKey
|
||||
: provider === 'turnstile'
|
||||
? state.turnstileSecretKey
|
||||
: provider === 'fc'
|
||||
? state.fcSecretKey
|
||||
: null;
|
||||
|
||||
await os.apiWithDialog(
|
||||
'admin/captcha/save',
|
||||
{
|
||||
provider: provider as Misskey.entities.AdminCaptchaSaveRequest['provider'],
|
||||
sitekey: sitekey,
|
||||
secret: secret,
|
||||
instanceUrl: state.mcaptchaInstanceUrl,
|
||||
captchaResult: captchaResult.value,
|
||||
},
|
||||
undefined,
|
||||
errorHandler,
|
||||
);
|
||||
}
|
||||
|
||||
await fetchInstance(true);
|
||||
});
|
||||
|
||||
watch(botProtectionForm.state, () => {
|
||||
captchaResult.value = null;
|
||||
});
|
||||
|
||||
const canSaving = computed((): boolean => {
|
||||
return (botProtectionForm.state.provider === 'none') ||
|
||||
(botProtectionForm.state.provider === 'hcaptcha' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'mcaptcha' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'recaptcha' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'turnstile' && !!captchaResult.value) ||
|
||||
(botProtectionForm.state.provider === 'testcaptcha' && !!captchaResult.value);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.captchaInfoMsg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export type RequestLogItem = {
|
||||
failed: boolean;
|
||||
url: string;
|
||||
name: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const gridSortOrderKeys = [
|
||||
'name',
|
||||
'category',
|
||||
'aliases',
|
||||
'type',
|
||||
'license',
|
||||
'host',
|
||||
'uri',
|
||||
'publicUrl',
|
||||
'isSensitive',
|
||||
'localOnly',
|
||||
'updatedAt',
|
||||
] as const satisfies string[];
|
||||
|
||||
export type GridSortOrderKey = typeof gridSortOrderKeys[number];
|
||||
|
||||
export function emptyStrToUndefined(value: string | null) {
|
||||
return value ? value : undefined;
|
||||
}
|
||||
|
||||
export function emptyStrToNull(value: string) {
|
||||
return value === '' ? null : value;
|
||||
}
|
||||
|
||||
export function emptyStrToEmptyArray(value: string) {
|
||||
return value === '' ? [] : value.split(' ').map(it => it.trim());
|
||||
}
|
||||
|
||||
export function roleIdsParser(text: string): { id: string, name: string }[] {
|
||||
// idとnameのペア配列をJSONで受け取る。それ以外の形式は許容しない
|
||||
try {
|
||||
const obj = JSON.parse(text);
|
||||
if (!Array.isArray(obj)) {
|
||||
return [];
|
||||
}
|
||||
if (!obj.every(it => typeof it === 'object' && 'id' in it && 'name' in it)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return obj.map(it => ({ id: it.id, name: it.name }));
|
||||
} catch (ex) {
|
||||
console.warn(ex);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkWindow
|
||||
ref="uiWindow"
|
||||
:initialWidth="400"
|
||||
:initialHeight="500"
|
||||
:canResize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<i class="ti ti-notes" style="margin-right: 0.5em;"></i> {{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}
|
||||
</template>
|
||||
<MkSpacer>
|
||||
<XRegisterLogs :logs="logs"/>
|
||||
</MkSpacer>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
import type { RequestLogItem } from './custom-emojis-manager.impl.js';
|
||||
|
||||
defineProps<{
|
||||
logs: RequestLogItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkWindow
|
||||
ref="uiWindow"
|
||||
:initialWidth="400"
|
||||
:initialHeight="500"
|
||||
:canResize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
<i class="ti ti-search" style="margin-right: 0.5em;"></i> {{ i18n.ts.search }}
|
||||
</template>
|
||||
<div :class="$style.root">
|
||||
<MkSpacer>
|
||||
<div class="_gaps">
|
||||
<div class="_gaps_s">
|
||||
<MkInput
|
||||
v-model="model.name"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>name</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.category"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>category</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.aliases"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>aliases</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="model.type"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>type</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.license"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>license</template>
|
||||
</MkInput>
|
||||
<MkSelect
|
||||
v-model="model.sensitive"
|
||||
>
|
||||
<template #label>sensitive</template>
|
||||
<option :value="null">-</option>
|
||||
<option :value="true">true</option>
|
||||
<option :value="false">false</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSelect
|
||||
v-model="model.localOnly"
|
||||
>
|
||||
<template #label>localOnly</template>
|
||||
<option :value="null">-</option>
|
||||
<option :value="true">true</option>
|
||||
<option :value="false">false</option>
|
||||
</MkSelect>
|
||||
<MkInput
|
||||
v-model="model.updatedAtFrom"
|
||||
type="date"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>updatedAt(from)</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="model.updatedAtTo"
|
||||
type="date"
|
||||
autocapitalize="off"
|
||||
>
|
||||
<template #label>updatedAt(to)</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="queryRolesText"
|
||||
type="text"
|
||||
readonly
|
||||
autocapitalize="off"
|
||||
@click="onQueryRolesEditClicked"
|
||||
>
|
||||
<template #label>role</template>
|
||||
<template #suffix><i class="ti ti-pencil"></i></template>
|
||||
</MkInput>
|
||||
</div>
|
||||
<MkFolder :spacerMax="8" :spacerMin="8">
|
||||
<template #icon><i class="ti ti-arrows-sort"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
|
||||
<MkSortOrderEditor
|
||||
:baseOrderKeyNames="gridSortOrderKeys"
|
||||
:currentOrders="sortOrders"
|
||||
@update="onSortOrderUpdate"
|
||||
/>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
<div :class="$style.footerActions">
|
||||
<MkButton primary @click="onSearchRequest">
|
||||
{{ i18n.ts.search }}
|
||||
</MkButton>
|
||||
<MkButton @click="onQueryResetButtonClicked">
|
||||
{{ i18n.ts.reset }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
|
||||
|
||||
import {
|
||||
gridSortOrderKeys,
|
||||
} from './custom-emojis-manager.impl.js';
|
||||
|
||||
import { i18n } from '@/i18n.js';
|
||||
import * as os from '@/os.js';
|
||||
|
||||
import type { EmojiSearchQuery } from './custom-emojis-manager.local.list.vue';
|
||||
import type { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
import type { GridSortOrderKey } from './custom-emojis-manager.impl.js';
|
||||
|
||||
const props = defineProps<{
|
||||
query: EmojiSearchQuery;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
(ev: 'queryUpdated', query: EmojiSearchQuery): void;
|
||||
(ev: 'sortOrderUpdated', orders: SortOrder<GridSortOrderKey>[]): void;
|
||||
(ev: 'search'): void;
|
||||
}>();
|
||||
|
||||
const model = ref<EmojiSearchQuery>(props.query);
|
||||
const queryRolesText = computed(() => model.value.roles.map(it => it.name).join(','));
|
||||
|
||||
watch(model, () => {
|
||||
emit('queryUpdated', model.value);
|
||||
}, { deep: true });
|
||||
|
||||
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
|
||||
|
||||
function onSortOrderUpdate(orders: SortOrder<GridSortOrderKey>[]) {
|
||||
sortOrders.value = orders;
|
||||
emit('sortOrderUpdated', orders);
|
||||
}
|
||||
|
||||
function onSearchRequest() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function onQueryResetButtonClicked() {
|
||||
model.value.name = '';
|
||||
model.value.category = '';
|
||||
model.value.aliases = '';
|
||||
model.value.type = '';
|
||||
model.value.license = '';
|
||||
model.value.sensitive = null;
|
||||
model.value.localOnly = null;
|
||||
model.value.updatedAtFrom = '';
|
||||
model.value.updatedAtTo = '';
|
||||
sortOrders.value = [];
|
||||
}
|
||||
|
||||
async function onQueryRolesEditClicked() {
|
||||
const result = await os.selectRole({
|
||||
initialRoleIds: model.value.roles.map(it => it.id),
|
||||
title: i18n.ts._customEmojisManager._local._list.dialogSelectRoleTitle,
|
||||
publicOnly: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value.roles = result.result;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style module>
|
||||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footerActions {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
padding: var(--MI-margin);
|
||||
background-color: var(--MI_THEME-bg);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,660 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader :overridePageMetadata="headerPageMetadata" :actions="headerActions"/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="_gaps" :class="$style.main">
|
||||
<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
|
||||
<template v-else>
|
||||
<div v-if="gridItems.length === 0" style="text-align: center">
|
||||
{{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div :class="$style.grid">
|
||||
<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="gridItems.length > 0" :class="$style.footer">
|
||||
<div :class="$style.left">
|
||||
<MkButton danger style="margin-right: auto" @click="onDeleteButtonClicked">
|
||||
{{ i18n.ts.delete }} ({{ deleteItemsCount }})
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
<div :class="$style.center">
|
||||
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.right">
|
||||
<MkButton primary :disabled="updateButtonDisabled" @click="onUpdateButtonClicked">
|
||||
{{ i18n.ts.update }} ({{ updatedItemsCount }})
|
||||
</MkButton>
|
||||
<MkButton @click="onGridResetButtonClicked">{{ i18n.ts.reset }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
import type { GridSortOrderKey } from './custom-emojis-manager.impl.js';
|
||||
|
||||
export type EmojiSearchQuery = {
|
||||
name: string | null;
|
||||
category: string | null;
|
||||
aliases: string | null;
|
||||
type: string | null;
|
||||
license: string | null;
|
||||
updatedAtFrom: string | null;
|
||||
updatedAtTo: string | null;
|
||||
sensitive: string | null;
|
||||
localOnly: string | null;
|
||||
roles: { id: string, name: string }[];
|
||||
sortOrders: SortOrder<GridSortOrderKey>[];
|
||||
limit: number;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onMounted, ref, nextTick, useCssModule } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os.js';
|
||||
import {
|
||||
emptyStrToEmptyArray,
|
||||
emptyStrToNull,
|
||||
emptyStrToUndefined,
|
||||
RequestLogItem,
|
||||
roleIdsParser,
|
||||
} from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { validators } from '@/components/grid/cell-validators.js';
|
||||
import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import MkPagingButtons from '@/components/MkPagingButtons.vue';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import { copyGridDataToClipboard, removeDataFromGrid } from '@/components/grid/grid-utils.js';
|
||||
import { useLoading } from "@/components/hook/useLoading.js";
|
||||
|
||||
type GridItem = {
|
||||
checked: boolean;
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
category: string;
|
||||
aliases: string;
|
||||
license: string;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
|
||||
fileId?: string;
|
||||
updatedAt: string | null;
|
||||
publicUrl?: string | null;
|
||||
originalUrl?: string | null;
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
const $style = useCssModule();
|
||||
|
||||
const required = validators.required();
|
||||
const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
|
||||
const unique = validators.unique();
|
||||
return {
|
||||
root: {
|
||||
noOverflowStyle: true,
|
||||
rounded: false,
|
||||
outerBorder: false,
|
||||
},
|
||||
row: {
|
||||
showNumber: true,
|
||||
selectable: true,
|
||||
// グリッドの行数をあらかじめ100行確保する
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [
|
||||
{
|
||||
// 初期値から変わっていたら背景色を変更
|
||||
condition: ({ row }) => JSON.stringify(gridItems.value[row.index]) !== JSON.stringify(originGridItems.value[row.index]),
|
||||
applyStyle: { className: $style.changedRow },
|
||||
},
|
||||
{
|
||||
// バリデーションに引っかかっていたら背景色を変更
|
||||
condition: ({ cells }) => cells.some(it => !it.violation.valid),
|
||||
applyStyle: { className: $style.violationRow },
|
||||
},
|
||||
],
|
||||
// 行のコンテキストメニュー設定
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(gridItems, context),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRows,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => {
|
||||
for (const rangedRow of context.rangedRows) {
|
||||
gridItems.value[rangedRow.index].checked = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
events: {
|
||||
delete(rows) {
|
||||
// 行削除時は元データの行を消さず、削除対象としてマークするのみにする
|
||||
for (const row of rows) {
|
||||
gridItems.value[row.index].checked = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'checked', icon: 'ti-trash', type: 'boolean', editable: true, width: 34 },
|
||||
{
|
||||
bindTo: 'url', icon: 'ti-icons', type: 'image', editable: true, width: 'auto', validators: [required],
|
||||
async customValueEditor(row, col, value, cellElement) {
|
||||
const file = await selectFile(cellElement);
|
||||
gridItems.value[row.index].url = file.url;
|
||||
gridItems.value[row.index].fileId = file.id;
|
||||
|
||||
return file.url;
|
||||
},
|
||||
},
|
||||
{
|
||||
bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
|
||||
validators: [required, regex, unique],
|
||||
},
|
||||
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
||||
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
||||
{
|
||||
bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
|
||||
valueTransformer(row) {
|
||||
// バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
|
||||
return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
|
||||
.map((it) => it.name)
|
||||
.join(',');
|
||||
},
|
||||
async customValueEditor(row) {
|
||||
// ID直記入は体験的に最悪なのでモーダルを使って入力する
|
||||
const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
|
||||
const result = await os.selectRole({
|
||||
initialRoleIds: current.map(it => it.id),
|
||||
title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
|
||||
infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
|
||||
publicOnly: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const transform = result.result.map(it => ({ id: it.id, name: it.name }));
|
||||
gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
|
||||
|
||||
return transform;
|
||||
},
|
||||
events: {
|
||||
paste: roleIdsParser,
|
||||
delete(cell) {
|
||||
// デフォルトはundefinedになるが、このプロパティは空配列にしたい
|
||||
gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
|
||||
},
|
||||
},
|
||||
},
|
||||
{ bindTo: 'type', type: 'text', editable: false, width: 90 },
|
||||
{ bindTo: 'updatedAt', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'publicUrl', type: 'text', editable: false, width: 180 },
|
||||
{ bindTo: 'originalUrl', type: 'text', editable: false, width: 180 },
|
||||
],
|
||||
cells: {
|
||||
// セルのコンテキストメニュー設定
|
||||
contextMenuFactory(col, row, value, context) {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => {
|
||||
return copyGridDataToClipboard(gridItems, context);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => {
|
||||
removeDataFromGrid(context, (cell) => {
|
||||
gridItems.value[cell.row.index][cell.column.setting.bindTo] = undefined;
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._local._list.markAsDeleteTargetRanges,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => {
|
||||
for (const rowIdx of [...new Set(context.rangedCells.map(it => it.row.index)).values()]) {
|
||||
gridItems.value[rowIdx].checked = true;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const loadingHandler = useLoading();
|
||||
|
||||
const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
|
||||
const allPages = ref<number>(0);
|
||||
const currentPage = ref<number>(0);
|
||||
|
||||
const searchQuery = ref<EmojiSearchQuery>({
|
||||
name: null,
|
||||
category: null,
|
||||
aliases: null,
|
||||
type: null,
|
||||
license: null,
|
||||
updatedAtFrom: null,
|
||||
updatedAtTo: null,
|
||||
sensitive: null,
|
||||
localOnly: null,
|
||||
roles: [],
|
||||
sortOrders: [],
|
||||
limit: 25,
|
||||
});
|
||||
let searchWindowOpening = false;
|
||||
|
||||
const previousQuery = ref<string | undefined>(undefined);
|
||||
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
|
||||
const requestLogs = ref<RequestLogItem[]>([]);
|
||||
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
const originGridItems = ref<GridItem[]>([]);
|
||||
const updateButtonDisabled = ref<boolean>(false);
|
||||
|
||||
const updatedItemsCount = computed(() => {
|
||||
return gridItems.value.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(originGridItems.value[idx])).length;
|
||||
});
|
||||
const deleteItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
|
||||
|
||||
async function onUpdateButtonClicked() {
|
||||
const _items = gridItems.value;
|
||||
const _originItems = originGridItems.value;
|
||||
if (_items.length !== _originItems.length) {
|
||||
throw new Error('The number of items has been changed. Please refresh the page and try again.');
|
||||
}
|
||||
|
||||
const updatedItems = _items.filter((it, idx) => !it.checked && JSON.stringify(it) !== JSON.stringify(_originItems[idx]));
|
||||
if (updatedItems.length === 0) {
|
||||
await os.alert({
|
||||
type: 'info',
|
||||
text: i18n.ts._customEmojisManager._local._list.alertUpdateEmojisNothingDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._list.confirmUpdateEmojisDescription({ count: updatedItems.length }),
|
||||
});
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = () => {
|
||||
return updatedItems.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/update',
|
||||
{
|
||||
// eslint-disable-next-line
|
||||
id: item.id!,
|
||||
name: item.name,
|
||||
category: emptyStrToNull(item.category),
|
||||
aliases: emptyStrToEmptyArray(item.aliases),
|
||||
license: emptyStrToNull(item.license),
|
||||
isSensitive: item.isSensitive,
|
||||
localOnly: item.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
|
||||
fileId: item.fileId,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
);
|
||||
};
|
||||
|
||||
const result = await os.promiseDialog(Promise.all(action()));
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
requestLogs.value = result.map(it => ({
|
||||
failed: !it.success,
|
||||
url: it.item.url,
|
||||
name: it.item.name,
|
||||
error: it.err ? JSON.stringify(it.err) : undefined,
|
||||
}));
|
||||
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function onDeleteButtonClicked() {
|
||||
const _items = gridItems.value;
|
||||
const _originItems = originGridItems.value;
|
||||
if (_items.length !== _originItems.length) {
|
||||
throw new Error('The number of items has been changed. Please refresh the page and try again.');
|
||||
}
|
||||
|
||||
const deleteItems = _items.filter((it) => it.checked);
|
||||
if (deleteItems.length === 0) {
|
||||
await os.alert({
|
||||
type: 'info',
|
||||
text: i18n.ts._customEmojisManager._local._list.alertDeleteEmojisNothingDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._list.confirmDeleteEmojisDescription({ count: deleteItems.length }),
|
||||
});
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function action() {
|
||||
const deleteIds = deleteItems.map(it => it.id!);
|
||||
await misskeyApi('admin/emoji/delete-bulk', { ids: deleteIds });
|
||||
}
|
||||
|
||||
await os.promiseDialog(
|
||||
action(),
|
||||
);
|
||||
}
|
||||
|
||||
async function onGridResetButtonClicked() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts.resetAreYouSure,
|
||||
text: i18n.ts._customEmojisManager._local._list.confirmResetDescription,
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
refreshGridItems();
|
||||
}
|
||||
|
||||
async function onSearchRequest() {
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function onPageChanged(pageNumber: number) {
|
||||
if (updatedItemsCount.value > 0) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts._customEmojisManager._local._list.confirmMovePage,
|
||||
text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
currentPage.value = pageNumber;
|
||||
await nextTick();
|
||||
refreshCustomEmojis();
|
||||
}
|
||||
|
||||
function onGridEvent(event: GridEvent) {
|
||||
switch (event.type) {
|
||||
case 'cell-validation':
|
||||
onGridCellValidation(event);
|
||||
break;
|
||||
case 'cell-value-change':
|
||||
onGridCellValueChange(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGridCellValidation(event: GridCellValidationEvent) {
|
||||
updateButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
|
||||
}
|
||||
|
||||
function onGridCellValueChange(event: GridCellValueChangeEvent) {
|
||||
const { row, column, newValue } = event;
|
||||
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
|
||||
gridItems.value[row.index][column.setting.bindTo] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCustomEmojis() {
|
||||
const limit = searchQuery.value.limit;
|
||||
|
||||
const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
|
||||
name: emptyStrToUndefined(searchQuery.value.name),
|
||||
type: emptyStrToUndefined(searchQuery.value.type),
|
||||
aliases: emptyStrToUndefined(searchQuery.value.aliases),
|
||||
category: emptyStrToUndefined(searchQuery.value.category),
|
||||
license: emptyStrToUndefined(searchQuery.value.license),
|
||||
isSensitive: searchQuery.value.sensitive ? Boolean(searchQuery.value.sensitive).valueOf() : undefined,
|
||||
localOnly: searchQuery.value.localOnly ? Boolean(searchQuery.value.localOnly).valueOf() : undefined,
|
||||
updatedAtFrom: emptyStrToUndefined(searchQuery.value.updatedAtFrom),
|
||||
updatedAtTo: emptyStrToUndefined(searchQuery.value.updatedAtTo),
|
||||
roleIds: searchQuery.value.roles.map(it => it.id),
|
||||
hostType: 'local',
|
||||
};
|
||||
|
||||
if (JSON.stringify(query) !== previousQuery.value) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
|
||||
query: query,
|
||||
limit: limit,
|
||||
page: currentPage.value,
|
||||
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}` as any),
|
||||
}));
|
||||
|
||||
customEmojis.value = result.emojis;
|
||||
allPages.value = result.allPages;
|
||||
|
||||
previousQuery.value = JSON.stringify(query);
|
||||
|
||||
refreshGridItems();
|
||||
}
|
||||
|
||||
function refreshGridItems() {
|
||||
gridItems.value = customEmojis.value.map(it => ({
|
||||
checked: false,
|
||||
id: it.id,
|
||||
fileId: undefined,
|
||||
url: it.publicUrl,
|
||||
name: it.name,
|
||||
host: it.host ?? '',
|
||||
category: it.category ?? '',
|
||||
aliases: it.aliases.join(','),
|
||||
license: it.license ?? '',
|
||||
isSensitive: it.isSensitive,
|
||||
localOnly: it.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: it.roleIdsThatCanBeUsedThisEmojiAsReaction,
|
||||
updatedAt: it.updatedAt,
|
||||
publicUrl: it.publicUrl,
|
||||
originalUrl: it.originalUrl,
|
||||
type: it.type,
|
||||
}));
|
||||
originGridItems.value = JSON.parse(JSON.stringify(gridItems.value));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCustomEmojis();
|
||||
});
|
||||
|
||||
const headerPageMetadata = computed(() => ({
|
||||
title: i18n.ts._customEmojisManager._local.tabTitleList,
|
||||
icon: 'ti ti-icons',
|
||||
}));
|
||||
|
||||
const headerActions = computed(() => [{
|
||||
icon: 'ti ti-search',
|
||||
text: i18n.ts.search,
|
||||
handler: () => {
|
||||
if (searchWindowOpening) return;
|
||||
searchWindowOpening = true;
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.search.vue')), {
|
||||
query: searchQuery.value,
|
||||
}, {
|
||||
queryUpdated: (query: EmojiSearchQuery) => {
|
||||
searchQuery.value = query;
|
||||
},
|
||||
sortOrderUpdated: (orders: SortOrder<GridSortOrderKey>[]) => {
|
||||
sortOrders.value = orders;
|
||||
},
|
||||
search: () => {
|
||||
onSearchRequest();
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
searchWindowOpening = false;
|
||||
},
|
||||
});
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-list-numbers',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.searchLimit,
|
||||
handler: (ev: MouseEvent) => {
|
||||
async function changeSearchLimit(to: number) {
|
||||
if (updatedItemsCount.value > 0) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
title: i18n.ts._customEmojisManager._local._list.confirmChangeView,
|
||||
text: i18n.ts._customEmojisManager._local._list.confirmMovePageDesciption,
|
||||
});
|
||||
if (canceled) return;
|
||||
}
|
||||
|
||||
searchQuery.value.limit = to;
|
||||
refreshCustomEmojis();
|
||||
}
|
||||
|
||||
os.popupMenu([{
|
||||
type: 'radioOption',
|
||||
text: '25',
|
||||
active: computed(() => searchQuery.value.limit === 25),
|
||||
action: () => changeSearchLimit(25),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: '50',
|
||||
active: computed(() => searchQuery.value.limit === 50),
|
||||
action: () => changeSearchLimit(50),
|
||||
}, {
|
||||
type: 'radioOption',
|
||||
text: '100',
|
||||
active: computed(() => searchQuery.value.limit === 100),
|
||||
action: () => changeSearchLimit(100),
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
},
|
||||
}, {
|
||||
icon: 'ti ti-notes',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.registrationLogs,
|
||||
handler: () => {
|
||||
const { dispose } = os.popup(defineAsyncComponent(() => import('./custom-emojis-manager.local.list.logs.vue')), {
|
||||
logs: requestLogs.value,
|
||||
}, {
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
}]);
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.violationRow {
|
||||
background-color: var(--MI_THEME-infoWarnBg);
|
||||
}
|
||||
|
||||
.changedRow {
|
||||
background-color: var(--MI_THEME-infoBg);
|
||||
}
|
||||
|
||||
.editedRow {
|
||||
background-color: var(--MI_THEME-infoBg);
|
||||
}
|
||||
|
||||
.main {
|
||||
height: calc(100vh - var(--MI-stickyTop) - var(--MI-stickyBottom));
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.grid {
|
||||
width: max-content;
|
||||
border-bottom: 1px solid var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
|
||||
padding: var(--MI-margin);
|
||||
border-top: 1px solid var(--MI_THEME-divider);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
|
||||
& .left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
& .right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 8px 0;
|
||||
border-top: solid 0.5px var(--MI_THEME-divider);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-settings"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._local._register.uploadSettingTitle }}</template>
|
||||
<template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<MkSelect v-model="selectedFolderId">
|
||||
<template #label>{{ i18n.ts.uploadFolder }}</template>
|
||||
<option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
|
||||
{{ folder.name }}
|
||||
</option>
|
||||
</MkSelect>
|
||||
|
||||
<MkSwitch v-model="keepOriginalUploading">
|
||||
<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
|
||||
<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<MkSwitch v-model="directoryToCategory">
|
||||
<template #label>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryLabel }}</template>
|
||||
<template #caption>{{ i18n.ts._customEmojisManager._local._register.directoryToCategoryCaption }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-notes"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
|
||||
<template #caption>
|
||||
{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
|
||||
</template>
|
||||
<XRegisterLogs :logs="requestLogs"/>
|
||||
</MkFolder>
|
||||
|
||||
<div
|
||||
:class="[$style.uploadBox, [isDragOver ? $style.dragOver : {}]]"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@drop.prevent.stop="onDrop"
|
||||
>
|
||||
<div style="margin-top: 1em">
|
||||
{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaCaption }}
|
||||
</div>
|
||||
<ul>
|
||||
<li>{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList1 }}</li>
|
||||
<li><a @click.prevent="onFileSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList2 }}</a></li>
|
||||
<li><a @click.prevent="onDriveSelectClicked">{{ i18n.ts._customEmojisManager._local._register.emojiInputAreaList3 }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="gridItems.length > 0" :class="$style.gridArea">
|
||||
<MkGrid
|
||||
:data="gridItems"
|
||||
:settings="setupGrid()"
|
||||
@event="onGridEvent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="gridItems.length > 0" :class="$style.footer">
|
||||
<MkButton primary :disabled="registerButtonDisabled" @click="onRegistryClicked">
|
||||
{{ i18n.ts.registration }}
|
||||
</MkButton>
|
||||
<MkButton @click="onClearClicked">
|
||||
{{ i18n.ts.clear }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { onMounted, ref, useCssModule } from 'vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import {
|
||||
emptyStrToEmptyArray,
|
||||
emptyStrToNull,
|
||||
RequestLogItem,
|
||||
roleIdsParser,
|
||||
} from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { validators } from '@/components/grid/cell-validators.js';
|
||||
import { chooseFileFromDrive, chooseFileFromPc } from '@/scripts/select-file.js';
|
||||
import { uploadFile } from '@/scripts/upload.js';
|
||||
import { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import { DroppedFile, extractDroppedItems, flattenDroppedFiles } from '@/scripts/file-drop.js';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
|
||||
import { GridRow } from '@/components/grid/row.js';
|
||||
|
||||
const MAXIMUM_EMOJI_REGISTER_COUNT = 100;
|
||||
|
||||
type FolderItem = {
|
||||
id?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type GridItem = {
|
||||
fileId: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
category: string;
|
||||
aliases: string;
|
||||
license: string;
|
||||
isSensitive: boolean;
|
||||
localOnly: boolean;
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: { id: string, name: string }[];
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
const $style = useCssModule();
|
||||
|
||||
const required = validators.required();
|
||||
const regex = validators.regex(/^[a-zA-Z0-9_]+$/);
|
||||
const unique = validators.unique();
|
||||
|
||||
function removeRows(rows: GridRow[]) {
|
||||
const idxes = [...new Set(rows.map(it => it.index))];
|
||||
gridItems.value = gridItems.value.filter((_, i) => !idxes.includes(i));
|
||||
}
|
||||
|
||||
return {
|
||||
row: {
|
||||
showNumber: true,
|
||||
selectable: true,
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [
|
||||
{
|
||||
// 1つでもバリデーションエラーがあれば行全体をエラー表示する
|
||||
condition: ({ cells }) => cells.some(it => !it.violation.valid),
|
||||
applyStyle: { className: $style.violationRow },
|
||||
},
|
||||
],
|
||||
// 行のコンテキストメニュー設定
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(gridItems, context),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRows,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => removeRows(context.rangedRows),
|
||||
},
|
||||
];
|
||||
},
|
||||
events: {
|
||||
delete(rows) {
|
||||
removeRows(rows);
|
||||
},
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto', validators: [required] },
|
||||
{
|
||||
bindTo: 'name', title: 'name', type: 'text', editable: true, width: 140,
|
||||
validators: [required, regex, unique],
|
||||
},
|
||||
{ bindTo: 'category', title: 'category', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'aliases', title: 'aliases', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'license', title: 'license', type: 'text', editable: true, width: 140 },
|
||||
{ bindTo: 'isSensitive', title: 'sensitive', type: 'boolean', editable: true, width: 90 },
|
||||
{ bindTo: 'localOnly', title: 'localOnly', type: 'boolean', editable: true, width: 90 },
|
||||
{
|
||||
bindTo: 'roleIdsThatCanBeUsedThisEmojiAsReaction', title: 'role', type: 'text', editable: true, width: 140,
|
||||
valueTransformer: (row) => {
|
||||
// バックエンドからからはIDと名前のペア配列で受け取るが、表示にIDがあると煩雑なので名前だけにする
|
||||
return gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction
|
||||
.map((it) => it.name)
|
||||
.join(',');
|
||||
},
|
||||
customValueEditor: async (row) => {
|
||||
// ID直記入は体験的に最悪なのでモーダルを使って入力する
|
||||
const current = gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction;
|
||||
const result = await os.selectRole({
|
||||
initialRoleIds: current.map(it => it.id),
|
||||
title: i18n.ts.rolesThatCanBeUsedThisEmojiAsReaction,
|
||||
infoMessage: i18n.ts.rolesThatCanBeUsedThisEmojiAsReactionEmptyDescription,
|
||||
publicOnly: true,
|
||||
});
|
||||
if (result.canceled) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const transform = result.result.map(it => ({ id: it.id, name: it.name }));
|
||||
gridItems.value[row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = transform;
|
||||
|
||||
return transform;
|
||||
},
|
||||
events: {
|
||||
paste: roleIdsParser,
|
||||
delete(cell) {
|
||||
// デフォルトはundefinedになるが、このプロパティは空配列にしたい
|
||||
gridItems.value[cell.row.index].roleIdsThatCanBeUsedThisEmojiAsReaction = [];
|
||||
},
|
||||
},
|
||||
},
|
||||
{ bindTo: 'type', type: 'text', editable: false, width: 90 },
|
||||
],
|
||||
cells: {
|
||||
// セルのコンテキストメニュー設定
|
||||
contextMenuFactory: (col, row, value, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(gridItems, context),
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.deleteSelectionRanges,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => removeRows(context.rangedCells.map(it => it.row)),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uploadFolders = ref<FolderItem[]>([]);
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
const selectedFolderId = ref(defaultStore.state.uploadFolder);
|
||||
const keepOriginalUploading = ref(defaultStore.state.keepOriginalUploading);
|
||||
const directoryToCategory = ref<boolean>(false);
|
||||
const registerButtonDisabled = ref<boolean>(false);
|
||||
const requestLogs = ref<RequestLogItem[]>([]);
|
||||
const isDragOver = ref<boolean>(false);
|
||||
|
||||
async function onRegistryClicked() {
|
||||
const dialogSelection = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._register.confirmRegisterEmojisDescription({ count: MAXIMUM_EMOJI_REGISTER_COUNT }),
|
||||
});
|
||||
|
||||
if (dialogSelection.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const items = gridItems.value;
|
||||
const upload = () => {
|
||||
return items.slice(0, MAXIMUM_EMOJI_REGISTER_COUNT)
|
||||
.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/add', {
|
||||
name: item.name,
|
||||
category: emptyStrToNull(item.category),
|
||||
aliases: emptyStrToEmptyArray(item.aliases),
|
||||
license: emptyStrToNull(item.license),
|
||||
isSensitive: item.isSensitive,
|
||||
localOnly: item.localOnly,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: item.roleIdsThatCanBeUsedThisEmojiAsReaction.map(it => it.id),
|
||||
fileId: item.fileId!,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
);
|
||||
};
|
||||
|
||||
const result = await os.promiseDialog(Promise.all(upload()));
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
requestLogs.value = result.map(it => ({
|
||||
failed: !it.success,
|
||||
url: it.item.url,
|
||||
name: it.item.name,
|
||||
error: it.err ? JSON.stringify(it.err) : undefined,
|
||||
}));
|
||||
|
||||
// 登録に成功したものは一覧から除く
|
||||
const successItems = result.filter(it => it.success).map(it => it.item);
|
||||
gridItems.value = gridItems.value.filter(it => !successItems.includes(it));
|
||||
}
|
||||
|
||||
async function onClearClicked() {
|
||||
const result = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts._customEmojisManager._local._register.confirmClearEmojisDescription,
|
||||
});
|
||||
|
||||
if (!result.canceled) {
|
||||
gridItems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function onDrop(ev: DragEvent) {
|
||||
isDragOver.value = false;
|
||||
|
||||
const droppedFiles = await extractDroppedItems(ev).then(it => flattenDroppedFiles(it));
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
text: i18n.tsx._customEmojisManager._local._register.confirmUploadEmojisDescription({ count: droppedFiles.length }),
|
||||
});
|
||||
if (confirm.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadedItems = Array.of<{ droppedFile: DroppedFile, driveFile: Misskey.entities.DriveFile }>();
|
||||
try {
|
||||
uploadedItems.push(
|
||||
...await os.promiseDialog(
|
||||
Promise.all(
|
||||
droppedFiles.map(async (it) => ({
|
||||
droppedFile: it,
|
||||
driveFile: await uploadFile(
|
||||
it.file,
|
||||
selectedFolderId.value,
|
||||
it.file.name.replace(/\.[^.]+$/, ''),
|
||||
keepOriginalUploading.value,
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
() => {
|
||||
},
|
||||
() => {
|
||||
},
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
// ダイアログは共通部品側で出ているはずなので何もしない
|
||||
return;
|
||||
}
|
||||
|
||||
const items = uploadedItems.map(({ droppedFile, driveFile }) => {
|
||||
const item = fromDriveFile(driveFile);
|
||||
if (directoryToCategory.value) {
|
||||
item.category = droppedFile.path
|
||||
.replace(/^\//, '')
|
||||
.replace(/\/[^/]+$/, '')
|
||||
.replace(droppedFile.file.name, '');
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
gridItems.value.push(...items);
|
||||
}
|
||||
|
||||
async function onFileSelectClicked() {
|
||||
const driveFiles = await chooseFileFromPc(
|
||||
true,
|
||||
{
|
||||
uploadFolder: selectedFolderId.value,
|
||||
keepOriginal: keepOriginalUploading.value,
|
||||
// 拡張子は消す
|
||||
nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
|
||||
},
|
||||
);
|
||||
|
||||
gridItems.value.push(...driveFiles.map(fromDriveFile));
|
||||
}
|
||||
|
||||
async function onDriveSelectClicked() {
|
||||
const driveFiles = await chooseFileFromDrive(true);
|
||||
gridItems.value.push(...driveFiles.map(fromDriveFile));
|
||||
}
|
||||
|
||||
function onGridEvent(event: GridEvent) {
|
||||
switch (event.type) {
|
||||
case 'cell-validation':
|
||||
onGridCellValidation(event);
|
||||
break;
|
||||
case 'cell-value-change':
|
||||
onGridCellValueChange(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGridCellValidation(event: GridCellValidationEvent) {
|
||||
registerButtonDisabled.value = event.all.filter(it => !it.valid).length > 0;
|
||||
}
|
||||
|
||||
function onGridCellValueChange(event: GridCellValueChangeEvent) {
|
||||
const { row, column, newValue } = event;
|
||||
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
|
||||
gridItems.value[row.index][column.setting.bindTo] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
function fromDriveFile(it: Misskey.entities.DriveFile): GridItem {
|
||||
return {
|
||||
fileId: it.id,
|
||||
url: it.url,
|
||||
name: it.name.replace(/(\.[a-zA-Z0-9]+)+$/, ''),
|
||||
host: '',
|
||||
category: '',
|
||||
aliases: '',
|
||||
license: '',
|
||||
isSensitive: it.isSensitive,
|
||||
localOnly: false,
|
||||
roleIdsThatCanBeUsedThisEmojiAsReaction: [],
|
||||
type: it.type,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshUploadFolders() {
|
||||
const result = await misskeyApi('drive/folders', {});
|
||||
uploadFolders.value = Array.of<FolderItem>({ name: '-' }, ...result);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshUploadFolders();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.violationRow {
|
||||
background-color: var(--MI_THEME-infoWarnBg);
|
||||
}
|
||||
|
||||
.uploadBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 0.5px dotted var(--MI_THEME-accentedBg);
|
||||
border-radius: var(--MI-radius);
|
||||
background-color: var(--MI_THEME-accentedBg);
|
||||
box-sizing: border-box;
|
||||
|
||||
&.dragOver {
|
||||
cursor: copy;
|
||||
}
|
||||
}
|
||||
|
||||
.gridArea {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
|
||||
position: sticky;
|
||||
left:0;
|
||||
bottom:0;
|
||||
z-index: 1;
|
||||
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
|
||||
margin-top: calc(var(--MI-margin) * -1);
|
||||
margin-bottom: calc(var(--MI-margin) * -1);
|
||||
padding-top: var(--MI-margin);
|
||||
padding-bottom: var(--MI-margin);
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs" hideTitle thin/>
|
||||
</template>
|
||||
<XListComponent v-if="headerTab === 'list'" key="localList"/>
|
||||
<MkSpacer v-else key="localRegister">
|
||||
<XRegisterComponent/>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import XListComponent from '@/pages/admin/custom-emojis-manager.local.list.vue';
|
||||
import XRegisterComponent from '@/pages/admin/custom-emojis-manager.local.register.vue';
|
||||
|
||||
type PageMode = 'list' | 'register';
|
||||
|
||||
const headerTab = ref<PageMode>('list');
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'list',
|
||||
title: i18n.ts._customEmojisManager._local.tabTitleList,
|
||||
}, {
|
||||
key: 'register',
|
||||
title: i18n.ts._customEmojisManager._local.tabTitleRegister,
|
||||
}]);
|
||||
</script>
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="logs.length > 0" style="display:flex; flex-direction: column; overflow-y: scroll; gap: 16px;">
|
||||
<MkSwitch v-model="showingSuccessLogs">
|
||||
<template #label>{{ i18n.ts._customEmojisManager._logs.showSuccessLogSwitch }}</template>
|
||||
</MkSwitch>
|
||||
<div>
|
||||
<div v-if="filteredLogs.length > 0">
|
||||
<MkGrid
|
||||
:data="filteredLogs"
|
||||
:settings="setupGrid()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ i18n.ts._customEmojisManager._logs.failureLogNothing }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ i18n.ts._customEmojisManager._logs.logNothing }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRefs } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
|
||||
|
||||
import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import type { GridSetting } from '@/components/grid/grid.js';
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
return {
|
||||
row: {
|
||||
showNumber: false,
|
||||
selectable: false,
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRows,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(logs, context),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'failed', title: 'failed', type: 'boolean', editable: false, width: 50 },
|
||||
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: false, width: 140 },
|
||||
{ bindTo: 'error', title: 'log', type: 'text', editable: false, width: 'auto' },
|
||||
],
|
||||
cells: {
|
||||
contextMenuFactory: (col, row, value, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._gridCommon.copySelectionRanges,
|
||||
icon: 'ti ti-copy',
|
||||
action: () => copyGridDataToClipboard(logs, context),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
logs: RequestLogItem[];
|
||||
}>();
|
||||
|
||||
const { logs } = toRefs(props);
|
||||
const showingSuccessLogs = ref<boolean>(false);
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const forceShowing = showingSuccessLogs.value;
|
||||
return logs.value.filter((log) => forceShowing || log.failed);
|
||||
});
|
||||
</script>
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #default>
|
||||
<div :class="$style.root" class="_gaps">
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-search"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchSettings }}</template>
|
||||
<template #caption>
|
||||
{{ i18n.ts._customEmojisManager._gridCommon.searchSettingCaption }}
|
||||
</template>
|
||||
|
||||
<div class="_gaps">
|
||||
<div :class="[[spMode ? $style.searchAreaSp : $style.searchArea]]">
|
||||
<MkInput
|
||||
v-model="queryName"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col1, $style.row1]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>name</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="queryHost"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col2, $style.row1]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>host</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="queryLicense"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col3, $style.row1]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>license</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput
|
||||
v-model="queryUri"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col1, $style.row2]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>uri</template>
|
||||
</MkInput>
|
||||
<MkInput
|
||||
v-model="queryPublicUrl"
|
||||
type="search"
|
||||
autocapitalize="off"
|
||||
:class="[$style.col2, $style.row2]"
|
||||
@enter="onSearchRequest"
|
||||
>
|
||||
<template #label>publicUrl</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<MkFolder :spacerMax="8" :spacerMin="8">
|
||||
<template #icon><i class="ti ti-arrows-sort"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.sortOrder }}</template>
|
||||
<MkSortOrderEditor
|
||||
:baseOrderKeyNames="gridSortOrderKeys"
|
||||
:currentOrders="sortOrders"
|
||||
@update="onSortOrderUpdate"
|
||||
/>
|
||||
</MkFolder>
|
||||
|
||||
<MkInput
|
||||
v-model="queryLimit"
|
||||
type="number"
|
||||
:max="100"
|
||||
>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.searchLimit }}</template>
|
||||
</MkInput>
|
||||
|
||||
<div :class="[[spMode ? $style.searchButtonsSp : $style.searchButtons]]">
|
||||
<MkButton primary @click="onSearchRequest">
|
||||
{{ i18n.ts.search }}
|
||||
</MkButton>
|
||||
<MkButton @click="onQueryResetButtonClicked">
|
||||
{{ i18n.ts.reset }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ti ti-notes"></i></template>
|
||||
<template #label>{{ i18n.ts._customEmojisManager._gridCommon.registrationLogs }}</template>
|
||||
<template #caption>
|
||||
{{ i18n.ts._customEmojisManager._gridCommon.registrationLogsCaption }}
|
||||
</template>
|
||||
<XRegisterLogs :logs="requestLogs"/>
|
||||
</MkFolder>
|
||||
|
||||
<component :is="loadingHandler.component.value" v-if="loadingHandler.showing.value"/>
|
||||
<template v-else>
|
||||
<div v-if="gridItems.length === 0" style="text-align: center">
|
||||
{{ i18n.ts._customEmojisManager._local._list.emojisNothing }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="gridItems.length > 0" :class="$style.gridArea">
|
||||
<MkGrid :data="gridItems" :settings="setupGrid()" @event="onGridEvent"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.footer">
|
||||
<div>
|
||||
<!-- レイアウト調整用のスペース -->
|
||||
</div>
|
||||
|
||||
<div :class="$style.center">
|
||||
<MkPagingButtons :current="currentPage" :max="allPages" :buttonCount="5" @pageChanged="onPageChanged"/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.right">
|
||||
<MkButton primary @click="onImportClicked">
|
||||
{{
|
||||
i18n.ts._customEmojisManager._remote.importEmojisButton
|
||||
}} ({{ checkedItemsCount }})
|
||||
</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, useCssModule } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkGrid from '@/components/grid/MkGrid.vue';
|
||||
import {
|
||||
emptyStrToUndefined,
|
||||
GridSortOrderKey,
|
||||
gridSortOrderKeys,
|
||||
RequestLogItem,
|
||||
} from '@/pages/admin/custom-emojis-manager.impl.js';
|
||||
import { GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { GridSetting } from '@/components/grid/grid.js';
|
||||
import { deviceKind } from '@/scripts/device-kind.js';
|
||||
import MkPagingButtons from '@/components/MkPagingButtons.vue';
|
||||
import MkSortOrderEditor from '@/components/MkSortOrderEditor.vue';
|
||||
import { SortOrder } from '@/components/MkSortOrderEditor.define.js';
|
||||
import { useLoading } from '@/components/hook/useLoading.js';
|
||||
|
||||
type GridItem = {
|
||||
checked: boolean;
|
||||
id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
function setupGrid(): GridSetting {
|
||||
const $style = useCssModule();
|
||||
|
||||
return {
|
||||
row: {
|
||||
// グリッドの行数をあらかじめ100行確保する
|
||||
minimumDefinitionCount: 100,
|
||||
styleRules: [
|
||||
{
|
||||
// チェックされたら背景色を変える
|
||||
condition: ({ row }) => gridItems.value[row.index].checked,
|
||||
applyStyle: { className: $style.changedRow },
|
||||
},
|
||||
],
|
||||
contextMenuFactory: (row, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._remote.importSelectionRows,
|
||||
icon: 'ti ti-download',
|
||||
action: async () => {
|
||||
const targets = context.rangedRows.map(it => gridItems.value[it.index]);
|
||||
await importEmojis(targets);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
cols: [
|
||||
{ bindTo: 'checked', icon: 'ti-download', type: 'boolean', editable: true, width: 34 },
|
||||
{ bindTo: 'url', icon: 'ti-icons', type: 'image', editable: false, width: 'auto' },
|
||||
{ bindTo: 'name', title: 'name', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'host', title: 'host', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'license', title: 'license', type: 'text', editable: false, width: 200 },
|
||||
{ bindTo: 'uri', title: 'uri', type: 'text', editable: false, width: 'auto' },
|
||||
{ bindTo: 'publicUrl', title: 'publicUrl', type: 'text', editable: false, width: 'auto' },
|
||||
],
|
||||
cells: {
|
||||
contextMenuFactory: (col, row, value, context) => {
|
||||
return [
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._remote.selectionRowDetail,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: async () => {
|
||||
const target = customEmojis.value[row.index];
|
||||
const { dispose } = os.popup(MkRemoteEmojiEditDialog, {
|
||||
emoji: {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
host: target.host!,
|
||||
license: target.license,
|
||||
url: target.publicUrl,
|
||||
},
|
||||
}, {
|
||||
done: () => {
|
||||
dispose();
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'button',
|
||||
text: i18n.ts._customEmojisManager._remote.importSelectionRangesRows,
|
||||
icon: 'ti ti-download',
|
||||
action: async () => {
|
||||
const targets = context.rangedCells.map(it => gridItems.value[it.row.index]);
|
||||
await importEmojis(targets);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const loadingHandler = useLoading();
|
||||
|
||||
const customEmojis = ref<Misskey.entities.EmojiDetailedAdmin[]>([]);
|
||||
const allPages = ref<number>(0);
|
||||
const currentPage = ref<number>(0);
|
||||
|
||||
const queryName = ref<string | null>(null);
|
||||
const queryHost = ref<string | null>(null);
|
||||
const queryLicense = ref<string | null>(null);
|
||||
const queryUri = ref<string | null>(null);
|
||||
const queryPublicUrl = ref<string | null>(null);
|
||||
const queryLimit = ref<number>(25);
|
||||
const previousQuery = ref<string | undefined>(undefined);
|
||||
const sortOrders = ref<SortOrder<GridSortOrderKey>[]>([]);
|
||||
const requestLogs = ref<RequestLogItem[]>([]);
|
||||
|
||||
const gridItems = ref<GridItem[]>([]);
|
||||
|
||||
const spMode = computed(() => ['smartphone', 'tablet'].includes(deviceKind));
|
||||
const checkedItemsCount = computed(() => gridItems.value.filter(it => it.checked).length);
|
||||
|
||||
function onSortOrderUpdate(_sortOrders: SortOrder<GridSortOrderKey>[]) {
|
||||
sortOrders.value = _sortOrders;
|
||||
}
|
||||
|
||||
async function onSearchRequest() {
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
function onQueryResetButtonClicked() {
|
||||
queryName.value = null;
|
||||
queryHost.value = null;
|
||||
queryLicense.value = null;
|
||||
queryUri.value = null;
|
||||
queryPublicUrl.value = null;
|
||||
}
|
||||
|
||||
async function onPageChanged(pageNumber: number) {
|
||||
currentPage.value = pageNumber;
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function onImportClicked() {
|
||||
const targets = gridItems.value.filter(it => it.checked);
|
||||
await importEmojis(targets);
|
||||
}
|
||||
|
||||
function onGridEvent(event: GridEvent) {
|
||||
switch (event.type) {
|
||||
case 'cell-value-change':
|
||||
onGridCellValueChange(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function onGridCellValueChange(event: GridCellValueChangeEvent) {
|
||||
const { row, column, newValue } = event;
|
||||
if (gridItems.value.length > row.index && column.setting.bindTo in gridItems.value[row.index]) {
|
||||
gridItems.value[row.index][column.setting.bindTo] = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
async function importEmojis(targets: GridItem[]) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'info',
|
||||
title: i18n.ts._customEmojisManager._remote.confirmImportEmojisTitle,
|
||||
text: i18n.tsx._customEmojisManager._remote.confirmImportEmojisDescription({ count: targets.length }),
|
||||
});
|
||||
|
||||
if (confirm.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await os.promiseDialog(
|
||||
Promise.all(
|
||||
targets.map(item =>
|
||||
misskeyApi(
|
||||
'admin/emoji/copy',
|
||||
{
|
||||
emojiId: item.id!,
|
||||
})
|
||||
.then(() => ({ item, success: true, err: undefined }))
|
||||
.catch(err => ({ item, success: false, err })),
|
||||
),
|
||||
),
|
||||
);
|
||||
const failedItems = result.filter(it => !it.success);
|
||||
|
||||
if (failedItems.length > 0) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.somethingHappened,
|
||||
text: i18n.ts._customEmojisManager._gridCommon.alertEmojisRegisterFailedDescription,
|
||||
});
|
||||
}
|
||||
|
||||
requestLogs.value = result.map(it => ({
|
||||
failed: !it.success,
|
||||
url: it.item.url,
|
||||
name: it.item.name,
|
||||
error: it.err ? JSON.stringify(it.err) : undefined,
|
||||
}));
|
||||
|
||||
await refreshCustomEmojis();
|
||||
}
|
||||
|
||||
async function refreshCustomEmojis() {
|
||||
const query: Misskey.entities.V2AdminEmojiListRequest['query'] = {
|
||||
name: emptyStrToUndefined(queryName.value),
|
||||
host: emptyStrToUndefined(queryHost.value),
|
||||
license: emptyStrToUndefined(queryLicense.value),
|
||||
uri: emptyStrToUndefined(queryUri.value),
|
||||
publicUrl: emptyStrToUndefined(queryPublicUrl.value),
|
||||
hostType: 'remote',
|
||||
};
|
||||
|
||||
if (JSON.stringify(query) !== previousQuery.value) {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
const result = await loadingHandler.scope(() => misskeyApi('v2/admin/emoji/list', {
|
||||
limit: queryLimit.value,
|
||||
query: query,
|
||||
page: currentPage.value,
|
||||
sortKeys: sortOrders.value.map(({ key, direction }) => `${direction}${key}`) as never[],
|
||||
}));
|
||||
|
||||
customEmojis.value = result.emojis;
|
||||
allPages.value = result.allPages;
|
||||
previousQuery.value = JSON.stringify(query);
|
||||
gridItems.value = customEmojis.value.map(it => ({
|
||||
checked: false,
|
||||
id: it.id,
|
||||
url: it.publicUrl,
|
||||
name: it.name,
|
||||
license: it.license,
|
||||
host: it.host!,
|
||||
}));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshCustomEmojis();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.row1 {
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
.row2 {
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
.col1 {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
|
||||
.col2 {
|
||||
grid-column: 2 / 3;
|
||||
}
|
||||
|
||||
.col3 {
|
||||
grid-column: 3 / 4;
|
||||
}
|
||||
|
||||
.root {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.changedRow {
|
||||
background-color: var(--MI_THEME-infoBg);
|
||||
}
|
||||
|
||||
.searchArea {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.searchButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchButtonsSp {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.searchAreaSp {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gridArea {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
background-color: var(--MI_THEME-buttonBg);
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
margin: 0 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: var(--MI_THEME-bg);
|
||||
|
||||
position: sticky;
|
||||
left:0;
|
||||
bottom:0;
|
||||
z-index: 1;
|
||||
// stickyで追従させる都合上、フッター自身でpaddingを持つ必要があるため、親要素で画一的に指定している分をネガティブマージンで相殺している
|
||||
margin-top: calc(var(--MI-margin) * -1);
|
||||
margin-bottom: calc(var(--MI-margin) * -1);
|
||||
padding-top: var(--MI-margin);
|
||||
padding-bottom: var(--MI-margin);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
|
||||
& .center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
& .right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { entities } from 'misskey-js';
|
||||
import { commonHandlers } from '../../../.storybook/mocks.js';
|
||||
import { emoji } from '../../../.storybook/fakes.js';
|
||||
import { fakeId } from '../../../.storybook/fake-utils.js';
|
||||
import custom_emojis_manager2 from './custom-emojis-manager2.vue';
|
||||
|
||||
function createRender(params: {
|
||||
emojis: entities.EmojiDetailedAdmin[];
|
||||
}) {
|
||||
const storedEmojis: entities.EmojiDetailedAdmin[] = [...params.emojis];
|
||||
const storedDriveFiles: entities.DriveFile[] = [];
|
||||
|
||||
return {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
custom_emojis_manager2,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<custom_emojis_manager2 v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
http.post('/api/v2/admin/emoji/list', async ({ request }) => {
|
||||
await delay(100);
|
||||
|
||||
const bodyStream = request.body as ReadableStream;
|
||||
const body = await new Response(bodyStream).json() as entities.V2AdminEmojiListRequest;
|
||||
|
||||
const emojis = storedEmojis;
|
||||
const limit = body.limit ?? 10;
|
||||
const page = body.page ?? 1;
|
||||
const result = emojis.slice((page - 1) * limit, page * limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
emojis: result,
|
||||
count: Math.min(emojis.length, limit),
|
||||
allCount: emojis.length,
|
||||
allPages: Math.ceil(emojis.length / limit),
|
||||
});
|
||||
}),
|
||||
http.post('/api/drive/folders', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
http.post('/api/drive/files', () => {
|
||||
return HttpResponse.json(storedDriveFiles);
|
||||
}),
|
||||
http.post('/api/drive/files/create', async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const file = data.get('file');
|
||||
if (!file || !(file instanceof File)) {
|
||||
return HttpResponse.json({ error: 'file is required' }, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// FIXME: ファイルのバイナリに0xEF 0xBF 0xBDが混入してしまい、うまく画像ファイルとして表示できない問題がある
|
||||
const base64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(new Blob([file], { type: 'image/webp' }));
|
||||
});
|
||||
|
||||
const driveFile: entities.DriveFile = {
|
||||
id: fakeId(file.name),
|
||||
createdAt: new Date().toISOString(),
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
md5: '',
|
||||
size: file.size,
|
||||
isSensitive: false,
|
||||
blurhash: null,
|
||||
properties: {},
|
||||
url: base64,
|
||||
thumbnailUrl: null,
|
||||
comment: null,
|
||||
folderId: null,
|
||||
folder: null,
|
||||
userId: null,
|
||||
user: null,
|
||||
};
|
||||
|
||||
storedDriveFiles.push(driveFile);
|
||||
|
||||
return HttpResponse.json(driveFile);
|
||||
}),
|
||||
http.post('api/admin/emoji/add', async ({ request }) => {
|
||||
await delay(100);
|
||||
|
||||
const bodyStream = request.body as ReadableStream;
|
||||
const body = await new Response(bodyStream).json() as entities.AdminEmojiAddRequest;
|
||||
|
||||
const fileId = body.fileId;
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const file = storedDriveFiles.find(f => f.id === fileId)!;
|
||||
|
||||
const em = emoji({
|
||||
id: fakeId(file.name),
|
||||
name: body.name,
|
||||
publicUrl: file.url,
|
||||
originalUrl: file.url,
|
||||
type: file.type,
|
||||
aliases: body.aliases,
|
||||
category: body.category ?? undefined,
|
||||
license: body.license ?? undefined,
|
||||
localOnly: body.localOnly,
|
||||
isSensitive: body.isSensitive,
|
||||
});
|
||||
storedEmojis.push(em);
|
||||
|
||||
return HttpResponse.json(null);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
} satisfies StoryObj<typeof custom_emojis_manager2>;
|
||||
}
|
||||
|
||||
export const Default = createRender({
|
||||
emojis: [],
|
||||
});
|
||||
|
||||
export const List10 = createRender({
|
||||
emojis: Array.from({ length: 10 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
|
||||
});
|
||||
|
||||
export const List100 = createRender({
|
||||
emojis: Array.from({ length: 100 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
|
||||
});
|
||||
|
||||
export const List1000 = createRender({
|
||||
emojis: Array.from({ length: 1000 }, (_, i) => emoji({ name: `emoji_${i}` }, i.toString())),
|
||||
});
|
||||
51
packages/frontend/src/pages/admin/custom-emojis-manager2.vue
Normal file
51
packages/frontend/src/pages/admin/custom-emojis-manager2.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader v-model:tab="headerTab" :tabs="headerTabs"/>
|
||||
</template>
|
||||
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
|
||||
<XGridRemoteComponent v-else/>
|
||||
</MkStickyContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import XGridLocalComponent from '@/pages/admin/custom-emojis-manager.local.vue';
|
||||
import XGridRemoteComponent from '@/pages/admin/custom-emojis-manager.remote.vue';
|
||||
import MkPageHeader from '@/components/global/MkPageHeader.vue';
|
||||
import MkStickyContainer from '@/components/global/MkStickyContainer.vue';
|
||||
|
||||
type PageMode = 'local' | 'remote';
|
||||
|
||||
const headerTab = ref<PageMode>('local');
|
||||
|
||||
const headerTabs = computed(() => [{
|
||||
key: 'local',
|
||||
title: i18n.ts.local,
|
||||
}, {
|
||||
key: 'remote',
|
||||
title: i18n.ts.remote,
|
||||
}]);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: i18n.ts.customEmojis,
|
||||
icon: 'ti ti-icons',
|
||||
needWideArea: true,
|
||||
})));
|
||||
</script>
|
||||
|
||||
<style lang="css" module>
|
||||
.local {
|
||||
height: calc(100dvh - var(--MI-stickyTop) - var(--MI-stickyBottom));
|
||||
overflow: clip;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -35,6 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
import { onActivated, onMounted, onUnmounted, provide, watch, ref, computed } from 'vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
||||
import type { SuperMenuDef } from '@/components/MkSuperMenu.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import { instance } from '@/instance.js';
|
||||
import { lookup } from '@/scripts/lookup.js';
|
||||
|
|
@ -56,7 +57,7 @@ const indexInfo = {
|
|||
|
||||
provide('shouldOmitHeaderTitle', false);
|
||||
|
||||
const INFO = ref(indexInfo);
|
||||
const INFO = ref<PageMetadata>(indexInfo);
|
||||
const childInfo = ref<null | PageMetadata>(null);
|
||||
const narrow = ref(false);
|
||||
const view = ref(null);
|
||||
|
|
@ -91,7 +92,7 @@ const ro = new ResizeObserver((entries, observer) => {
|
|||
narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
|
||||
});
|
||||
|
||||
const menuDef = computed(() => [{
|
||||
const menuDef = computed<SuperMenuDef[]>(() => [{
|
||||
title: i18n.ts.quickAction,
|
||||
items: [{
|
||||
type: 'button',
|
||||
|
|
@ -99,7 +100,7 @@ const menuDef = computed(() => [{
|
|||
text: i18n.ts.lookup,
|
||||
action: adminLookup,
|
||||
}, ...(instance.disableRegistration ? [{
|
||||
type: 'button',
|
||||
type: 'button' as const,
|
||||
icon: 'ti ti-user-plus',
|
||||
text: i18n.ts.createInviteCode,
|
||||
action: invite,
|
||||
|
|
@ -136,6 +137,11 @@ const menuDef = computed(() => [{
|
|||
text: i18n.ts.customEmojis,
|
||||
to: '/admin/emojis',
|
||||
active: currentPage.value?.route.name === 'emojis',
|
||||
}, {
|
||||
icon: 'ti ti-icons',
|
||||
text: i18n.ts.customEmojis + '(beta)',
|
||||
to: '/admin/emojis2',
|
||||
active: currentPage.value?.route.name === 'emojis2',
|
||||
}, {
|
||||
icon: 'ti ti-sparkles',
|
||||
text: i18n.ts.avatarDecorations,
|
||||
|
|
@ -343,12 +349,14 @@ defineExpose({
|
|||
height: 100%;
|
||||
|
||||
> .nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
width: 32%;
|
||||
max-width: 280px;
|
||||
box-sizing: border-box;
|
||||
border-right: solid 0.5px var(--MI_THEME-divider);
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
> .main {
|
||||
|
|
|
|||
|
|
@ -641,7 +641,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="role.policies.avatarDecorationLimit.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0">
|
||||
<MkInput v-model="role.policies.avatarDecorationLimit.value" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit">
|
||||
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.avatarDecorationLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
|
|
@ -757,6 +757,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import { throttle } from 'throttle-debounce';
|
||||
import { ROLE_POLICIES } from '@@/js/const.js';
|
||||
import RolesEditorFormula from './RolesEditorFormula.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkColorInput from '@/components/MkColorInput.vue';
|
||||
|
|
@ -767,7 +768,6 @@ import MkSwitch from '@/components/MkSwitch.vue';
|
|||
import MkRange from '@/components/MkRange.vue';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { ROLE_POLICIES } from '@@/js/const.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
|
||||
|
|
@ -793,6 +793,12 @@ for (const ROLE_POLICY of ROLE_POLICIES) {
|
|||
}
|
||||
}
|
||||
|
||||
function updateAvatarDecorationLimit(value: string | number) {
|
||||
const numValue = Number(value);
|
||||
const limited = Math.min(16, Math.max(0, numValue));
|
||||
role.value.policies.avatarDecorationLimit.value = limited;
|
||||
}
|
||||
|
||||
const rolePermission = computed({
|
||||
get: () => role.value.isAdministrator ? 'administrator' : role.value.isModerator ? 'moderator' : 'normal',
|
||||
set: (val) => {
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkFolder v-if="matchQuery([i18n.ts._role._options.avatarDecorationLimit, 'avatarDecorationLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.avatarDecorationLimit }}</template>
|
||||
<template #suffix>{{ policies.avatarDecorationLimit }}</template>
|
||||
<MkInput v-model="policies.avatarDecorationLimit" type="number" :min="0">
|
||||
<MkInput v-model="avatarDecorationLimit" type="number" :min="0" :max="16" @update:modelValue="updateAvatarDecorationLimit">
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
|
|
@ -334,6 +334,17 @@ for (const ROLE_POLICY of ROLE_POLICIES) {
|
|||
policies[ROLE_POLICY] = instance.policies[ROLE_POLICY];
|
||||
}
|
||||
|
||||
const avatarDecorationLimit = computed({
|
||||
get: () => Math.min(16, Math.max(0, policies.avatarDecorationLimit)),
|
||||
set: (value) => {
|
||||
policies.avatarDecorationLimit = Math.min(Number(value), 16);
|
||||
},
|
||||
});
|
||||
|
||||
function updateAvatarDecorationLimit(value: string | number) {
|
||||
avatarDecorationLimit.value = Number(value);
|
||||
}
|
||||
|
||||
function matchQuery(keywords: string[]): boolean {
|
||||
if (baseRoleQ.value.trim().length === 0) return true;
|
||||
return keywords.some(keyword => keyword.toLowerCase().includes(baseRoleQ.value.toLowerCase()));
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkNotes :pagination="featuredPagination"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'search'" key="search">
|
||||
<div class="_gaps">
|
||||
<div v-if="notesSearchAvailable" class="_gaps">
|
||||
<div>
|
||||
<MkInput v-model="searchQuery" @enter="search()">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
|
|
@ -54,6 +54,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<MkNotes v-if="searchPagination" :key="searchKey" :pagination="searchPagination"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<MkInfo warn>{{ i18n.ts.notesSearchNotAvailable }}</MkInfo>
|
||||
</div>
|
||||
</div>
|
||||
</MkHorizontalSwipe>
|
||||
</MkSpacer>
|
||||
|
|
@ -94,6 +97,7 @@ import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
|
|||
import { PageHeaderItem } from '@/types/page-header.js';
|
||||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { notesSearchAvailable } from '@/scripts/check-permissions.js';
|
||||
import { miLocalStorage } from '@/local-storage.js';
|
||||
import { useRouter } from '@/router/supplier.js';
|
||||
import { deepMerge } from '@/scripts/merge.js';
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<MkSpacer :contentMax="1200">
|
||||
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
|
||||
<div v-if="tab === 'search'" key="search">
|
||||
<div v-if="tab === 'search'" key="search" :class="$style.searchRoot">
|
||||
<div class="_gaps">
|
||||
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter="search">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
|
|
@ -27,23 +27,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
<div v-if="tab === 'featured'" key="featured">
|
||||
<MkPagination v-slot="{items}" :pagination="featuredPagination">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
|
||||
<div :class="$style.root">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'favorites'" key="favorites">
|
||||
<MkPagination v-slot="{items}" :pagination="favoritesPagination">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
|
||||
<div :class="$style.root">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'following'" key="following">
|
||||
<MkPagination v-slot="{items}" :pagination="followingPagination">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
|
||||
<div :class="$style.root">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
<div v-else-if="tab === 'owned'" key="owned">
|
||||
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
|
||||
<MkPagination v-slot="{items}" :pagination="ownedPagination">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" class="_margin" :channel="channel"/>
|
||||
<div :class="$style.root">
|
||||
<MkChannelPreview v-for="channel in items" :key="channel.id" :channel="channel"/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkHorizontalSwipe>
|
||||
|
|
@ -85,6 +93,7 @@ onMounted(() => {
|
|||
|
||||
const featuredPagination = {
|
||||
endpoint: 'channels/featured' as const,
|
||||
limit: 10,
|
||||
noPaging: true,
|
||||
};
|
||||
const favoritesPagination = {
|
||||
|
|
@ -157,3 +166,17 @@ definePageMetadata(() => ({
|
|||
icon: 'ti ti-device-tv',
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.searchRoot {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: var(--MI-margin);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -46,9 +46,10 @@ import { clipsCache } from '@/cache.js';
|
|||
import { isSupportShare } from '@/scripts/navigator.js';
|
||||
import { copyToClipboard } from '@/scripts/copy-to-clipboard.js';
|
||||
import { genEmbedCode } from '@/scripts/get-embed-code.js';
|
||||
import { getServerContext } from '@/server-context.js';
|
||||
import { assertServerContext, serverContext } from '@/server-context.js';
|
||||
|
||||
const CTX_CLIP = getServerContext('clip');
|
||||
// contextは非ログイン状態の情報しかないためログイン時は利用できない
|
||||
const CTX_CLIP = !$i && assertServerContext(serverContext, 'clip') ? serverContext.clip : null;
|
||||
|
||||
const props = defineProps<{
|
||||
clipId: string,
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #default="{items}">
|
||||
<div class="ldhfsamy">
|
||||
<button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)">
|
||||
<img :src="`/emoji/${emoji.name}.webp`" class="img" :alt="emoji.name"/>
|
||||
<img :src="emoji.url" class="img" :alt="emoji.name"/>
|
||||
<div class="body">
|
||||
<div class="name _monospace">{{ emoji.name }}</div>
|
||||
<div class="info">{{ emoji.category }}</div>
|
||||
|
|
@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #default="{items}">
|
||||
<div class="ldhfsamy">
|
||||
<div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)">
|
||||
<img :src="`/emoji/${emoji.name}@${emoji.host}.webp`" class="img" :alt="emoji.name"/>
|
||||
<img :src="getProxiedImageUrl(emoji.url, 'emoji')" class="img" :alt="emoji.name"/>
|
||||
<div class="body">
|
||||
<div class="name _monospace">{{ emoji.name }}</div>
|
||||
<div class="info">{{ emoji.host }}</div>
|
||||
|
|
@ -78,11 +78,13 @@ import { computed, defineAsyncComponent, ref, shallowRef } from 'vue';
|
|||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkRemoteEmojiEditDialog from '@/components/MkRemoteEmojiEditDialog.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
import { selectFile } from '@/scripts/select-file.js';
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
|
||||
|
|
@ -161,6 +163,19 @@ const edit = (emoji) => {
|
|||
});
|
||||
};
|
||||
|
||||
const detailRemoteEmoji = (emoji) => {
|
||||
const { dispose } = os.popup(MkRemoteEmojiEditDialog, {
|
||||
emoji: emoji,
|
||||
}, {
|
||||
done: () => {
|
||||
dispose();
|
||||
},
|
||||
closed: () => {
|
||||
dispose();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const importEmoji = (emoji) => {
|
||||
os.apiWithDialog('admin/emoji/copy', {
|
||||
emojiId: emoji.id,
|
||||
|
|
@ -171,13 +186,15 @@ const remoteMenu = (emoji, ev: MouseEvent) => {
|
|||
os.popupMenu([{
|
||||
type: 'label',
|
||||
text: ':' + emoji.name + ':',
|
||||
},
|
||||
{
|
||||
}, {
|
||||
text: i18n.ts.details,
|
||||
icon: 'ti ti-info-circle',
|
||||
action: () => { detailRemoteEmoji(emoji); },
|
||||
}, {
|
||||
text: i18n.ts.import,
|
||||
icon: 'ti ti-plus',
|
||||
action: () => { importEmoji(emoji); },
|
||||
},
|
||||
{
|
||||
}, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ph-trash ph-bold ph-lg',
|
||||
action: () => {
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ watch(roleIdsThatCanBeUsedThisEmojiAsReaction, async () => {
|
|||
rolesThatCanBeUsedThisEmojiAsReaction.value = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.value.map((id) => misskeyApi('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
|
||||
}, { immediate: true });
|
||||
|
||||
const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? `/emoji/${props.emoji.name}.webp` : null);
|
||||
const imgUrl = computed(() => file.value ? file.value.url : props.emoji ? props.emoji.url : null);
|
||||
|
||||
async function changeImage(ev: Event) {
|
||||
file.value = await selectFile(ev.currentTarget ?? ev.target, null);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template>
|
||||
<MkSpacer :contentMax="1200">
|
||||
<MkTab v-model="origin" style="margin-bottom: var(--MI-margin);">
|
||||
<MkTab v-if="instance.federation !== 'none'" v-model="origin" style="margin-bottom: var(--MI-margin);">
|
||||
<option value="local">{{ i18n.ts.local }}</option>
|
||||
<option value="remote">{{ i18n.ts.remote }}</option>
|
||||
</MkTab>
|
||||
|
|
@ -69,6 +69,7 @@ import MkUserList from '@/components/MkUserList.vue';
|
|||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import MkTab from '@/components/MkTab.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -59,18 +59,18 @@ async function onAccept(token: string) {
|
|||
name: props.name,
|
||||
iconUrl: props.icon,
|
||||
permission: _permissions.value,
|
||||
}, token).catch(() => {
|
||||
}, token).then(() => {
|
||||
if (props.callback && props.callback !== '') {
|
||||
const cbUrl = new URL(props.callback);
|
||||
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url');
|
||||
cbUrl.searchParams.set('session', props.session);
|
||||
location.href = cbUrl.toString();
|
||||
} else {
|
||||
authRoot.value?.showUI('success');
|
||||
}
|
||||
}).catch(() => {
|
||||
authRoot.value?.showUI('failed');
|
||||
});
|
||||
|
||||
if (props.callback && props.callback !== '') {
|
||||
const cbUrl = new URL(props.callback);
|
||||
if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:', 'vbscript:'].includes(cbUrl.protocol)) throw new Error('invalid url');
|
||||
cbUrl.searchParams.set('session', props.session);
|
||||
location.href = cbUrl.toString();
|
||||
} else {
|
||||
authRoot.value?.showUI('success');
|
||||
}
|
||||
}
|
||||
|
||||
function onDeny() {
|
||||
|
|
@ -117,5 +117,6 @@ definePageMetadata(() => ({
|
|||
border-radius: var(--MI-radius);
|
||||
background-color: var(--MI_THEME-panel);
|
||||
overflow-x: scroll;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, computed, watch, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { host } from '@@/js/config.js';
|
||||
import type { Paging } from '@/components/MkPagination.vue';
|
||||
import MkNotes from '@/components/MkNotes.vue';
|
||||
import MkRemoteCaution from '@/components/MkRemoteCaution.vue';
|
||||
|
|
@ -61,9 +62,11 @@ import { dateString } from '@/filters/date.js';
|
|||
import MkClipPreview from '@/components/MkClipPreview.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { pleaseLogin } from '@/scripts/please-login.js';
|
||||
import { getServerContext } from '@/server-context.js';
|
||||
import { serverContext, assertServerContext } from '@/server-context.js';
|
||||
import { $i } from '@/account.js';
|
||||
|
||||
const CTX_NOTE = getServerContext('note');
|
||||
// contextは非ログイン状態の情報しかないためログイン時は利用できない
|
||||
const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;
|
||||
|
||||
const MkNoteDetailed = defineAsyncComponent(() =>
|
||||
(defaultStore.state.noteDesign === 'misskey') ? import('@/components/MkNoteDetailed.vue') :
|
||||
|
|
@ -146,7 +149,12 @@ function fetchNote() {
|
|||
}).catch(err => {
|
||||
if (err.id === '8e75455b-738c-471d-9f80-62693f33372e') {
|
||||
pleaseLogin({
|
||||
path: '/',
|
||||
message: i18n.ts.thisContentsAreMarkedAsSigninRequiredByAuthor,
|
||||
openOnRemote: {
|
||||
type: 'lookup',
|
||||
url: `https://${host}/notes/${props.noteId}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
error.value = err;
|
||||
|
|
|
|||
|
|
@ -13,16 +13,20 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #header>{{ i18n.ts.options }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkRadios v-model="hostSelect">
|
||||
<template #label>{{ i18n.ts.host }}</template>
|
||||
<option value="all" default>{{ i18n.ts.all }}</option>
|
||||
<option value="local">{{ i18n.ts.local }}</option>
|
||||
<option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
|
||||
</MkRadios>
|
||||
<MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
|
||||
<template #prefix><i class="ti ti-server"></i></template>
|
||||
</MkInput>
|
||||
<template v-if="instance.federation !== 'none'">
|
||||
<MkRadios v-model="hostSelect">
|
||||
<template #label>{{ i18n.ts.host }}</template>
|
||||
<option value="all" default>{{ i18n.ts.all }}</option>
|
||||
<option value="local">{{ i18n.ts.local }}</option>
|
||||
<option v-if="noteSearchableScope === 'global'" value="specified">{{ i18n.ts.specifyHost }}</option>
|
||||
</MkRadios>
|
||||
<MkInput v-if="noteSearchableScope === 'global'" v-model="hostInput" :disabled="hostSelect !== 'specified'" :large="true" type="search">
|
||||
<template #prefix><i class="ti ti-server"></i></template>
|
||||
</MkInput>
|
||||
</template>
|
||||
|
||||
<MkSwitch v-model="order">Sort by newest to oldest</MkSwitch>
|
||||
|
||||
<MkSelect v-model="filetype" small>
|
||||
<template #label>File Type</template>
|
||||
<option :value="null">None</option>
|
||||
|
|
@ -114,7 +118,7 @@ setHostSelectWithInput(hostInput.value, undefined);
|
|||
watch(hostInput, setHostSelectWithInput);
|
||||
|
||||
const searchHost = computed(() => {
|
||||
if (hostSelect.value === 'local') return '.';
|
||||
if (hostSelect.value === 'local' || instance.federation === 'none') return '.';
|
||||
if (hostSelect.value === 'specified') return hostInput.value;
|
||||
return null;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkInput v-model="searchQuery" :large="true" :autofocus="true" type="search" @enter.prevent="search">
|
||||
<template #prefix><i class="ti ti-search"></i></template>
|
||||
</MkInput>
|
||||
<MkRadios v-model="searchOrigin" @update:modelValue="search()">
|
||||
<MkRadios v-if="instance.federation !== 'none'" v-model="searchOrigin" @update:modelValue="search()">
|
||||
<option value="combined">{{ i18n.ts.all }}</option>
|
||||
<option value="local">{{ i18n.ts.local }}</option>
|
||||
<option value="remote">{{ i18n.ts.remote }}</option>
|
||||
|
|
@ -33,6 +33,7 @@ import MkInput from '@/components/MkInput.vue';
|
|||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import * as os from '@/os.js';
|
||||
import MkFoldableSection from '@/components/MkFoldableSection.vue';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
|
|
@ -118,7 +119,7 @@ async function search() {
|
|||
limit: 10,
|
||||
params: {
|
||||
query: query,
|
||||
origin: searchOrigin.value,
|
||||
origin: instance.federation === 'none' ? 'local' : searchOrigin.value,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkButton @click="init"><i class="ti ti-refresh"></i> {{ i18n.ts.reloadAccountsList }}</MkButton>
|
||||
</div>
|
||||
|
||||
<MkUserCardMini v-for="user in accounts" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/>
|
||||
<template v-for="[id, user] in accounts">
|
||||
<MkUserCardMini v-if="user != null" :key="user.id" :user="user" :class="$style.user" @click.prevent="menu(user, $event)"/>
|
||||
<button v-else v-panel class="_button" :class="$style.unknownUser" @click="menu(id, $event)">
|
||||
<div :class="$style.unknownUserAvatarMock"><i class="ti ti-user-question"></i></div>
|
||||
<div>
|
||||
<div :class="$style.unknownUserTitle">{{ i18n.ts.unknown }}</div>
|
||||
<div :class="$style.unknownUserSub">ID: <span class="_monospace">{{ id }}</span></div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</div>
|
||||
|
|
@ -29,9 +38,10 @@ import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWith
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import { MenuItem } from '@/types/menu';
|
||||
|
||||
const storedAccounts = ref<{ id: string, token: string }[] | null>(null);
|
||||
const accounts = ref<Misskey.entities.UserDetailed[]>([]);
|
||||
const accounts = ref(new Map<string, Misskey.entities.UserDetailed | null>());
|
||||
|
||||
const init = async () => {
|
||||
getAccounts().then(accounts => {
|
||||
|
|
@ -41,21 +51,35 @@ const init = async () => {
|
|||
userIds: storedAccounts.value.map(x => x.id),
|
||||
});
|
||||
}).then(response => {
|
||||
accounts.value = response;
|
||||
if (storedAccounts.value == null) return;
|
||||
accounts.value = new Map(storedAccounts.value.map(x => [x.id, response.find((y: Misskey.entities.UserDetailed) => y.id === x.id) ?? null]));
|
||||
});
|
||||
};
|
||||
|
||||
function menu(account: Misskey.entities.UserDetailed, ev: MouseEvent) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.switch,
|
||||
icon: 'ti ti-switch-horizontal',
|
||||
action: () => switchAccount(account),
|
||||
}, {
|
||||
text: i18n.ts.logout,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => removeAccount(account),
|
||||
}], ev.currentTarget ?? ev.target);
|
||||
function menu(account: Misskey.entities.UserDetailed | string, ev: MouseEvent) {
|
||||
let menu: MenuItem[];
|
||||
|
||||
if (typeof account === 'string') {
|
||||
menu = [{
|
||||
text: i18n.ts.logout,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => removeAccount(account),
|
||||
}];
|
||||
} else {
|
||||
menu = [{
|
||||
text: i18n.ts.switch,
|
||||
icon: 'ti ti-switch-horizontal',
|
||||
action: () => switchAccount(account.id),
|
||||
}, {
|
||||
text: i18n.ts.logout,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: () => removeAccount(account.id),
|
||||
}];
|
||||
}
|
||||
|
||||
os.popupMenu(menu, ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
function addAccount(ev: MouseEvent) {
|
||||
|
|
@ -68,9 +92,9 @@ function addAccount(ev: MouseEvent) {
|
|||
}], ev.currentTarget ?? ev.target);
|
||||
}
|
||||
|
||||
async function removeAccount(account: Misskey.entities.UserDetailed) {
|
||||
await _removeAccount(account.id);
|
||||
accounts.value = accounts.value.filter(x => x.id !== account.id);
|
||||
async function removeAccount(id: string) {
|
||||
await _removeAccount(id);
|
||||
accounts.value.delete(id);
|
||||
}
|
||||
|
||||
function addExistingAccount() {
|
||||
|
|
@ -90,9 +114,9 @@ function createAccount() {
|
|||
});
|
||||
}
|
||||
|
||||
async function switchAccount(account: Misskey.entities.UserDetailed) {
|
||||
async function switchAccount(id: string) {
|
||||
const fetchedAccounts = await getAccounts();
|
||||
const token = fetchedAccounts.find(x => x.id === account.id)!.token;
|
||||
const token = fetchedAccounts.find(x => x.id === id)!.token;
|
||||
switchAccountWithToken(token);
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +136,49 @@ definePageMetadata(() => ({
|
|||
|
||||
<style lang="scss" module>
|
||||
.user {
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.unknownUser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: start;
|
||||
padding: 16px;
|
||||
background: var(--MI_THEME-panel);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.unknownUserAvatarMock {
|
||||
display: block;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
line-height: 34px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
background-color: color-mix(in srgb, var(--MI_THEME-fg), transparent 85%);
|
||||
color: color-mix(in srgb, var(--MI_THEME-fg), transparent 25%);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.unknownUserTitle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.unknownUserSub {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 95%;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 16px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSwitch v-model="limitWidthOfReaction">{{ i18n.ts.limitWidthOfReaction }}</MkSwitch>
|
||||
</div>
|
||||
|
||||
<MkSelect v-model="instanceTicker">
|
||||
<MkSelect v-if="instance.federation !== 'none'" v-model="instanceTicker">
|
||||
<template #label>{{ i18n.ts.instanceTicker }}</template>
|
||||
<option value="none">{{ i18n.ts._instanceTicker.none }}</option>
|
||||
<option value="remote">{{ i18n.ts._instanceTicker.remote }}</option>
|
||||
|
|
@ -357,6 +357,7 @@ import MkInfo from '@/components/MkInfo.vue';
|
|||
import { searchEngineMap } from '@/scripts/search-engine-map.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import * as os from '@/os.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { reloadAsk } from '@/scripts/reload-ask.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const indexInfo = {
|
|||
icon: 'ti ti-settings',
|
||||
hideHeader: true,
|
||||
};
|
||||
const INFO = ref(indexInfo);
|
||||
const INFO = ref<PageMetadata>(indexInfo);
|
||||
const el = shallowRef<HTMLElement | null>(null);
|
||||
const childInfo = ref<null | PageMetadata>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -9,17 +9,24 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #icon><i class="ph-envelope ph-bold ph-lg"></i></template>
|
||||
<template #label>{{ i18n.ts.wordMute }}</template>
|
||||
|
||||
<XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/>
|
||||
<div class="_gaps_m">
|
||||
<MkInfo>{{ i18n.ts.wordMuteDescription }}</MkInfo>
|
||||
<MkSwitch v-model="showSoftWordMutedWord">{{ i18n.ts.showMutedWord }}</MkSwitch>
|
||||
<XWordMute :muted="$i.mutedWords" @save="saveMutedWords"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #icon><i class="ph-x-square ph-bold ph-lg"></i></template>
|
||||
<template #label>{{ i18n.ts.hardWordMute }}</template>
|
||||
|
||||
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
|
||||
<div class="_gaps_m">
|
||||
<MkInfo>{{ i18n.ts.hardWordMuteDescription }}</MkInfo>
|
||||
<XWordMute :muted="$i.hardMutedWords" @save="saveHardMutedWords"/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<MkFolder v-if="instance.federation !== 'none'">
|
||||
<template #icon><i class="ti ti-planet-off"></i></template>
|
||||
<template #label>{{ i18n.ts.instanceMute }}</template>
|
||||
|
||||
|
|
@ -126,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import XInstanceMute from './mute-block.instance-mute.vue';
|
||||
import XWordMute from './mute-block.word-mute.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
|
|
@ -135,9 +142,13 @@ import { i18n } from '@/i18n.js';
|
|||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { infoImageUrl } from '@/instance.js';
|
||||
import { instance, infoImageUrl } from '@/instance.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { reloadAsk } from '@/scripts/reload-ask.js';
|
||||
|
||||
const $i = signinRequired();
|
||||
|
||||
|
|
@ -160,6 +171,14 @@ const expandedRenoteMuteItems = ref([]);
|
|||
const expandedMuteItems = ref([]);
|
||||
const expandedBlockItems = ref([]);
|
||||
|
||||
const showSoftWordMutedWord = computed(defaultStore.makeGetterSetter('showSoftWordMutedWord'));
|
||||
|
||||
watch([
|
||||
showSoftWordMutedWord,
|
||||
], async () => {
|
||||
await reloadAsk({ reason: i18n.ts.reloadToApplySetting, unison: true });
|
||||
});
|
||||
|
||||
async function unrenoteMute(user, ev) {
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.renoteUnmute,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<template #caption>
|
||||
<div>{{ i18n.ts._accountSettings.requireSigninToViewContentsDescription1 }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription2 }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div>
|
||||
<div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.requireSigninToViewContentsDescription3 }}</div>
|
||||
</template>
|
||||
</MkSwitch>
|
||||
|
||||
|
|
@ -93,7 +93,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._accountSettings.makeNotesFollowersOnlyBeforeDescription }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
|
||||
<div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
|
||||
</template>
|
||||
</FormSlot>
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<template #caption>
|
||||
<div>{{ i18n.ts._accountSettings.makeNotesHiddenBeforeDescription }}</div>
|
||||
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
|
||||
<div v-if="instance.federation !== 'none'"><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._accountSettings.mayNotEffectForFederatedNotes }}</div>
|
||||
</template>
|
||||
</FormSlot>
|
||||
</div>
|
||||
|
|
@ -171,6 +171,7 @@ import MkFolder from '@/components/MkFolder.vue';
|
|||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { signinRequired } from '@/account.js';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||
import FormSlot from '@/components/form/slot.vue';
|
||||
|
|
@ -224,7 +225,7 @@ watch([makeNotesFollowersOnlyBefore, makeNotesHiddenBefore], () => {
|
|||
});
|
||||
|
||||
async function update_requireSigninToViewContents(value: boolean) {
|
||||
if (value) {
|
||||
if (value === true && instance.federation !== 'none') {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.acknowledgeNotesAndEnable,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkSelect v-model="statusbar.type" placeholder="Please select">
|
||||
<template #label>{{ i18n.ts.type }}</template>
|
||||
<option value="rss">RSS</option>
|
||||
<option value="federation">Federation</option>
|
||||
<option v-if="instance.federation !== 'none'" value="federation">Federation</option>
|
||||
<option value="userList">User list timeline</option>
|
||||
</MkSelect>
|
||||
|
||||
|
|
@ -96,6 +96,7 @@ import MkButton from '@/components/MkButton.vue';
|
|||
import MkRange from '@/components/MkRange.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { instance } from '@/instance.js';
|
||||
import { deepClone } from '@/scripts/clone.js';
|
||||
|
||||
const props = defineProps<{
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { watch, ref, computed } from 'vue';
|
||||
import { toUnicode } from 'punycode/';
|
||||
import { toUnicode } from 'punycode.js';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import JSON5 from 'json5';
|
||||
|
|
|
|||
56
packages/frontend/src/pages/user/files.vue
Normal file
56
packages/frontend/src/pages/user/files.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<MkSpacer :contentMax="1100">
|
||||
<div :class="$style.root">
|
||||
<MkPagination v-slot="{items}" :pagination="pagination">
|
||||
<div :class="$style.stream">
|
||||
<MkNoteMediaGrid v-for="note in items" :note="note" square/>
|
||||
</div>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
|
||||
import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
}>();
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'users/notes' as const,
|
||||
limit: 15,
|
||||
params: computed(() => ({
|
||||
userId: props.user.id,
|
||||
withFiles: true,
|
||||
})),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stream {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--MI-marginHalf);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
.stream {
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
|
@ -138,7 +138,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<MkInfo v-if="user.pinnedNotes.length === 0 && $i?.id === user.id">{{ i18n.ts.userPagePinTip }}</MkInfo>
|
||||
<template v-if="narrow">
|
||||
<MkLazy>
|
||||
<XFiles :key="user.id" :user="user" :collapsed="true"/>
|
||||
<XFiles :key="user.id" :user="user" :collapsed="true" @unfold="emit('unfoldFiles')"/>
|
||||
</MkLazy>
|
||||
<MkLazy>
|
||||
<XActivity :key="user.id" :user="user" :collapsed="true"/>
|
||||
|
|
@ -180,7 +180,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
</div>
|
||||
</div>
|
||||
<div v-if="!narrow" class="sub _gaps" style="container-type: inline-size;">
|
||||
<XFiles :key="user.id" :user="user"/>
|
||||
<XFiles :key="user.id" :user="user" @unfold="emit('unfoldFiles')"/>
|
||||
<XActivity :key="user.id" :user="user"/>
|
||||
<XListenBrainz v-if="user.listenbrainz && listenbrainzdata" :key="user.id" :user="user"/>
|
||||
</div>
|
||||
|
|
@ -242,7 +242,6 @@ function calcAge(birthdate: string): number {
|
|||
const XFiles = defineAsyncComponent(() => import('./index.files.vue'));
|
||||
const XActivity = defineAsyncComponent(() => import('./index.activity.vue'));
|
||||
const XListenBrainz = defineAsyncComponent(() => import('./index.listenbrainz.vue'));
|
||||
//const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
|
|
@ -252,6 +251,10 @@ const props = withDefaults(defineProps<{
|
|||
disableNotes: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'unfoldFiles'): void;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const user = ref(props.user);
|
||||
|
|
|
|||
|
|
@ -4,30 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
-->
|
||||
|
||||
<template>
|
||||
<MkContainer :max-height="300" :foldable="true" :expanded="!collapsed">
|
||||
<MkContainer :max-height="300" :foldable="true" :expanded="!collapsed" :onUnfold="unfoldContainer">
|
||||
<template #icon><i class="ti ti-photo"></i></template>
|
||||
<template #header>{{ i18n.ts.files }}</template>
|
||||
<div :class="$style.root">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-if="!fetching && files.length > 0" :class="$style.stream">
|
||||
<template v-for="file in files" :key="file.note.id + file.file.id">
|
||||
<div v-if="file.file.isSensitive && !showingFiles.includes(file.file.id)" :class="$style.img" @click="showingFiles.push(file.file.id)">
|
||||
<!-- TODO: 画像以外のファイルに対応 -->
|
||||
<ImgWithBlurhash :class="$style.sensitiveImg" :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name" :forceBlurhash="true"/>
|
||||
<div :class="$style.sensitive">
|
||||
<div>
|
||||
<div><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}</div>
|
||||
<div>{{ i18n.ts.clickToShow }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MkA v-else :class="$style.img" :to="notePage(file.note)">
|
||||
<!-- TODO: 画像以外のファイルに対応 -->
|
||||
<ImgWithBlurhash :hash="file.file.blurhash" :src="thumbnail(file.file)" :title="file.file.name"/>
|
||||
</MkA>
|
||||
</template>
|
||||
<div v-if="!fetching && notes.length > 0" :class="$style.stream">
|
||||
<MkNoteMediaGrid v-for="note in notes" :note="note"/>
|
||||
</div>
|
||||
<p v-if="!fetching && files.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
|
||||
<p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
|
||||
</div>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
|
@ -35,13 +20,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy.js';
|
||||
import { notePage } from '@/filters/note.js';
|
||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { defaultStore } from '@/store.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import MkNoteMediaGrid from '@/components/MkNoteMediaGrid.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.UserDetailed;
|
||||
|
|
@ -50,33 +32,25 @@ const props = withDefaults(defineProps<{
|
|||
collapsed: false,
|
||||
});
|
||||
|
||||
const fetching = ref(true);
|
||||
const files = ref<{
|
||||
note: Misskey.entities.Note;
|
||||
file: Misskey.entities.DriveFile;
|
||||
}[]>([]);
|
||||
const showingFiles = ref<string[]>([]);
|
||||
const emit = defineEmits<{
|
||||
(ev: 'unfold'): void;
|
||||
}>();
|
||||
|
||||
function thumbnail(image: Misskey.entities.DriveFile): string {
|
||||
return defaultStore.state.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(image.url)
|
||||
: image.thumbnailUrl;
|
||||
const fetching = ref(true);
|
||||
const notes = ref<Misskey.entities.Note[]>([]);
|
||||
|
||||
function unfoldContainer(): boolean {
|
||||
emit('unfold');
|
||||
return false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
misskeyApi('users/notes', {
|
||||
userId: props.user.id,
|
||||
withFiles: true,
|
||||
limit: 15,
|
||||
}).then(notes => {
|
||||
for (const note of notes) {
|
||||
for (const file of note.files) {
|
||||
files.value.push({
|
||||
note,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}
|
||||
limit: 10,
|
||||
}).then(_notes => {
|
||||
notes.value = _notes;
|
||||
fetching.value = false;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,10 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||
<div>
|
||||
<div v-if="user">
|
||||
<MkHorizontalSwipe v-model:tab="tab" :tabs="headerTabs">
|
||||
<XHome v-if="tab === 'home'" key="home" :user="user"/>
|
||||
<XHome v-if="tab === 'home'" key="home" :user="user" @unfoldFiles="() => { tab = 'files'; }"/>
|
||||
<MkSpacer v-else-if="tab === 'notes'" key="notes" :contentMax="800" style="padding-top: 0">
|
||||
<XTimeline :user="user"/>
|
||||
</MkSpacer>
|
||||
<XFiles v-else-if="tab === 'files'" :user="user"/>
|
||||
<XActivity v-else-if="tab === 'activity'" key="activity" :user="user"/>
|
||||
<XAchievements v-else-if="tab === 'achievements'" key="achievements" :user="user"/>
|
||||
<XReactions v-else-if="tab === 'reactions'" key="reactions" :user="user"/>
|
||||
|
|
@ -39,10 +40,11 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
|||
import { i18n } from '@/i18n.js';
|
||||
import { $i } from '@/account.js';
|
||||
import MkHorizontalSwipe from '@/components/MkHorizontalSwipe.vue';
|
||||
import { getServerContext } from '@/server-context.js';
|
||||
import { serverContext, assertServerContext } from '@/server-context.js';
|
||||
|
||||
const XHome = defineAsyncComponent(() => import('./home.vue'));
|
||||
const XTimeline = defineAsyncComponent(() => import('./index.timeline.vue'));
|
||||
const XFiles = defineAsyncComponent(() => import('./files.vue'));
|
||||
const XActivity = defineAsyncComponent(() => import('./activity.vue'));
|
||||
const XAchievements = defineAsyncComponent(() => import('./achievements.vue'));
|
||||
const XReactions = defineAsyncComponent(() => import('./reactions.vue'));
|
||||
|
|
@ -53,7 +55,8 @@ const XFlashs = defineAsyncComponent(() => import('./flashs.vue'));
|
|||
const XGallery = defineAsyncComponent(() => import('./gallery.vue'));
|
||||
const XRaw = defineAsyncComponent(() => import('./raw.vue'));
|
||||
|
||||
const CTX_USER = getServerContext('user');
|
||||
// contextは非ログイン状態の情報しかないためログイン時は利用できない
|
||||
const CTX_USER = !$i && assertServerContext(serverContext, 'user') ? serverContext.user : null;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
acct: string;
|
||||
|
|
@ -102,6 +105,10 @@ const headerTabs = computed(() => user.value ? [{
|
|||
key: 'notes',
|
||||
title: i18n.ts.notes,
|
||||
icon: 'ti ti-pencil',
|
||||
}, {
|
||||
key: 'files',
|
||||
title: i18n.ts.files,
|
||||
icon: 'ti ti-photo',
|
||||
}, {
|
||||
key: 'activity',
|
||||
title: i18n.ts.activity,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue