rename: client -> frontend
89
packages/frontend/.eslintrc.js
Normal file
|
@ -0,0 +1,89 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
'node': false,
|
||||
},
|
||||
parser: 'vue-eslint-parser',
|
||||
parserOptions: {
|
||||
'parser': '@typescript-eslint/parser',
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
extraFileExtensions: ['.vue'],
|
||||
},
|
||||
extends: [
|
||||
'../shared/.eslintrc.js',
|
||||
'plugin:vue/vue3-recommended',
|
||||
],
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-interface': [
|
||||
'error',
|
||||
{
|
||||
'allowSingleExtends': true,
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/prefer-nullish-coalescing': [
|
||||
'error',
|
||||
],
|
||||
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
|
||||
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
|
||||
'id-denylist': ['error', 'window', 'e'],
|
||||
'no-shadow': ['warn'],
|
||||
'vue/attributes-order': ['error', {
|
||||
'alphabetical': false,
|
||||
}],
|
||||
'vue/no-use-v-if-with-v-for': ['error', {
|
||||
'allowUsingIterationVar': false,
|
||||
}],
|
||||
'vue/no-ref-as-operand': 'error',
|
||||
'vue/no-multi-spaces': ['error', {
|
||||
'ignoreProperties': false,
|
||||
}],
|
||||
'vue/no-v-html': 'warn',
|
||||
'vue/order-in-components': 'error',
|
||||
'vue/html-indent': ['warn', 'tab', {
|
||||
'attribute': 1,
|
||||
'baseIndent': 0,
|
||||
'closeBracket': 0,
|
||||
'alignAttributesVertically': true,
|
||||
'ignores': [],
|
||||
}],
|
||||
'vue/html-closing-bracket-spacing': ['warn', {
|
||||
'startTag': 'never',
|
||||
'endTag': 'never',
|
||||
'selfClosingTag': 'never',
|
||||
}],
|
||||
'vue/multi-word-component-names': 'warn',
|
||||
'vue/require-v-for-key': 'warn',
|
||||
'vue/no-unused-components': 'warn',
|
||||
'vue/valid-v-for': 'warn',
|
||||
'vue/return-in-computed-property': 'warn',
|
||||
'vue/no-setup-props-destructure': 'warn',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
// (vue/vue3-recommended disabled the autofix for Vue 2 compatibility)
|
||||
'vue/v-on-event-hyphenation': ['warn', 'always', { autofix: true }],
|
||||
},
|
||||
globals: {
|
||||
// Node.js
|
||||
'module': false,
|
||||
'require': false,
|
||||
'__dirname': false,
|
||||
|
||||
// Vue
|
||||
'$$': false,
|
||||
'$ref': false,
|
||||
'$shallowRef': false,
|
||||
'$computed': false,
|
||||
|
||||
// Misskey
|
||||
'_DEV_': false,
|
||||
'_LANGS_': false,
|
||||
'_VERSION_': false,
|
||||
'_ENV_': false,
|
||||
'_PERF_PREFIX_': false,
|
||||
'_DATA_TRANSFER_DRIVE_FILE_': false,
|
||||
'_DATA_TRANSFER_DRIVE_FOLDER_': false,
|
||||
'_DATA_TRANSFER_DECK_COLUMN_': false,
|
||||
},
|
||||
};
|
11
packages/frontend/.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"path-intellisense.mappings": {
|
||||
"@": "${workspaceRoot}/packages/frontend/src/"
|
||||
},
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"vue"
|
||||
]
|
||||
}
|
10
packages/frontend/@types/global.d.ts
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
type FIXME = any;
|
||||
|
||||
declare const _LANGS_: string[][];
|
||||
declare const _VERSION_: string;
|
||||
declare const _ENV_: string;
|
||||
declare const _DEV_: boolean;
|
||||
declare const _PERF_PREFIX_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FILE_: string;
|
||||
declare const _DATA_TRANSFER_DRIVE_FOLDER_: string;
|
||||
declare const _DATA_TRANSFER_DECK_COLUMN_: string;
|
7
packages/frontend/@types/theme.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
declare module '@/themes/*.json5' {
|
||||
import { Theme } from "@/scripts/theme";
|
||||
|
||||
const theme: Theme;
|
||||
|
||||
export default theme;
|
||||
}
|
16
packages/frontend/@types/vue.d.ts
vendored
Normal file
|
@ -0,0 +1,16 @@
|
|||
/// <reference types="vue/macros-global" />
|
||||
|
||||
import type { $i } from '@/account';
|
||||
import type { defaultStore } from '@/store';
|
||||
import type { instance } from '@/instance';
|
||||
import type { i18n } from '@/i18n';
|
||||
|
||||
declare module 'vue' {
|
||||
interface ComponentCustomProperties {
|
||||
$i: typeof $i;
|
||||
$store: typeof defaultStore;
|
||||
$instance: typeof instance;
|
||||
$t: typeof i18n['t'];
|
||||
$ts: typeof i18n['ts'];
|
||||
}
|
||||
}
|
BIN
packages/frontend/assets/about-icon.png
Normal file
After Width: | Height: | Size: 20 KiB |
BIN
packages/frontend/assets/dummy.png
Normal file
After Width: | Height: | Size: 6.1 KiB |
BIN
packages/frontend/assets/fedi.jpg
Normal file
After Width: | Height: | Size: 76 KiB |
6
packages/frontend/assets/label-red.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
|
||||
<polygon fill="#ea2412" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
|
||||
</svg>
|
After Width: | Height: | Size: 441 B |
6
packages/frontend/assets/label.svg
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" width="96px" height="96px" viewBox="0 0 96 96" enable-background="new 0 0 96 96" xml:space="preserve">
|
||||
<polygon fill="#0B8AEA" points="0,45.255 45.254,0 84.854,0 0,84.854 "/>
|
||||
</svg>
|
After Width: | Height: | Size: 441 B |
7
packages/frontend/assets/misskey.svg
Normal file
After Width: | Height: | Size: 9.2 KiB |
BIN
packages/frontend/assets/remove.png
Normal file
After Width: | Height: | Size: 424 B |
BIN
packages/frontend/assets/sounds/aisha/1.mp3
Normal file
BIN
packages/frontend/assets/sounds/aisha/2.mp3
Normal file
BIN
packages/frontend/assets/sounds/aisha/3.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba1.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba2.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba3.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba4.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba5.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba6.mp3
Normal file
BIN
packages/frontend/assets/sounds/noizenecio/kick_gaba7.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/down.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/kick.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/pirori-square-wet.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/pirori-wet.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/pirori.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/poi1.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/poi2.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/pope1.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/pope2.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/popo.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/queue-jammed.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/reverved.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/ryukyu.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/snare.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/square-pico.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/triple.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/up.mp3
Normal file
BIN
packages/frontend/assets/sounds/syuilo/waon.mp3
Normal file
21
packages/frontend/assets/tagcanvas.min.js
vendored
Normal file
7
packages/frontend/assets/unread.svg
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" width="32px" height="32px" viewBox="0 0 32 32" enable-background="new 0 0 32 32" xml:space="preserve">
|
||||
<circle fill="#3AA2DC" cx="16.5" cy="16.5" r="6"/>
|
||||
</svg>
|
After Width: | Height: | Size: 536 B |
94
packages/frontend/package.json
Normal file
|
@ -0,0 +1,94 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "vue-tsc --noEmit && eslint --quiet \"src/**/*.{ts,vue}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordapp/twemoji": "14.0.2",
|
||||
"@rollup/plugin-alias": "4.0.2",
|
||||
"@rollup/plugin-json": "6.0.0",
|
||||
"@rollup/pluginutils": "5.0.2",
|
||||
"@syuilo/aiscript": "0.11.1",
|
||||
"@tabler/icons": "^1.118.0",
|
||||
"@vitejs/plugin-vue": "4.0.0",
|
||||
"@vue/compiler-sfc": "3.2.45",
|
||||
"autobind-decorator": "2.4.0",
|
||||
"autosize": "5.0.2",
|
||||
"blurhash": "2.0.4",
|
||||
"broadcast-channel": "4.18.1",
|
||||
"browser-image-resizer": "git+https://github.com/misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"chart.js": "4.1.1",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "^1.3.0",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.0",
|
||||
"compare-versions": "5.0.1",
|
||||
"cropperjs": "2.0.0-beta",
|
||||
"date-fns": "2.29.3",
|
||||
"escape-regexp": "0.0.1",
|
||||
"eventemitter3": "5.0.0",
|
||||
"idb-keyval": "6.2.0",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
"json5": "2.2.2",
|
||||
"katex": "0.15.6",
|
||||
"matter-js": "0.18.0",
|
||||
"mfm-js": "0.23.0",
|
||||
"misskey-js": "0.0.14",
|
||||
"photoswipe": "5.3.4",
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.1.1",
|
||||
"querystring": "0.2.1",
|
||||
"rndstr": "1.0.0",
|
||||
"rollup": "3.8.0",
|
||||
"s-age": "1.1.2",
|
||||
"sass": "1.57.1",
|
||||
"seedrandom": "3.0.5",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.148.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.4.2",
|
||||
"tsc-alias": "1.8.2",
|
||||
"tsconfig-paths": "4.1.1",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typescript": "4.9.4",
|
||||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vite": "4.0.3",
|
||||
"vue": "3.2.45",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/glob": "8.0.0",
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp-rename": "2.0.1",
|
||||
"@types/katex": "0.14.0",
|
||||
"@types/matter-js": "0.18.2",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/seedrandom": "3.0.3",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/uuid": "9.0.0",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "5.47.0",
|
||||
"@typescript-eslint/parser": "5.47.0",
|
||||
"@vue/runtime-core": "3.2.45",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.2.0",
|
||||
"eslint": "8.30.0",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"eslint-plugin-vue": "9.8.0",
|
||||
"start-server-and-test": "1.15.2",
|
||||
"vue-eslint-parser": "^9.1.0",
|
||||
"vue-tsc": "^1.0.16"
|
||||
}
|
||||
}
|
238
packages/frontend/src/account.ts
Normal file
|
@ -0,0 +1,238 @@
|
|||
import { defineAsyncComponent, reactive } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { showSuspendedDialog } from './scripts/show-suspended-dialog';
|
||||
import { i18n } from './i18n';
|
||||
import { del, get, set } from '@/scripts/idb-proxy';
|
||||
import { apiUrl } from '@/config';
|
||||
import { waiting, api, popup, popupMenu, success, alert } from '@/os';
|
||||
import { unisonReload, reloadChannel } from '@/scripts/unison-reload';
|
||||
|
||||
// TODO: 他のタブと永続化されたstateを同期
|
||||
|
||||
type Account = misskey.entities.MeDetailed;
|
||||
|
||||
const accountData = localStorage.getItem('account');
|
||||
|
||||
// TODO: 外部からはreadonlyに
|
||||
export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
|
||||
|
||||
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
|
||||
export const iAmAdmin = $i != null && $i.isAdmin;
|
||||
|
||||
export async function signout() {
|
||||
waiting();
|
||||
localStorage.removeItem('account');
|
||||
|
||||
await removeAccount($i.id);
|
||||
|
||||
const accounts = await getAccounts();
|
||||
|
||||
//#region Remove service worker registration
|
||||
try {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const push = await registration.pushManager.getSubscription();
|
||||
if (push) {
|
||||
await window.fetch(`${apiUrl}/sw/unregister`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: $i.token,
|
||||
endpoint: push.endpoint,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
await navigator.serviceWorker.getRegistrations()
|
||||
.then(registrations => {
|
||||
return Promise.all(registrations.map(registration => registration.unregister()));
|
||||
});
|
||||
}
|
||||
} catch (err) {}
|
||||
//#endregion
|
||||
|
||||
document.cookie = 'igi=; path=/';
|
||||
|
||||
if (accounts.length > 0) login(accounts[0].token);
|
||||
else unisonReload('/');
|
||||
}
|
||||
|
||||
export async function getAccounts(): Promise<{ id: Account['id'], token: Account['token'] }[]> {
|
||||
return (await get('accounts')) || [];
|
||||
}
|
||||
|
||||
export async function addAccount(id: Account['id'], token: Account['token']) {
|
||||
const accounts = await getAccounts();
|
||||
if (!accounts.some(x => x.id === id)) {
|
||||
await set('accounts', accounts.concat([{ id, token }]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeAccount(id: Account['id']) {
|
||||
const accounts = await getAccounts();
|
||||
accounts.splice(accounts.findIndex(x => x.id === id), 1);
|
||||
|
||||
if (accounts.length > 0) await set('accounts', accounts);
|
||||
else await del('accounts');
|
||||
}
|
||||
|
||||
function fetchAccount(token: string): Promise<Account> {
|
||||
return new Promise((done, fail) => {
|
||||
// Fetch user
|
||||
window.fetch(`${apiUrl}/i`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
i: token,
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(res => {
|
||||
if (res.error) {
|
||||
if (res.error.id === 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370') {
|
||||
showSuspendedDialog().then(() => {
|
||||
signout();
|
||||
});
|
||||
} else {
|
||||
alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.failedToFetchAccountInformation,
|
||||
text: JSON.stringify(res.error),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.token = token;
|
||||
done(res);
|
||||
}
|
||||
})
|
||||
.catch(fail);
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAccount(accountData) {
|
||||
for (const [key, value] of Object.entries(accountData)) {
|
||||
$i[key] = value;
|
||||
}
|
||||
localStorage.setItem('account', JSON.stringify($i));
|
||||
}
|
||||
|
||||
export function refreshAccount() {
|
||||
return fetchAccount($i.token).then(updateAccount);
|
||||
}
|
||||
|
||||
export async function login(token: Account['token'], redirect?: string) {
|
||||
waiting();
|
||||
if (_DEV_) console.log('logging as token ', token);
|
||||
const me = await fetchAccount(token);
|
||||
localStorage.setItem('account', JSON.stringify(me));
|
||||
document.cookie = `token=${token}; path=/; max-age=31536000`; // bull dashboardの認証とかで使う
|
||||
await addAccount(me.id, token);
|
||||
|
||||
if (redirect) {
|
||||
// 他のタブは再読み込みするだけ
|
||||
reloadChannel.postMessage(null);
|
||||
// このページはredirectで指定された先に移動
|
||||
location.href = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
unisonReload();
|
||||
}
|
||||
|
||||
export async function openAccountMenu(opts: {
|
||||
includeCurrentAccount?: boolean;
|
||||
withExtraOperation: boolean;
|
||||
active?: misskey.entities.UserDetailed['id'];
|
||||
onChoose?: (account: misskey.entities.UserDetailed) => void;
|
||||
}, ev: MouseEvent) {
|
||||
function showSigninDialog() {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, {
|
||||
done: res => {
|
||||
addAccount(res.id, res.i);
|
||||
success();
|
||||
},
|
||||
}, 'closed');
|
||||
}
|
||||
|
||||
function createAccount() {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, {
|
||||
done: res => {
|
||||
addAccount(res.id, res.i);
|
||||
switchAccountWithToken(res.i);
|
||||
},
|
||||
}, 'closed');
|
||||
}
|
||||
|
||||
async function switchAccount(account: misskey.entities.UserDetailed) {
|
||||
const storedAccounts = await getAccounts();
|
||||
const token = storedAccounts.find(x => x.id === account.id).token;
|
||||
switchAccountWithToken(token);
|
||||
}
|
||||
|
||||
function switchAccountWithToken(token: string) {
|
||||
login(token);
|
||||
}
|
||||
|
||||
const storedAccounts = await getAccounts().then(accounts => accounts.filter(x => x.id !== $i.id));
|
||||
const accountsPromise = api('users/show', { userIds: storedAccounts.map(x => x.id) });
|
||||
|
||||
function createItem(account: misskey.entities.UserDetailed) {
|
||||
return {
|
||||
type: 'user',
|
||||
user: account,
|
||||
active: opts.active != null ? opts.active === account.id : false,
|
||||
action: () => {
|
||||
if (opts.onChoose) {
|
||||
opts.onChoose(account);
|
||||
} else {
|
||||
switchAccount(account);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const accountItemPromises = storedAccounts.map(a => new Promise(res => {
|
||||
accountsPromise.then(accounts => {
|
||||
const account = accounts.find(x => x.id === a.id);
|
||||
if (account == null) return res(null);
|
||||
res(createItem(account));
|
||||
});
|
||||
}));
|
||||
|
||||
if (opts.withExtraOperation) {
|
||||
popupMenu([...[{
|
||||
type: 'link',
|
||||
text: i18n.ts.profile,
|
||||
to: `/@${ $i.username }`,
|
||||
avatar: $i,
|
||||
}, null, ...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises, {
|
||||
type: 'parent',
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.addAccount,
|
||||
children: [{
|
||||
text: i18n.ts.existingAccount,
|
||||
action: () => { showSigninDialog(); },
|
||||
}, {
|
||||
text: i18n.ts.createAccount,
|
||||
action: () => { createAccount(); },
|
||||
}],
|
||||
}, {
|
||||
type: 'link',
|
||||
icon: 'ti ti-users',
|
||||
text: i18n.ts.manageAccounts,
|
||||
to: '/settings/accounts',
|
||||
}]], ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
} else {
|
||||
popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, {
|
||||
align: 'left',
|
||||
});
|
||||
}
|
||||
}
|
109
packages/frontend/src/components/MkAbuseReport.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<div class="bcekxzvu _gap _panel">
|
||||
<div class="target">
|
||||
<MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`">
|
||||
<MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/>
|
||||
<div class="names">
|
||||
<MkUserName class="name" :user="report.targetUser"/>
|
||||
<MkAcct class="acct" :user="report.targetUser" style="display: block;"/>
|
||||
</div>
|
||||
</MkA>
|
||||
<MkKeyValue class="_formBlock">
|
||||
<template #key>{{ i18n.ts.registeredDate }}</template>
|
||||
<template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template>
|
||||
</MkKeyValue>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<div>
|
||||
<Mfm :text="report.comment"/>
|
||||
</div>
|
||||
<hr/>
|
||||
<div>{{ i18n.ts.reporter }}: <MkAcct :user="report.reporter"/></div>
|
||||
<div v-if="report.assignee">
|
||||
{{ i18n.ts.moderator }}:
|
||||
<MkAcct :user="report.assignee"/>
|
||||
</div>
|
||||
<div><MkTime :time="report.createdAt"/></div>
|
||||
<div class="action">
|
||||
<MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved">
|
||||
{{ i18n.ts.forwardReport }}
|
||||
<template #caption>{{ i18n.ts.forwardReportIsAnonymous }}</template>
|
||||
</MkSwitch>
|
||||
<MkButton v-if="!report.resolved" primary @click="resolve">{{ i18n.ts.abuseMarkAsResolved }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkSwitch from '@/components/form/switch.vue';
|
||||
import MkKeyValue from '@/components/MkKeyValue.vue';
|
||||
import { acct, userPage } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
report: any;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'resolved', reportId: string): void;
|
||||
}>();
|
||||
|
||||
let forward = $ref(props.report.forwarded);
|
||||
|
||||
function resolve() {
|
||||
os.apiWithDialog('admin/resolve-abuse-user-report', {
|
||||
forward: forward,
|
||||
reportId: props.report.id,
|
||||
}).then(() => {
|
||||
emit('resolved', props.report.id);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bcekxzvu {
|
||||
display: flex;
|
||||
|
||||
> .target {
|
||||
width: 35%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
padding: 24px;
|
||||
border-right: solid 1px var(--divider);
|
||||
|
||||
> .info {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
--c: rgb(255 196 0 / 15%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
|
||||
> .avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
> .names {
|
||||
margin-left: 0.3em;
|
||||
padding: 0 8px;
|
||||
flex: 1;
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .detail {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
65
packages/frontend/src/components/MkAbuseReportWindow.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')">
|
||||
<template #header>
|
||||
<i class="ti ti-exclamation-circle" style="margin-right: 0.5em;"></i>
|
||||
<I18n :src="i18n.ts.reportAbuseOf" tag="span">
|
||||
<template #name>
|
||||
<b><MkAcct :user="user"/></b>
|
||||
</template>
|
||||
</I18n>
|
||||
</template>
|
||||
<div class="dpvffvvy _monolithic_">
|
||||
<div class="_section">
|
||||
<MkTextarea v-model="comment">
|
||||
<template #label>{{ i18n.ts.details }}</template>
|
||||
<template #caption>{{ i18n.ts.fillAbuseReportDescription }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
<div class="_section">
|
||||
<MkButton primary full :disabled="comment.length === 0" @click="send">{{ i18n.ts.send }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</XWindow>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XWindow from '@/components/MkWindow.vue';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
user: Misskey.entities.User;
|
||||
initialComment?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const uiWindow = ref<InstanceType<typeof XWindow>>();
|
||||
const comment = ref(props.initialComment || '');
|
||||
|
||||
function send() {
|
||||
os.apiWithDialog('users/report-abuse', {
|
||||
userId: props.user.id,
|
||||
comment: comment.value,
|
||||
}, undefined).then(res => {
|
||||
os.alert({
|
||||
type: 'success',
|
||||
text: i18n.ts.abuseReported,
|
||||
});
|
||||
uiWindow.value?.close();
|
||||
emit('closed');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dpvffvvy {
|
||||
--root-margin: 16px;
|
||||
}
|
||||
</style>
|
236
packages/frontend/src/components/MkActiveUsersHeatmap.vue
Normal file
|
@ -0,0 +1,236 @@
|
|||
<template>
|
||||
<div ref="rootEl">
|
||||
<MkLoading v-if="fetching"/>
|
||||
<div v-else>
|
||||
<canvas ref="chartEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import {
|
||||
Chart,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import * as os from '@/os';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { defaultStore } from '@/store';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { MatrixController, MatrixElement } from 'chartjs-chart-matrix';
|
||||
import { chartVLine } from '@/scripts/chart-vline';
|
||||
|
||||
Chart.register(
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
MatrixController, MatrixElement,
|
||||
);
|
||||
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
|
||||
const rootEl = $ref<HTMLDivElement>(null);
|
||||
const chartEl = $ref<HTMLCanvasElement>(null);
|
||||
const now = new Date();
|
||||
let chartInstance: Chart = null;
|
||||
let fetching = $ref(true);
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
|
||||
async function renderChart() {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const wide = rootEl.offsetWidth > 700;
|
||||
const narrow = rootEl.offsetWidth < 400;
|
||||
|
||||
const weeks = wide ? 50 : narrow ? 10 : 25;
|
||||
const chartLimit = 7 * weeks;
|
||||
|
||||
const getDate = (ago: number) => {
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const d = now.getDate();
|
||||
|
||||
return new Date(y, m, d - ago);
|
||||
};
|
||||
|
||||
const format = (arr) => {
|
||||
return arr.map((v, i) => {
|
||||
const dt = getDate(i);
|
||||
const iso = `${dt.getFullYear()}-${(dt.getMonth() + 1).toString().padStart(2, '0')}-${dt.getDate().toString().padStart(2, '0')}`;
|
||||
return {
|
||||
x: iso,
|
||||
y: dt.getDay(),
|
||||
d: iso,
|
||||
v,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' });
|
||||
|
||||
fetching = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
|
||||
// フォントカラー
|
||||
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
const color = '#3498db';
|
||||
|
||||
const max = Math.max(...raw.readWrite);
|
||||
|
||||
const marginEachCell = 4;
|
||||
|
||||
chartInstance = new Chart(chartEl, {
|
||||
type: 'matrix',
|
||||
data: {
|
||||
datasets: [{
|
||||
label: 'Read & Write',
|
||||
data: format(raw.readWrite),
|
||||
pointRadius: 0,
|
||||
borderWidth: 0,
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: 3,
|
||||
backgroundColor(c) {
|
||||
const value = c.dataset.data[c.dataIndex].v;
|
||||
const a = value / max;
|
||||
return alpha(color, a);
|
||||
},
|
||||
fill: true,
|
||||
width(c) {
|
||||
const a = c.chart.chartArea ?? {};
|
||||
// 20週間
|
||||
return (a.right - a.left) / weeks - marginEachCell;
|
||||
},
|
||||
height(c) {
|
||||
const a = c.chart.chartArea ?? {};
|
||||
// 7日
|
||||
return (a.bottom - a.top) / 7 - marginEachCell;
|
||||
},
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
aspectRatio: wide ? 6 : narrow ? 1.8 : 3.2,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 8,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
offset: true,
|
||||
position: 'bottom',
|
||||
time: {
|
||||
unit: 'week',
|
||||
round: 'week',
|
||||
isoWeekday: 0,
|
||||
displayFormats: {
|
||||
week: 'MMM dd',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
color: gridColor,
|
||||
borderColor: 'rgb(0, 0, 0, 0)',
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
offset: true,
|
||||
reverse: true,
|
||||
position: 'right',
|
||||
grid: {
|
||||
display: false,
|
||||
color: gridColor,
|
||||
borderColor: 'rgb(0, 0, 0, 0)',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
padding: 1,
|
||||
font: {
|
||||
size: 9,
|
||||
},
|
||||
callback: (value, index, values) => ['', 'Mon', '', 'Wed', '', 'Fri', ''][value],
|
||||
},
|
||||
},
|
||||
},
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
callbacks: {
|
||||
title(context) {
|
||||
const v = context[0].dataset.data[context[0].dataIndex];
|
||||
return v.d;
|
||||
},
|
||||
label(context) {
|
||||
const v = context.dataset.data[context.dataIndex];
|
||||
return ['Active: ' + v.v];
|
||||
},
|
||||
},
|
||||
//mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
renderChart();
|
||||
});
|
||||
</script>
|
225
packages/frontend/src/components/MkAnalogClock.vue
Normal file
|
@ -0,0 +1,225 @@
|
|||
<template>
|
||||
<svg class="mbcofsoe" viewBox="0 0 10 10" preserveAspectRatio="none">
|
||||
<template v-if="props.graduations === 'dots'">
|
||||
<circle
|
||||
v-for="(angle, i) in graduationsMajor"
|
||||
:cx="5 + (Math.sin(angle) * (5 - graduationsPadding))"
|
||||
:cy="5 - (Math.cos(angle) * (5 - graduationsPadding))"
|
||||
:r="0.125"
|
||||
:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : majorGraduationColor"
|
||||
:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="props.graduations === 'numbers'">
|
||||
<text
|
||||
v-for="(angle, i) in texts"
|
||||
:x="5 + (Math.sin(angle) * (5 - textsPadding))"
|
||||
:y="5 - (Math.cos(angle) * (5 - textsPadding))"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="middle"
|
||||
:font-size="(props.twentyfour ? h : h % 12) === i ? 1 : 0.7"
|
||||
:font-weight="(props.twentyfour ? h : h % 12) === i ? 'bold' : 'normal'"
|
||||
:fill="(props.twentyfour ? h : h % 12) === i ? nowColor : 'currentColor'"
|
||||
:opacity="!props.fadeGraduations || (props.twentyfour ? h : h % 12) === i ? 1 : Math.max(0, 1 - (angleDiff(hAngle, angle) / Math.PI) - numbersOpacityFactor)"
|
||||
>
|
||||
{{ i === 0 ? (props.twentyfour ? '24' : '12') : i }}
|
||||
</text>
|
||||
</template>
|
||||
|
||||
<!--
|
||||
<line
|
||||
:x1="5 - (Math.sin(sAngle) * (sHandLengthRatio * handsTailLength))"
|
||||
:y1="5 + (Math.cos(sAngle) * (sHandLengthRatio * handsTailLength))"
|
||||
:x2="5 + (Math.sin(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
|
||||
:y2="5 - (Math.cos(sAngle) * ((sHandLengthRatio * 5) - handsPadding))"
|
||||
:stroke="sHandColor"
|
||||
:stroke-width="thickness / 2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
-->
|
||||
|
||||
<line
|
||||
class="s"
|
||||
:class="{ animate: !disableSAnimate && sAnimation !== 'none', elastic: sAnimation === 'elastic', easeOut: sAnimation === 'easeOut' }"
|
||||
:x1="5 - (0 * (sHandLengthRatio * handsTailLength))"
|
||||
:y1="5 + (1 * (sHandLengthRatio * handsTailLength))"
|
||||
:x2="5 + (0 * ((sHandLengthRatio * 5) - handsPadding))"
|
||||
:y2="5 - (1 * ((sHandLengthRatio * 5) - handsPadding))"
|
||||
:stroke="sHandColor"
|
||||
:stroke-width="thickness / 2"
|
||||
:style="`transform: rotateZ(${sAngle}rad)`"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<line
|
||||
:x1="5 - (Math.sin(mAngle) * (mHandLengthRatio * handsTailLength))"
|
||||
:y1="5 + (Math.cos(mAngle) * (mHandLengthRatio * handsTailLength))"
|
||||
:x2="5 + (Math.sin(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
|
||||
:y2="5 - (Math.cos(mAngle) * ((mHandLengthRatio * 5) - handsPadding))"
|
||||
:stroke="mHandColor"
|
||||
:stroke-width="thickness"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<line
|
||||
:x1="5 - (Math.sin(hAngle) * (hHandLengthRatio * handsTailLength))"
|
||||
:y1="5 + (Math.cos(hAngle) * (hHandLengthRatio * handsTailLength))"
|
||||
:x2="5 + (Math.sin(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
|
||||
:y2="5 - (Math.cos(hAngle) * ((hHandLengthRatio * 5) - handsPadding))"
|
||||
:stroke="hHandColor"
|
||||
:stroke-width="thickness"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, shallowRef, nextTick } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { globalEvents } from '@/events.js';
|
||||
|
||||
// https://stackoverflow.com/questions/1878907/how-can-i-find-the-difference-between-two-angles
|
||||
const angleDiff = (a: number, b: number) => {
|
||||
const x = Math.abs(a - b);
|
||||
return Math.abs((x + Math.PI) % (Math.PI * 2) - Math.PI);
|
||||
};
|
||||
|
||||
const graduationsPadding = 0.5;
|
||||
const textsPadding = 0.6;
|
||||
const handsPadding = 1;
|
||||
const handsTailLength = 0.7;
|
||||
const hHandLengthRatio = 0.75;
|
||||
const mHandLengthRatio = 1;
|
||||
const sHandLengthRatio = 1;
|
||||
const numbersOpacityFactor = 0.35;
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
thickness?: number;
|
||||
offset?: number;
|
||||
twentyfour?: boolean;
|
||||
graduations?: 'none' | 'dots' | 'numbers';
|
||||
fadeGraduations?: boolean;
|
||||
sAnimation?: 'none' | 'elastic' | 'easeOut';
|
||||
}>(), {
|
||||
numbers: false,
|
||||
thickness: 0.1,
|
||||
offset: 0 - new Date().getTimezoneOffset(),
|
||||
twentyfour: false,
|
||||
graduations: 'dots',
|
||||
fadeGraduations: true,
|
||||
sAnimation: 'elastic',
|
||||
});
|
||||
|
||||
const graduationsMajor = computed(() => {
|
||||
const angles: number[] = [];
|
||||
const times = props.twentyfour ? 24 : 12;
|
||||
for (let i = 0; i < times; i++) {
|
||||
const angle = Math.PI * i / (times / 2);
|
||||
angles.push(angle);
|
||||
}
|
||||
return angles;
|
||||
});
|
||||
const texts = computed(() => {
|
||||
const angles: number[] = [];
|
||||
const times = props.twentyfour ? 24 : 12;
|
||||
for (let i = 0; i < times; i++) {
|
||||
const angle = Math.PI * i / (times / 2);
|
||||
angles.push(angle);
|
||||
}
|
||||
return angles;
|
||||
});
|
||||
|
||||
let enabled = true;
|
||||
let majorGraduationColor = $ref<string>();
|
||||
//let minorGraduationColor = $ref<string>();
|
||||
let sHandColor = $ref<string>();
|
||||
let mHandColor = $ref<string>();
|
||||
let hHandColor = $ref<string>();
|
||||
let nowColor = $ref<string>();
|
||||
let h = $ref<number>(0);
|
||||
let m = $ref<number>(0);
|
||||
let s = $ref<number>(0);
|
||||
let hAngle = $ref<number>(0);
|
||||
let mAngle = $ref<number>(0);
|
||||
let sAngle = $ref<number>(0);
|
||||
let disableSAnimate = $ref(false);
|
||||
let sOneRound = false;
|
||||
|
||||
function tick() {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
|
||||
s = now.getSeconds();
|
||||
m = now.getMinutes();
|
||||
h = now.getHours();
|
||||
hAngle = Math.PI * (h % (props.twentyfour ? 24 : 12) + (m + s / 60) / 60) / (props.twentyfour ? 12 : 6);
|
||||
mAngle = Math.PI * (m + s / 60) / 30;
|
||||
if (sOneRound) { // 秒針が一周した際のアニメーションをよしなに処理する(これが無いと秒が59->0になったときに期待したアニメーションにならない)
|
||||
sAngle = Math.PI * 60 / 30;
|
||||
window.setTimeout(() => {
|
||||
disableSAnimate = true;
|
||||
window.setTimeout(() => {
|
||||
sAngle = 0;
|
||||
window.setTimeout(() => {
|
||||
disableSAnimate = false;
|
||||
}, 100);
|
||||
}, 100);
|
||||
}, 700);
|
||||
} else {
|
||||
sAngle = Math.PI * s / 30;
|
||||
}
|
||||
sOneRound = s === 59;
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
function calcColors() {
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
const dark = tinycolor(computedStyle.getPropertyValue('--bg')).isDark();
|
||||
const accent = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
|
||||
majorGraduationColor = dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
|
||||
//minorGraduationColor = dark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
sHandColor = dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
|
||||
mHandColor = tinycolor(computedStyle.getPropertyValue('--fg')).toHexString();
|
||||
hHandColor = accent;
|
||||
nowColor = accent;
|
||||
}
|
||||
|
||||
calcColors();
|
||||
|
||||
onMounted(() => {
|
||||
const update = () => {
|
||||
if (enabled) {
|
||||
tick();
|
||||
window.setTimeout(update, 1000);
|
||||
}
|
||||
};
|
||||
update();
|
||||
|
||||
globalEvents.on('themeChanged', calcColors);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
enabled = false;
|
||||
|
||||
globalEvents.off('themeChanged', calcColors);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mbcofsoe {
|
||||
display: block;
|
||||
|
||||
> .s {
|
||||
will-change: transform;
|
||||
transform-origin: 50% 50%;
|
||||
|
||||
&.animate.elastic {
|
||||
transition: transform .2s cubic-bezier(.4,2.08,.55,.44);
|
||||
}
|
||||
|
||||
&.animate.easeOut {
|
||||
transition: transform .7s cubic-bezier(0,.7,.3,1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
476
packages/frontend/src/components/MkAutocomplete.vue
Normal file
|
@ -0,0 +1,476 @@
|
|||
<template>
|
||||
<div ref="rootEl" class="swhvrteh _popup _shadow" :style="{ zIndex }" @contextmenu.prevent="() => {}">
|
||||
<ol v-if="type === 'user'" ref="suggests" class="users">
|
||||
<li v-for="user in users" tabindex="-1" class="user" @click="complete(type, user)" @keydown="onKeydown">
|
||||
<img class="avatar" :src="user.avatarUrl"/>
|
||||
<span class="name">
|
||||
<MkUserName :key="user.id" :user="user"/>
|
||||
</span>
|
||||
<span class="username">@{{ acct(user) }}</span>
|
||||
</li>
|
||||
<li tabindex="-1" class="choose" @click="chooseUser()" @keydown="onKeydown">{{ i18n.ts.selectUser }}</li>
|
||||
</ol>
|
||||
<ol v-else-if="hashtags.length > 0" ref="suggests" class="hashtags">
|
||||
<li v-for="hashtag in hashtags" tabindex="-1" @click="complete(type, hashtag)" @keydown="onKeydown">
|
||||
<span class="name">{{ hashtag }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="emojis.length > 0" ref="suggests" class="emojis">
|
||||
<li v-for="emoji in emojis" tabindex="-1" @click="complete(type, emoji.emoji)" @keydown="onKeydown">
|
||||
<span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span>
|
||||
<span v-else-if="defaultStore.state.emojiStyle != 'native'" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span>
|
||||
<span v-else class="emoji">{{ emoji.emoji }}</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span>
|
||||
<span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span>
|
||||
</li>
|
||||
</ol>
|
||||
<ol v-else-if="mfmTags.length > 0" ref="suggests" class="mfmTags">
|
||||
<li v-for="tag in mfmTags" tabindex="-1" @click="complete(type, tag)" @keydown="onKeydown">
|
||||
<span class="tag">{{ tag }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import { char2twemojiFilePath, char2fluentEmojiFilePath } from '@/scripts/emoji-base';
|
||||
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
|
||||
import { acct } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
import { MFM_TAGS } from '@/scripts/mfm-tags';
|
||||
import { defaultStore } from '@/store';
|
||||
import { emojilist } from '@/scripts/emojilist';
|
||||
import { instance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type EmojiDef = {
|
||||
emoji: string;
|
||||
name: string;
|
||||
aliasOf?: string;
|
||||
url?: string;
|
||||
isCustomEmoji?: boolean;
|
||||
};
|
||||
|
||||
const lib = emojilist.filter(x => x.category !== 'flags');
|
||||
|
||||
const emjdb: EmojiDef[] = lib.map(x => ({
|
||||
emoji: x.char,
|
||||
name: x.name,
|
||||
url: char2path(x.char),
|
||||
}));
|
||||
|
||||
const char2path = defaultStore.state.emojiStyle === 'twemoji' ? char2twemojiFilePath : char2fluentEmojiFilePath;
|
||||
|
||||
for (const x of lib) {
|
||||
if (x.keywords) {
|
||||
for (const k of x.keywords) {
|
||||
emjdb.push({
|
||||
emoji: x.char,
|
||||
name: k,
|
||||
aliasOf: x.name,
|
||||
url: char2path(x.char),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emjdb.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
//#region Construct Emoji DB
|
||||
const customEmojis = instance.emojis;
|
||||
const emojiDefinitions: EmojiDef[] = [];
|
||||
|
||||
for (const x of customEmojis) {
|
||||
emojiDefinitions.push({
|
||||
name: x.name,
|
||||
emoji: `:${x.name}:`,
|
||||
url: x.url,
|
||||
isCustomEmoji: true,
|
||||
});
|
||||
|
||||
if (x.aliases) {
|
||||
for (const alias of x.aliases) {
|
||||
emojiDefinitions.push({
|
||||
name: alias,
|
||||
aliasOf: x.name,
|
||||
emoji: `:${x.name}:`,
|
||||
url: x.url,
|
||||
isCustomEmoji: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emojiDefinitions.sort((a, b) => a.name.length - b.name.length);
|
||||
|
||||
const emojiDb = markRaw(emojiDefinitions.concat(emjdb));
|
||||
//#endregion
|
||||
|
||||
export default {
|
||||
emojiDb,
|
||||
emojiDefinitions,
|
||||
emojilist,
|
||||
customEmojis,
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const props = defineProps<{
|
||||
type: string;
|
||||
q: string | null;
|
||||
textarea: HTMLTextAreaElement;
|
||||
close: () => void;
|
||||
x: number;
|
||||
y: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'done', value: { type: string; value: any }): void;
|
||||
(event: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const suggests = ref<Element>();
|
||||
const rootEl = ref<HTMLDivElement>();
|
||||
|
||||
const fetching = ref(true);
|
||||
const users = ref<any[]>([]);
|
||||
const hashtags = ref<any[]>([]);
|
||||
const emojis = ref<(EmojiDef)[]>([]);
|
||||
const items = ref<Element[] | HTMLCollection>([]);
|
||||
const mfmTags = ref<string[]>([]);
|
||||
const select = ref(-1);
|
||||
const zIndex = os.claimZIndex('high');
|
||||
|
||||
function complete(type: string, value: any) {
|
||||
emit('done', { type, value });
|
||||
emit('closed');
|
||||
if (type === 'emoji') {
|
||||
let recents = defaultStore.state.recentlyUsedEmojis;
|
||||
recents = recents.filter((emoji: any) => emoji !== value);
|
||||
recents.unshift(value);
|
||||
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
|
||||
}
|
||||
}
|
||||
|
||||
function setPosition() {
|
||||
if (!rootEl.value) return;
|
||||
if (props.x + rootEl.value.offsetWidth > window.innerWidth) {
|
||||
rootEl.value.style.left = (window.innerWidth - rootEl.value.offsetWidth) + 'px';
|
||||
} else {
|
||||
rootEl.value.style.left = `${props.x}px`;
|
||||
}
|
||||
if (props.y + rootEl.value.offsetHeight > window.innerHeight) {
|
||||
rootEl.value.style.top = (props.y - rootEl.value.offsetHeight) + 'px';
|
||||
rootEl.value.style.marginTop = '0';
|
||||
} else {
|
||||
rootEl.value.style.top = props.y + 'px';
|
||||
rootEl.value.style.marginTop = 'calc(1em + 8px)';
|
||||
}
|
||||
}
|
||||
|
||||
function exec() {
|
||||
select.value = -1;
|
||||
if (suggests.value) {
|
||||
for (const el of Array.from(items.value)) {
|
||||
el.removeAttribute('data-selected');
|
||||
}
|
||||
}
|
||||
if (props.type === 'user') {
|
||||
if (!props.q) {
|
||||
users.value = [];
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `autocomplete:user:${props.q}`;
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
|
||||
if (cache) {
|
||||
users.value = JSON.parse(cache);
|
||||
fetching.value = false;
|
||||
} else {
|
||||
os.api('users/search-by-username-and-host', {
|
||||
username: props.q,
|
||||
limit: 10,
|
||||
detail: false,
|
||||
}).then(searchedUsers => {
|
||||
users.value = searchedUsers as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedUsers));
|
||||
});
|
||||
}
|
||||
} else if (props.type === 'hashtag') {
|
||||
if (!props.q || props.q === '') {
|
||||
hashtags.value = JSON.parse(localStorage.getItem('hashtags') || '[]');
|
||||
fetching.value = false;
|
||||
} else {
|
||||
const cacheKey = `autocomplete:hashtag:${props.q}`;
|
||||
const cache = sessionStorage.getItem(cacheKey);
|
||||
if (cache) {
|
||||
const hashtags = JSON.parse(cache);
|
||||
hashtags.value = hashtags;
|
||||
fetching.value = false;
|
||||
} else {
|
||||
os.api('hashtags/search', {
|
||||
query: props.q,
|
||||
limit: 30,
|
||||
}).then(searchedHashtags => {
|
||||
hashtags.value = searchedHashtags as any[];
|
||||
fetching.value = false;
|
||||
// キャッシュ
|
||||
sessionStorage.setItem(cacheKey, JSON.stringify(searchedHashtags));
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (props.type === 'emoji') {
|
||||
if (!props.q || props.q === '') {
|
||||
// 最近使った絵文字をサジェスト
|
||||
emojis.value = defaultStore.state.recentlyUsedEmojis.map(emoji => emojiDb.find(dbEmoji => dbEmoji.emoji === emoji)).filter(x => x) as EmojiDef[];
|
||||
return;
|
||||
}
|
||||
|
||||
const matched: EmojiDef[] = [];
|
||||
const max = 30;
|
||||
|
||||
emojiDb.some(x => {
|
||||
if (x.name.startsWith(props.q ?? '') && !x.aliasOf && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
|
||||
return matched.length === max;
|
||||
});
|
||||
|
||||
if (matched.length < max) {
|
||||
emojiDb.some(x => {
|
||||
if (x.name.startsWith(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
|
||||
return matched.length === max;
|
||||
});
|
||||
}
|
||||
|
||||
if (matched.length < max) {
|
||||
emojiDb.some(x => {
|
||||
if (x.name.includes(props.q ?? '') && !matched.some(y => y.emoji === x.emoji)) matched.push(x);
|
||||
return matched.length === max;
|
||||
});
|
||||
}
|
||||
|
||||
emojis.value = matched;
|
||||
} else if (props.type === 'mfmTag') {
|
||||
if (!props.q || props.q === '') {
|
||||
mfmTags.value = MFM_TAGS;
|
||||
return;
|
||||
}
|
||||
|
||||
mfmTags.value = MFM_TAGS.filter(tag => tag.startsWith(props.q ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
function onMousedown(event: Event) {
|
||||
if (!contains(rootEl.value, event.target) && (rootEl.value !== event.target)) props.close();
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
const cancel = () => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
if (select.value !== -1) {
|
||||
cancel();
|
||||
(items.value[select.value] as any).click();
|
||||
} else {
|
||||
props.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
cancel();
|
||||
props.close();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
if (select.value !== -1) {
|
||||
cancel();
|
||||
selectPrev();
|
||||
} else {
|
||||
props.close();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Tab':
|
||||
case 'ArrowDown':
|
||||
cancel();
|
||||
selectNext();
|
||||
break;
|
||||
|
||||
default:
|
||||
event.stopPropagation();
|
||||
props.textarea.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function selectNext() {
|
||||
if (++select.value >= items.value.length) select.value = 0;
|
||||
if (items.value.length === 0) select.value = -1;
|
||||
applySelect();
|
||||
}
|
||||
|
||||
function selectPrev() {
|
||||
if (--select.value < 0) select.value = items.value.length - 1;
|
||||
applySelect();
|
||||
}
|
||||
|
||||
function applySelect() {
|
||||
for (const el of Array.from(items.value)) {
|
||||
el.removeAttribute('data-selected');
|
||||
}
|
||||
|
||||
if (select.value !== -1) {
|
||||
items.value[select.value].setAttribute('data-selected', 'true');
|
||||
(items.value[select.value] as any).focus();
|
||||
}
|
||||
}
|
||||
|
||||
function chooseUser() {
|
||||
props.close();
|
||||
os.selectUser().then(user => {
|
||||
complete('user', user);
|
||||
props.textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
onUpdated(() => {
|
||||
setPosition();
|
||||
items.value = suggests.value?.children ?? [];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
setPosition();
|
||||
|
||||
props.textarea.addEventListener('keydown', onKeydown);
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
exec();
|
||||
|
||||
watch(() => props.q, () => {
|
||||
nextTick(() => {
|
||||
exec();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.textarea.removeEventListener('keydown', onKeydown);
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.swhvrteh {
|
||||
position: fixed;
|
||||
max-width: 100%;
|
||||
margin-top: calc(1em + 8px);
|
||||
overflow: hidden;
|
||||
transition: top 0.1s ease, left 0.1s ease;
|
||||
|
||||
> ol {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
max-height: 190px;
|
||||
max-width: 500px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 0.9em;
|
||||
cursor: default;
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
* {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--X3);
|
||||
}
|
||||
|
||||
&[data-selected='true'] {
|
||||
background: var(--accent);
|
||||
|
||||
&, * {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
|
||||
&, * {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .users > li {
|
||||
|
||||
.avatar {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
max-width: 28px;
|
||||
max-height: 28px;
|
||||
margin: 0 8px 0 0;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0 8px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis > li {
|
||||
|
||||
.emoji {
|
||||
display: inline-block;
|
||||
margin: 0 4px 0 0;
|
||||
width: 24px;
|
||||
|
||||
> img {
|
||||
width: 24px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
}
|
||||
|
||||
.alias {
|
||||
margin: 0 0 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .mfmTags > li {
|
||||
|
||||
.name {
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
24
packages/frontend/src/components/MkAvatars.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
|
||||
<MkAvatar :user="user" style="width:32px;height:32px;" :show-indicator="true"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
userIds: string[];
|
||||
}>();
|
||||
|
||||
const users = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
users.value = await os.api('users/show', {
|
||||
userIds: props.userIds,
|
||||
});
|
||||
});
|
||||
</script>
|
227
packages/frontend/src/components/MkButton.vue
Normal file
|
@ -0,0 +1,227 @@
|
|||
<template>
|
||||
<button
|
||||
v-if="!link"
|
||||
ref="el" class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full }"
|
||||
:type="type"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</button>
|
||||
<MkA
|
||||
v-else class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full }"
|
||||
:to="to"
|
||||
@mousedown="onMousedown"
|
||||
>
|
||||
<div ref="ripples" class="ripples"></div>
|
||||
<div class="content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
primary?: boolean;
|
||||
gradate?: boolean;
|
||||
rounded?: boolean;
|
||||
inline?: boolean;
|
||||
link?: boolean;
|
||||
to?: string;
|
||||
autofocus?: boolean;
|
||||
wait?: boolean;
|
||||
danger?: boolean;
|
||||
full?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'click', payload: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
let el = $ref<HTMLElement | null>(null);
|
||||
let ripples = $ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autofocus) {
|
||||
nextTick(() => {
|
||||
el!.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function distance(p, q): number {
|
||||
return Math.hypot(p.x - q.x, p.y - q.y);
|
||||
}
|
||||
|
||||
function calcCircleScale(boxW, boxH, circleCenterX, circleCenterY): number {
|
||||
const origin = { x: circleCenterX, y: circleCenterY };
|
||||
const dist1 = distance({ x: 0, y: 0 }, origin);
|
||||
const dist2 = distance({ x: boxW, y: 0 }, origin);
|
||||
const dist3 = distance({ x: 0, y: boxH }, origin);
|
||||
const dist4 = distance({ x: boxW, y: boxH }, origin);
|
||||
return Math.max(dist1, dist2, dist3, dist4) * 2;
|
||||
}
|
||||
|
||||
function onMousedown(evt: MouseEvent): void {
|
||||
const target = evt.target! as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
|
||||
const ripple = document.createElement('div');
|
||||
ripple.style.top = (evt.clientY - rect.top - 1).toString() + 'px';
|
||||
ripple.style.left = (evt.clientX - rect.left - 1).toString() + 'px';
|
||||
|
||||
ripples!.appendChild(ripple);
|
||||
|
||||
const circleCenterX = evt.clientX - rect.left;
|
||||
const circleCenterY = evt.clientY - rect.top;
|
||||
|
||||
const scale = calcCircleScale(target.clientWidth, target.clientHeight, circleCenterX, circleCenterY);
|
||||
|
||||
window.setTimeout(() => {
|
||||
ripple.style.transform = 'scale(' + (scale / 2) + ')';
|
||||
}, 1);
|
||||
window.setTimeout(() => {
|
||||
ripple.style.transition = 'all 1s ease';
|
||||
ripple.style.opacity = '0';
|
||||
}, 1000);
|
||||
window.setTimeout(() => {
|
||||
if (ripples) ripples.removeChild(ripple);
|
||||
}, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bghgjjyj {
|
||||
position: relative;
|
||||
z-index: 1; // 他コンポーネントのbox-shadowに隠されないようにするため
|
||||
display: block;
|
||||
min-width: 100px;
|
||||
width: max-content;
|
||||
padding: 7px 14px;
|
||||
text-align: center;
|
||||
font-weight: normal;
|
||||
font-size: 95%;
|
||||
box-shadow: none;
|
||||
text-decoration: none;
|
||||
background: var(--buttonBg);
|
||||
border-radius: 5px;
|
||||
overflow: clip;
|
||||
box-sizing: border-box;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--buttonHoverBg);
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&.rounded {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
background: var(--accent);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: var(--X8);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: var(--X8);
|
||||
}
|
||||
}
|
||||
|
||||
&.gradate {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
background: linear-gradient(90deg, var(--buttonGradateA), var(--buttonGradateB));
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: linear-gradient(90deg, var(--X8), var(--X8));
|
||||
}
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: #ff2a2a;
|
||||
|
||||
&.primary {
|
||||
color: #fff;
|
||||
background: #ff2a2a;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: #ff4242;
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: #d42e2e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.inline {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
> .ripples {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
::v-deep(div) {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 100%;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
transition: all 0.5s cubic-bezier(0,.5,0,1);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary > .ripples ::v-deep(div) {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
> .content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
118
packages/frontend/src/components/MkCaptcha.vue
Normal file
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<div>
|
||||
<span v-if="!available">{{ i18n.ts.waiting }}<MkEllipsis/></span>
|
||||
<div ref="captchaEl"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type Captcha = {
|
||||
render(container: string | Node, options: {
|
||||
readonly [_ in 'sitekey' | 'theme' | 'type' | 'size' | 'tabindex' | 'callback' | 'expired' | 'expired-callback' | 'error-callback' | 'endpoint']?: unknown;
|
||||
}): string;
|
||||
remove(id: string): void;
|
||||
execute(id: string): void;
|
||||
reset(id?: string): void;
|
||||
getResponse(id: string): string;
|
||||
};
|
||||
|
||||
type CaptchaProvider = 'hcaptcha' | 'recaptcha' | 'turnstile';
|
||||
|
||||
type CaptchaContainer = {
|
||||
readonly [_ in CaptchaProvider]?: Captcha;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window extends CaptchaContainer { }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
provider: CaptchaProvider;
|
||||
sitekey: string;
|
||||
modelValue?: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', v: string | null): void;
|
||||
}>();
|
||||
|
||||
const available = ref(false);
|
||||
|
||||
const captchaEl = ref<HTMLDivElement | undefined>();
|
||||
|
||||
const variable = computed(() => {
|
||||
switch (props.provider) {
|
||||
case 'hcaptcha': return 'hcaptcha';
|
||||
case 'recaptcha': return 'grecaptcha';
|
||||
case 'turnstile': return 'turnstile';
|
||||
}
|
||||
});
|
||||
|
||||
const loaded = !!window[variable.value];
|
||||
|
||||
const src = computed(() => {
|
||||
switch (props.provider) {
|
||||
case 'hcaptcha': return 'https://js.hcaptcha.com/1/api.js?render=explicit&recaptchacompat=off';
|
||||
case 'recaptcha': return 'https://www.recaptcha.net/recaptcha/api.js?render=explicit';
|
||||
case 'turnstile': return 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
|
||||
}
|
||||
});
|
||||
|
||||
const scriptId = computed(() => `script-${props.provider}`)
|
||||
|
||||
const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha);
|
||||
|
||||
if (loaded) {
|
||||
available.value = true;
|
||||
} else {
|
||||
(document.getElementById(scriptId.value) || document.head.appendChild(Object.assign(document.createElement('script'), {
|
||||
async: true,
|
||||
id: scriptId.value,
|
||||
src: src.value,
|
||||
})))
|
||||
.addEventListener('load', () => available.value = true);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (captcha.value.reset) captcha.value.reset();
|
||||
}
|
||||
|
||||
function requestRender() {
|
||||
if (captcha.value.render && captchaEl.value instanceof Element) {
|
||||
captcha.value.render(captchaEl.value, {
|
||||
sitekey: props.sitekey,
|
||||
theme: defaultStore.state.darkMode ? 'dark' : 'light',
|
||||
callback: callback,
|
||||
'expired-callback': callback,
|
||||
'error-callback': callback,
|
||||
});
|
||||
} else {
|
||||
window.setTimeout(requestRender, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function callback(response?: string) {
|
||||
emit('update:modelValue', typeof response === 'string' ? response : null);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (available.value) {
|
||||
requestRender();
|
||||
} else {
|
||||
watch(available, requestRender);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
reset,
|
||||
});
|
||||
|
||||
</script>
|
129
packages/frontend/src/components/MkChannelFollowButton.vue
Normal file
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<button
|
||||
class="hdcaacmi _button"
|
||||
:class="{ wait, active: isFollowing, full }"
|
||||
:disabled="wait"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<template v-if="isFollowing">
|
||||
<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true"/>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
channel: Record<string, any>;
|
||||
full?: boolean;
|
||||
}>(), {
|
||||
full: false,
|
||||
});
|
||||
|
||||
const isFollowing = ref<boolean>(props.channel.isFollowing);
|
||||
const wait = ref(false);
|
||||
|
||||
async function onClick() {
|
||||
wait.value = true;
|
||||
|
||||
try {
|
||||
if (isFollowing.value) {
|
||||
await os.api('channels/unfollow', {
|
||||
channelId: props.channel.id,
|
||||
});
|
||||
isFollowing.value = false;
|
||||
} else {
|
||||
await os.api('channels/follow', {
|
||||
channelId: props.channel.id,
|
||||
});
|
||||
isFollowing.value = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
wait.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hdcaacmi {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
height: 31px;
|
||||
font-size: 16px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
|
||||
&.full {
|
||||
padding: 0 8px 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
//background: mix($primary, #fff, 20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
//background: mix($primary, #fff, 40);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
154
packages/frontend/src/components/MkChannelPreview.vue
Normal file
|
@ -0,0 +1,154 @@
|
|||
<template>
|
||||
<MkA :to="`/channels/${channel.id}`" class="eftoefju _panel" tabindex="-1">
|
||||
<div class="banner" :style="bannerStyle">
|
||||
<div class="fade"></div>
|
||||
<div class="name"><i class="ti ti-device-tv"></i> {{ channel.name }}</div>
|
||||
<div class="status">
|
||||
<div>
|
||||
<i class="ti ti-users ti-fw"></i>
|
||||
<I18n :src="i18n.ts._channel.usersCount" tag="span" style="margin-left: 4px;">
|
||||
<template #n>
|
||||
<b>{{ channel.usersCount }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
<div>
|
||||
<i class="ti ti-pencil ti-fw"></i>
|
||||
<I18n :src="i18n.ts._channel.notesCount" tag="span" style="margin-left: 4px;">
|
||||
<template #n>
|
||||
<b>{{ channel.notesCount }}</b>
|
||||
</template>
|
||||
</I18n>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<article v-if="channel.description">
|
||||
<p :title="channel.description">{{ channel.description.length > 85 ? channel.description.slice(0, 85) + '…' : channel.description }}</p>
|
||||
</article>
|
||||
<footer>
|
||||
<span v-if="channel.lastNotedAt">
|
||||
{{ i18n.ts.updatedAt }}: <MkTime :time="channel.lastNotedAt"/>
|
||||
</span>
|
||||
</footer>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
channel: Record<string, any>;
|
||||
}>();
|
||||
|
||||
const bannerStyle = computed(() => {
|
||||
if (props.channel.bannerUrl) {
|
||||
return { backgroundImage: `url(${props.channel.bannerUrl})` };
|
||||
} else {
|
||||
return { backgroundColor: '#4c5e6d' };
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.eftoefju {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> .banner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
|
||||
> .fade {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
}
|
||||
|
||||
> .name {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
> .status {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 12px;
|
||||
font-size: 80%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
padding: 12px 16px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
|
||||
> span {
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
font-size: 0.9em;
|
||||
|
||||
> .banner {
|
||||
height: 80px;
|
||||
|
||||
> .status {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 0.8em;
|
||||
|
||||
> .banner {
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
859
packages/frontend/src/components/MkChart.vue
Normal file
|
@ -0,0 +1,859 @@
|
|||
<template>
|
||||
<div class="cbbedffa">
|
||||
<canvas ref="chartEl"></canvas>
|
||||
<div v-if="fetching" class="fetching">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
/* eslint-disable id-denylist --
|
||||
Chart.js has a `data` attribute in most chart definitions, which triggers the
|
||||
id-denylist violation when setting it. This is causing about 60+ lint issues.
|
||||
As this is part of Chart.js's API it makes sense to disable the check here.
|
||||
*/
|
||||
import { onMounted, ref, watch, PropType, onUnmounted } from 'vue';
|
||||
import {
|
||||
Chart,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import 'chartjs-adapter-date-fns';
|
||||
import { enUS } from 'date-fns/locale';
|
||||
import zoomPlugin from 'chartjs-plugin-zoom';
|
||||
import gradient from 'chartjs-plugin-gradient';
|
||||
import * as os from '@/os';
|
||||
import { defaultStore } from '@/store';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import { chartVLine } from '@/scripts/chart-vline';
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
args: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 90,
|
||||
},
|
||||
span: {
|
||||
type: String as PropType<'hour' | 'day'>,
|
||||
required: true,
|
||||
},
|
||||
detailed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
stacked: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
bar: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
aspectRatio: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
Chart.register(
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
zoomPlugin,
|
||||
gradient,
|
||||
);
|
||||
|
||||
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
|
||||
const negate = arr => arr.map(x => -x);
|
||||
const alpha = (hex, a) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
||||
};
|
||||
|
||||
const colors = {
|
||||
blue: '#008FFB',
|
||||
green: '#00E396',
|
||||
yellow: '#FEB019',
|
||||
red: '#FF4560',
|
||||
purple: '#e300db',
|
||||
orange: '#fe6919',
|
||||
lime: '#bde800',
|
||||
cyan: '#00e0e0',
|
||||
};
|
||||
const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
|
||||
const getColor = (i) => {
|
||||
return colorSets[i % colorSets.length];
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
let chartInstance: Chart = null;
|
||||
let chartData: {
|
||||
series: {
|
||||
name: string;
|
||||
type: 'line' | 'area';
|
||||
color?: string;
|
||||
dashed?: boolean;
|
||||
hidden?: boolean;
|
||||
data: {
|
||||
x: number;
|
||||
y: number;
|
||||
}[];
|
||||
}[];
|
||||
} = null;
|
||||
|
||||
const chartEl = ref<HTMLCanvasElement>(null);
|
||||
const fetching = ref(true);
|
||||
|
||||
const getDate = (ago: number) => {
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
const d = now.getDate();
|
||||
const h = now.getHours();
|
||||
|
||||
return props.span === 'day' ? new Date(y, m, d - ago) : new Date(y, m, d, h - ago);
|
||||
};
|
||||
|
||||
const format = (arr) => {
|
||||
return arr.map((v, i) => ({
|
||||
x: getDate(i).getTime(),
|
||||
y: v,
|
||||
}));
|
||||
};
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip();
|
||||
|
||||
const render = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
|
||||
const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
|
||||
|
||||
// フォントカラー
|
||||
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
|
||||
|
||||
const maxes = chartData.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
|
||||
|
||||
chartInstance = new Chart(chartEl.value, {
|
||||
type: props.bar ? 'bar' : 'line',
|
||||
data: {
|
||||
labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
|
||||
datasets: chartData.series.map((x, i) => ({
|
||||
parsing: false,
|
||||
label: x.name,
|
||||
data: x.data.slice().reverse(),
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: props.bar ? 0 : 2,
|
||||
borderColor: x.color ? x.color : getColor(i),
|
||||
borderDash: x.dashed ? [5, 5] : [],
|
||||
borderJoinStyle: 'round',
|
||||
borderRadius: props.bar ? 3 : undefined,
|
||||
backgroundColor: props.bar ? (x.color ? x.color : getColor(i)) : alpha(x.color ? x.color : getColor(i), 0.1),
|
||||
gradient: props.bar ? undefined : {
|
||||
backgroundColor: {
|
||||
axis: 'y',
|
||||
colors: {
|
||||
0: alpha(x.color ? x.color : getColor(i), 0),
|
||||
[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.35),
|
||||
},
|
||||
},
|
||||
},
|
||||
barPercentage: 0.9,
|
||||
categoryPercentage: 0.9,
|
||||
fill: x.type === 'area',
|
||||
clip: 8,
|
||||
hidden: !!x.hidden,
|
||||
})),
|
||||
},
|
||||
options: {
|
||||
aspectRatio: props.aspectRatio || 2.5,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 0,
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
stacked: props.stacked,
|
||||
offset: false,
|
||||
time: {
|
||||
stepSize: 1,
|
||||
unit: props.span === 'day' ? 'month' : 'day',
|
||||
},
|
||||
grid: {
|
||||
color: gridColor,
|
||||
borderColor: 'rgb(0, 0, 0, 0)',
|
||||
},
|
||||
ticks: {
|
||||
display: props.detailed,
|
||||
maxRotation: 0,
|
||||
autoSkipPadding: 16,
|
||||
},
|
||||
adapters: {
|
||||
date: {
|
||||
locale: enUS,
|
||||
},
|
||||
},
|
||||
min: getDate(props.limit).getTime(),
|
||||
},
|
||||
y: {
|
||||
position: 'left',
|
||||
stacked: props.stacked,
|
||||
suggestedMax: 50,
|
||||
grid: {
|
||||
color: gridColor,
|
||||
borderColor: 'rgb(0, 0, 0, 0)',
|
||||
},
|
||||
ticks: {
|
||||
display: props.detailed,
|
||||
//mirror: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index',
|
||||
},
|
||||
elements: {
|
||||
point: {
|
||||
hoverRadius: 5,
|
||||
hoverBorderWidth: 2,
|
||||
},
|
||||
},
|
||||
animation: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: props.detailed,
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 16,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: externalTooltipHandler,
|
||||
},
|
||||
zoom: props.detailed ? {
|
||||
pan: {
|
||||
enabled: true,
|
||||
},
|
||||
zoom: {
|
||||
wheel: {
|
||||
enabled: true,
|
||||
},
|
||||
pinch: {
|
||||
enabled: true,
|
||||
},
|
||||
drag: {
|
||||
enabled: false,
|
||||
},
|
||||
mode: 'x',
|
||||
},
|
||||
limits: {
|
||||
x: {
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
},
|
||||
y: {
|
||||
min: 'original',
|
||||
max: 'original',
|
||||
},
|
||||
},
|
||||
} : undefined,
|
||||
gradient,
|
||||
},
|
||||
},
|
||||
plugins: [chartVLine(vLineColor)],
|
||||
});
|
||||
};
|
||||
|
||||
const exportData = () => {
|
||||
// TODO
|
||||
};
|
||||
|
||||
const fetchFederationChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Received',
|
||||
type: 'area',
|
||||
data: format(raw.inboxInstances),
|
||||
color: colors.blue,
|
||||
}, {
|
||||
name: 'Delivered',
|
||||
type: 'area',
|
||||
data: format(raw.deliveredInstances),
|
||||
color: colors.green,
|
||||
}, {
|
||||
name: 'Stalled',
|
||||
type: 'area',
|
||||
data: format(raw.stalled),
|
||||
color: colors.red,
|
||||
}, {
|
||||
name: 'Pub Active',
|
||||
type: 'line',
|
||||
data: format(raw.pubActive),
|
||||
color: colors.purple,
|
||||
}, {
|
||||
name: 'Sub Active',
|
||||
type: 'line',
|
||||
data: format(raw.subActive),
|
||||
color: colors.orange,
|
||||
}, {
|
||||
name: 'Pub & Sub',
|
||||
type: 'line',
|
||||
data: format(raw.pubsub),
|
||||
dashed: true,
|
||||
color: colors.cyan,
|
||||
}, {
|
||||
name: 'Pub',
|
||||
type: 'line',
|
||||
data: format(raw.pub),
|
||||
dashed: true,
|
||||
color: colors.purple,
|
||||
}, {
|
||||
name: 'Sub',
|
||||
type: 'line',
|
||||
data: format(raw.sub),
|
||||
dashed: true,
|
||||
color: colors.orange,
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchApRequestChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'In',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(raw.inboxReceived),
|
||||
}, {
|
||||
name: 'Out (succ)',
|
||||
type: 'area',
|
||||
color: '#00E396',
|
||||
data: format(raw.deliverSucceeded),
|
||||
}, {
|
||||
name: 'Out (fail)',
|
||||
type: 'area',
|
||||
color: '#FEB019',
|
||||
data: format(raw.deliverFailed),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchNotesChart = async (type: string): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
data: format(type === 'combined'
|
||||
? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
|
||||
: sum(raw[type].inc, negate(raw[type].dec)),
|
||||
),
|
||||
color: '#888888',
|
||||
}, {
|
||||
name: 'Renotes',
|
||||
type: 'area',
|
||||
data: format(type === 'combined'
|
||||
? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
|
||||
: raw[type].diffs.renote,
|
||||
),
|
||||
color: colors.green,
|
||||
}, {
|
||||
name: 'Replies',
|
||||
type: 'area',
|
||||
data: format(type === 'combined'
|
||||
? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
|
||||
: raw[type].diffs.reply,
|
||||
),
|
||||
color: colors.yellow,
|
||||
}, {
|
||||
name: 'Normal',
|
||||
type: 'area',
|
||||
data: format(type === 'combined'
|
||||
? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
|
||||
: raw[type].diffs.normal,
|
||||
),
|
||||
color: colors.blue,
|
||||
}, {
|
||||
name: 'With file',
|
||||
type: 'area',
|
||||
data: format(type === 'combined'
|
||||
? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
|
||||
: raw[type].diffs.withFile,
|
||||
),
|
||||
color: colors.purple,
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchNotesTotalChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
data: format(sum(raw.local.total, raw.remote.total)),
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
data: format(raw.local.total),
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
data: format(raw.remote.total),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Combined',
|
||||
type: 'line',
|
||||
data: format(total
|
||||
? sum(raw.local.total, raw.remote.total)
|
||||
: sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)),
|
||||
),
|
||||
}, {
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
data: format(total
|
||||
? raw.local.total
|
||||
: sum(raw.local.inc, negate(raw.local.dec)),
|
||||
),
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
data: format(total
|
||||
? raw.remote.total
|
||||
: sum(raw.remote.inc, negate(raw.remote.dec)),
|
||||
),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchActiveUsersChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Read & Write',
|
||||
type: 'area',
|
||||
data: format(raw.readWrite),
|
||||
color: colors.orange,
|
||||
}, {
|
||||
name: 'Write',
|
||||
type: 'area',
|
||||
data: format(raw.write),
|
||||
color: colors.lime,
|
||||
}, {
|
||||
name: 'Read',
|
||||
type: 'area',
|
||||
data: format(raw.read),
|
||||
color: colors.blue,
|
||||
}, {
|
||||
name: '< Week',
|
||||
type: 'area',
|
||||
data: format(raw.registeredWithinWeek),
|
||||
color: colors.green,
|
||||
}, {
|
||||
name: '< Month',
|
||||
type: 'area',
|
||||
data: format(raw.registeredWithinMonth),
|
||||
color: colors.yellow,
|
||||
}, {
|
||||
name: '< Year',
|
||||
type: 'area',
|
||||
data: format(raw.registeredWithinYear),
|
||||
color: colors.red,
|
||||
}, {
|
||||
name: '> Week',
|
||||
type: 'area',
|
||||
data: format(raw.registeredOutsideWeek),
|
||||
color: colors.yellow,
|
||||
}, {
|
||||
name: '> Month',
|
||||
type: 'area',
|
||||
data: format(raw.registeredOutsideMonth),
|
||||
color: colors.red,
|
||||
}, {
|
||||
name: '> Year',
|
||||
type: 'area',
|
||||
data: format(raw.registeredOutsideYear),
|
||||
color: colors.purple,
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchDriveChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
dashed: true,
|
||||
data: format(
|
||||
sum(
|
||||
raw.local.incSize,
|
||||
negate(raw.local.decSize),
|
||||
raw.remote.incSize,
|
||||
negate(raw.remote.decSize),
|
||||
),
|
||||
),
|
||||
}, {
|
||||
name: 'Local +',
|
||||
type: 'area',
|
||||
data: format(raw.local.incSize),
|
||||
}, {
|
||||
name: 'Local -',
|
||||
type: 'area',
|
||||
data: format(negate(raw.local.decSize)),
|
||||
}, {
|
||||
name: 'Remote +',
|
||||
type: 'area',
|
||||
data: format(raw.remote.incSize),
|
||||
}, {
|
||||
name: 'Remote -',
|
||||
type: 'area',
|
||||
data: format(negate(raw.remote.decSize)),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchDriveFilesChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
dashed: true,
|
||||
data: format(
|
||||
sum(
|
||||
raw.local.incCount,
|
||||
negate(raw.local.decCount),
|
||||
raw.remote.incCount,
|
||||
negate(raw.remote.decCount),
|
||||
),
|
||||
),
|
||||
}, {
|
||||
name: 'Local +',
|
||||
type: 'area',
|
||||
data: format(raw.local.incCount),
|
||||
}, {
|
||||
name: 'Local -',
|
||||
type: 'area',
|
||||
data: format(negate(raw.local.decCount)),
|
||||
}, {
|
||||
name: 'Remote +',
|
||||
type: 'area',
|
||||
data: format(raw.remote.incCount),
|
||||
}, {
|
||||
name: 'Remote -',
|
||||
type: 'area',
|
||||
data: format(negate(raw.remote.decCount)),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'In',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(raw.requests.received),
|
||||
}, {
|
||||
name: 'Out (succ)',
|
||||
type: 'area',
|
||||
color: '#00E396',
|
||||
data: format(raw.requests.succeeded),
|
||||
}, {
|
||||
name: 'Out (fail)',
|
||||
type: 'area',
|
||||
color: '#FEB019',
|
||||
data: format(raw.requests.failed),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Users',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(total
|
||||
? raw.users.total
|
||||
: sum(raw.users.inc, negate(raw.users.dec)),
|
||||
),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Notes',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(total
|
||||
? raw.notes.total
|
||||
: sum(raw.notes.inc, negate(raw.notes.dec)),
|
||||
),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Following',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(total
|
||||
? raw.following.total
|
||||
: sum(raw.following.inc, negate(raw.following.dec)),
|
||||
),
|
||||
}, {
|
||||
name: 'Followers',
|
||||
type: 'area',
|
||||
color: '#00E396',
|
||||
data: format(total
|
||||
? raw.followers.total
|
||||
: sum(raw.followers.inc, negate(raw.followers.dec)),
|
||||
),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
bytes: true,
|
||||
series: [{
|
||||
name: 'Drive usage',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(total
|
||||
? raw.drive.totalUsage
|
||||
: sum(raw.drive.incUsage, negate(raw.drive.decUsage)),
|
||||
),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Drive files',
|
||||
type: 'area',
|
||||
color: '#008FFB',
|
||||
data: format(total
|
||||
? raw.drive.totalFiles
|
||||
: sum(raw.drive.incFiles, negate(raw.drive.decFiles)),
|
||||
),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPerUserNotesChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [...(props.args.withoutAll ? [] : [{
|
||||
name: 'All',
|
||||
type: 'line',
|
||||
data: format(sum(raw.inc, negate(raw.dec))),
|
||||
color: '#888888',
|
||||
}]), {
|
||||
name: 'With file',
|
||||
type: 'area',
|
||||
data: format(raw.diffs.withFile),
|
||||
color: colors.purple,
|
||||
}, {
|
||||
name: 'Renotes',
|
||||
type: 'area',
|
||||
data: format(raw.diffs.renote),
|
||||
color: colors.green,
|
||||
}, {
|
||||
name: 'Replies',
|
||||
type: 'area',
|
||||
data: format(raw.diffs.reply),
|
||||
color: colors.yellow,
|
||||
}, {
|
||||
name: 'Normal',
|
||||
type: 'area',
|
||||
data: format(raw.diffs.normal),
|
||||
color: colors.blue,
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
data: format(raw.local.followings.total),
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
data: format(raw.remote.followings.total),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Local',
|
||||
type: 'area',
|
||||
data: format(raw.local.followers.total),
|
||||
}, {
|
||||
name: 'Remote',
|
||||
type: 'area',
|
||||
data: format(raw.remote.followers.total),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPerUserDriveChart = async (): Promise<typeof chartData> => {
|
||||
const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span });
|
||||
return {
|
||||
series: [{
|
||||
name: 'Inc',
|
||||
type: 'area',
|
||||
data: format(raw.incSize),
|
||||
}, {
|
||||
name: 'Dec',
|
||||
type: 'area',
|
||||
data: format(raw.decSize),
|
||||
}],
|
||||
};
|
||||
};
|
||||
|
||||
const fetchAndRender = async () => {
|
||||
const fetchData = () => {
|
||||
switch (props.src) {
|
||||
case 'federation': return fetchFederationChart();
|
||||
case 'ap-request': return fetchApRequestChart();
|
||||
case 'users': return fetchUsersChart(false);
|
||||
case 'users-total': return fetchUsersChart(true);
|
||||
case 'active-users': return fetchActiveUsersChart();
|
||||
case 'notes': return fetchNotesChart('combined');
|
||||
case 'local-notes': return fetchNotesChart('local');
|
||||
case 'remote-notes': return fetchNotesChart('remote');
|
||||
case 'notes-total': return fetchNotesTotalChart();
|
||||
case 'drive': return fetchDriveChart();
|
||||
case 'drive-files': return fetchDriveFilesChart();
|
||||
case 'instance-requests': return fetchInstanceRequestsChart();
|
||||
case 'instance-users': return fetchInstanceUsersChart(false);
|
||||
case 'instance-users-total': return fetchInstanceUsersChart(true);
|
||||
case 'instance-notes': return fetchInstanceNotesChart(false);
|
||||
case 'instance-notes-total': return fetchInstanceNotesChart(true);
|
||||
case 'instance-ff': return fetchInstanceFfChart(false);
|
||||
case 'instance-ff-total': return fetchInstanceFfChart(true);
|
||||
case 'instance-drive-usage': return fetchInstanceDriveUsageChart(false);
|
||||
case 'instance-drive-usage-total': return fetchInstanceDriveUsageChart(true);
|
||||
case 'instance-drive-files': return fetchInstanceDriveFilesChart(false);
|
||||
case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
|
||||
|
||||
case 'per-user-notes': return fetchPerUserNotesChart();
|
||||
case 'per-user-following': return fetchPerUserFollowingChart();
|
||||
case 'per-user-followers': return fetchPerUserFollowersChart();
|
||||
case 'per-user-drive': return fetchPerUserDriveChart();
|
||||
}
|
||||
};
|
||||
fetching.value = true;
|
||||
chartData = await fetchData();
|
||||
fetching.value = false;
|
||||
render();
|
||||
};
|
||||
|
||||
watch(() => [props.src, props.span], fetchAndRender);
|
||||
|
||||
onMounted(() => {
|
||||
fetchAndRender();
|
||||
});
|
||||
/* eslint-enable id-denylist */
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cbbedffa {
|
||||
position: relative;
|
||||
|
||||
> .fetching {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
-webkit-backdrop-filter: var(--blur, blur(12px));
|
||||
backdrop-filter: var(--blur, blur(12px));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: wait;
|
||||
}
|
||||
}
|
||||
</style>
|
53
packages/frontend/src/components/MkChartTooltip.vue
Normal file
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')">
|
||||
<div v-if="title || series" class="qpcyisrl">
|
||||
<div v-if="title" class="title">{{ title }}</div>
|
||||
<template v-if="series">
|
||||
<div v-for="x in series" class="series">
|
||||
<span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span>
|
||||
<span>{{ x.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</MkTooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkTooltip from './MkTooltip.vue';
|
||||
|
||||
defineProps<{
|
||||
showing: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
title?: string;
|
||||
series?: {
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
text: string;
|
||||
}[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.qpcyisrl {
|
||||
> .title {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
> .series {
|
||||
> .color {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
20
packages/frontend/src/components/MkCode.core.vue
Normal file
|
@ -0,0 +1,20 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code>
|
||||
<pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/themes/prism-okaidia.css';
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
lang?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const prismLang = computed(() => Prism.languages[props.lang] ? props.lang : 'js');
|
||||
const html = computed(() => Prism.highlight(props.code, Prism.languages[prismLang.value], prismLang.value));
|
||||
</script>
|
15
packages/frontend/src/components/MkCode.vue
Normal file
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<XCode :code="code" :lang="lang" :inline="inline"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
lang?: string;
|
||||
inline?: boolean;
|
||||
}>();
|
||||
|
||||
const XCode = defineAsyncComponent(() => import('@/components/MkCode.core.vue'));
|
||||
</script>
|
275
packages/frontend/src/components/MkContainer.vue
Normal file
|
@ -0,0 +1,275 @@
|
|||
<template>
|
||||
<div v-size="{ max: [380] }" class="ukygtjoj _panel" :class="{ naked, thin, hideHeader: !showHeader, scrollable, closed: !showBody }">
|
||||
<header v-if="showHeader" ref="header">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="sub">
|
||||
<slot name="func"></slot>
|
||||
<button v-if="foldable" class="_button" @click="() => showBody = !showBody">
|
||||
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
|
||||
<template v-else><i class="ti ti-chevron-down"></i></template>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<transition
|
||||
:name="$store.state.animation ? 'container-toggle' : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody" ref="content" class="content" :class="{ omitted }">
|
||||
<slot></slot>
|
||||
<button v-if="omitted" class="fade _button" @click="() => { ignoreOmit = true; omitted = false; }">
|
||||
<span>{{ $ts.showMore }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
showHeader: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
thin: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
naked: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
foldable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
maxHeight: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showBody: this.expanded,
|
||||
omitted: null,
|
||||
ignoreOmit: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$watch('showBody', showBody => {
|
||||
const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
|
||||
this.$el.style.minHeight = `${headerHeight}px`;
|
||||
if (showBody) {
|
||||
this.$el.style.flexBasis = 'auto';
|
||||
} else {
|
||||
this.$el.style.flexBasis = `${headerHeight}px`;
|
||||
}
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
|
||||
this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
|
||||
|
||||
const calcOmit = () => {
|
||||
if (this.omitted || this.ignoreOmit || this.maxHeight == null) return;
|
||||
const height = this.$refs.content.offsetHeight;
|
||||
this.omitted = height > this.maxHeight;
|
||||
};
|
||||
|
||||
calcOmit();
|
||||
new ResizeObserver((entries, observer) => {
|
||||
calcOmit();
|
||||
}).observe(this.$refs.content);
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
if (!this.foldable) return;
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container-toggle-enter-active, .container-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition: opacity 0.5s, height 0.5s !important;
|
||||
}
|
||||
.container-toggle-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.container-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ukygtjoj {
|
||||
position: relative;
|
||||
overflow: clip;
|
||||
contain: content;
|
||||
|
||||
&.naked {
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&.scrollable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> .content {
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
position: sticky;
|
||||
top: var(--stickyTop, 0px);
|
||||
left: 0;
|
||||
color: var(--panelHeaderFg);
|
||||
background: var(--panelHeaderBg);
|
||||
border-bottom: solid 0.5px var(--panelHeaderDivider);
|
||||
z-index: 2;
|
||||
line-height: 1.4em;
|
||||
|
||||
> .title {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
|
||||
> ::v-deep(i) {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .sub {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
|
||||
> ::v-deep(button) {
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
--stickyTop: 0px;
|
||||
|
||||
&.omitted {
|
||||
position: relative;
|
||||
max-height: var(--maxHeight);
|
||||
overflow: hidden;
|
||||
|
||||
> .fade {
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
background: linear-gradient(0deg, var(--panel), var(--X15));
|
||||
|
||||
> span {
|
||||
display: inline-block;
|
||||
background: var(--panel);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
> span {
|
||||
background: var(--panelHighlight);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_380px, &.thin {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
> .content {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 380px) {
|
||||
.ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._forceContainerFull_ .ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
._forceContainerFull_.ukygtjoj {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
85
packages/frontend/src/components/MkContextMenu.vue
Normal file
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<transition :name="$store.state.animation ? 'fade' : ''" appear>
|
||||
<div ref="rootEl" class="nvlagfpb" :style="{ zIndex }" @contextmenu.prevent.stop="() => {}">
|
||||
<MkMenu :items="items" :align="'left'" @close="$emit('closed')"/>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
import MkMenu from './MkMenu.vue';
|
||||
import { MenuItem } from './types/menu.vue';
|
||||
import contains from '@/scripts/contains';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
items: MenuItem[];
|
||||
ev: MouseEvent;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
let rootEl = $ref<HTMLDivElement>();
|
||||
|
||||
let zIndex = $ref<number>(os.claimZIndex('high'));
|
||||
|
||||
onMounted(() => {
|
||||
let left = props.ev.pageX + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
let top = props.ev.pageY + 1; // 間違って右ダブルクリックした場合に意図せずアイテムがクリックされるのを防ぐため + 1
|
||||
|
||||
const width = rootEl.offsetWidth;
|
||||
const height = rootEl.offsetHeight;
|
||||
|
||||
if (left + width - window.pageXOffset > window.innerWidth) {
|
||||
left = window.innerWidth - width + window.pageXOffset;
|
||||
}
|
||||
|
||||
if (top + height - window.pageYOffset > window.innerHeight) {
|
||||
top = window.innerHeight - height + window.pageYOffset;
|
||||
}
|
||||
|
||||
if (top < 0) {
|
||||
top = 0;
|
||||
}
|
||||
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
rootEl.style.top = `${top}px`;
|
||||
rootEl.style.left = `${left}px`;
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
});
|
||||
|
||||
function onMousedown(evt: Event) {
|
||||
if (!contains(rootEl, evt.target) && (rootEl !== evt.target)) emit('closed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nvlagfpb {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
174
packages/frontend/src/components/MkCropperDialog.vue
Normal file
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<XModalWindow
|
||||
ref="dialogEl"
|
||||
:width="800"
|
||||
:height="500"
|
||||
:scroll="false"
|
||||
:with-ok-button="true"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.cropImage }}</template>
|
||||
<template #default="{ width, height }">
|
||||
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
|
||||
<Transition name="fade">
|
||||
<div v-if="loading" class="loading">
|
||||
<MkLoading/>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="container">
|
||||
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { nextTick, onMounted } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import Cropper from 'cropperjs';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import XModalWindow from '@/components/MkModalWindow.vue';
|
||||
import * as os from '@/os';
|
||||
import { $i } from '@/account';
|
||||
import { defaultStore } from '@/store';
|
||||
import { apiUrl } from '@/config';
|
||||
import { i18n } from '@/i18n';
|
||||
import { getProxiedImageUrl } from '@/scripts/media-proxy';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'ok', cropped: misskey.entities.DriveFile): void;
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
file: misskey.entities.DriveFile;
|
||||
aspectRatio: number;
|
||||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url);
|
||||
let dialogEl = $ref<InstanceType<typeof XModalWindow>>();
|
||||
let imgEl = $ref<HTMLImageElement>();
|
||||
let cropper: Cropper | null = null;
|
||||
let loading = $ref(true);
|
||||
|
||||
const ok = async () => {
|
||||
const promise = new Promise<misskey.entities.DriveFile>(async (res) => {
|
||||
const croppedCanvas = await cropper?.getCropperSelection()?.$toCanvas();
|
||||
croppedCanvas.toBlob(blob => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('i', $i.token);
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||
}
|
||||
|
||||
window.fetch(apiUrl + '/drive/files/create', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(f => {
|
||||
res(f);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
os.promiseDialog(promise);
|
||||
|
||||
const f = await promise;
|
||||
|
||||
emit('ok', f);
|
||||
dialogEl.close();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
dialogEl.close();
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading = false;
|
||||
|
||||
if (cropper) {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
cropper.getCropperSelection()!.$center();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
cropper = new Cropper(imgEl, {
|
||||
});
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
|
||||
const selection = cropper.getCropperSelection()!;
|
||||
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--accent')).toHexString();
|
||||
selection.aspectRatio = props.aspectRatio;
|
||||
selection.initialAspectRatio = props.aspectRatio;
|
||||
selection.outlined = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 100);
|
||||
|
||||
// モーダルオープンアニメーションが終わったあとで再度調整
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 500);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease 0.5s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.mk-cropper-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: var(--vw);
|
||||
height: var(--vh);
|
||||
position: relative;
|
||||
|
||||
> .loading {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
-webkit-backdrop-filter: var(--blur, blur(10px));
|
||||
backdrop-filter: var(--blur, blur(10px));
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
> .container {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> ::v-deep(cropper-canvas) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> cropper-selection > cropper-handle[action="move"] {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
62
packages/frontend/src/components/MkCwButton.vue
Normal file
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<button class="nrvgflfu _button" @click="toggle">
|
||||
<b>{{ modelValue ? i18n.ts._cw.hide : i18n.ts._cw.show }}</b>
|
||||
<span v-if="!modelValue">{{ label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { length } from 'stringz';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { concat } from '@/scripts/array';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
note: misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', v: boolean): void;
|
||||
}>();
|
||||
|
||||
const label = computed(() => {
|
||||
return concat([
|
||||
props.note.text ? [i18n.t('_cw.chars', { count: length(props.note.text) })] : [],
|
||||
props.note.files && props.note.files.length !== 0 ? [i18n.t('_cw.files', { count: props.note.files.length })] : [],
|
||||
props.note.poll != null ? [i18n.ts.poll] : [],
|
||||
] as string[][]).join(' / ');
|
||||
});
|
||||
|
||||
const toggle = () => {
|
||||
emit('update:modelValue', !props.modelValue);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nrvgflfu {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.7em;
|
||||
color: var(--cwFg);
|
||||
background: var(--cwBg);
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: var(--cwHoverBg);
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-left: 4px;
|
||||
|
||||
&:before {
|
||||
content: '(';
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
189
packages/frontend/src/components/MkDateSeparatedList.vue
Normal file
|
@ -0,0 +1,189 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, h, PropType, TransitionGroup } from 'vue';
|
||||
import MkAd from '@/components/global/MkAd.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
items: {
|
||||
type: Array as PropType<{ id: string; createdAt: string; _shouldInsertAd_: boolean; }[]>,
|
||||
required: true,
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'down',
|
||||
},
|
||||
reversed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
noGap: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
ad: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props, { slots, expose }) {
|
||||
function getDateText(time: string) {
|
||||
const date = new Date(time).getDate();
|
||||
const month = new Date(time).getMonth() + 1;
|
||||
return i18n.t('monthAndDay', {
|
||||
month: month.toString(),
|
||||
day: date.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (props.items.length === 0) return;
|
||||
|
||||
const renderChildren = () => props.items.map((item, i) => {
|
||||
if (!slots || !slots.default) return;
|
||||
|
||||
const el = slots.default({
|
||||
item: item,
|
||||
})[0];
|
||||
if (el.key == null && item.id) el.key = item.id;
|
||||
|
||||
if (
|
||||
i !== props.items.length - 1 &&
|
||||
new Date(item.createdAt).getDate() !== new Date(props.items[i + 1].createdAt).getDate()
|
||||
) {
|
||||
const separator = h('div', {
|
||||
class: 'separator',
|
||||
key: item.id + ':separator',
|
||||
}, h('p', {
|
||||
class: 'date',
|
||||
}, [
|
||||
h('span', [
|
||||
h('i', {
|
||||
class: 'ti ti-chevron-up icon',
|
||||
}),
|
||||
getDateText(item.createdAt),
|
||||
]),
|
||||
h('span', [
|
||||
getDateText(props.items[i + 1].createdAt),
|
||||
h('i', {
|
||||
class: 'ti ti-chevron-down icon',
|
||||
}),
|
||||
]),
|
||||
]));
|
||||
|
||||
return [el, separator];
|
||||
} else {
|
||||
if (props.ad && item._shouldInsertAd_) {
|
||||
return [h(MkAd, {
|
||||
class: 'a', // advertiseの意(ブロッカー対策)
|
||||
key: item.id + ':ad',
|
||||
prefer: ['horizontal', 'horizontal-big'],
|
||||
}), el];
|
||||
} else {
|
||||
return el;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => h(
|
||||
defaultStore.state.animation ? TransitionGroup : 'div',
|
||||
defaultStore.state.animation ? {
|
||||
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
|
||||
name: 'list',
|
||||
tag: 'div',
|
||||
'data-direction': props.direction,
|
||||
'data-reversed': props.reversed ? 'true' : 'false',
|
||||
} : {
|
||||
class: 'sqadhkmv' + (props.noGap ? ' noGap' : ''),
|
||||
},
|
||||
{ default: renderChildren });
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.sqadhkmv {
|
||||
container-type: inline-size;
|
||||
|
||||
> *:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: var(--margin);
|
||||
}
|
||||
|
||||
> .list-move {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
> .list-enter-active {
|
||||
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
&[data-direction="up"] {
|
||||
> .list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(64px);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-direction="down"] {
|
||||
> .list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-64px);
|
||||
}
|
||||
}
|
||||
|
||||
> .separator {
|
||||
text-align: center;
|
||||
|
||||
> .date {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 32px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--dateLabelFg);
|
||||
|
||||
> span {
|
||||
&:first-child {
|
||||
margin-right: 8px;
|
||||
|
||||
> .icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 8px;
|
||||
|
||||
> .icon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.noGap {
|
||||
> * {
|
||||
margin: 0 !important;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
208
packages/frontend/src/components/MkDialog.vue
Normal file
|
@ -0,0 +1,208 @@
|
|||
<template>
|
||||
<MkModal ref="modal" :prefer-type="'dialog'" :z-priority="'high'" @click="done(true)" @closed="emit('closed')">
|
||||
<div class="mk-dialog">
|
||||
<div v-if="icon" class="icon">
|
||||
<i :class="icon"></i>
|
||||
</div>
|
||||
<div v-else-if="!input && !select" class="icon" :class="type">
|
||||
<i v-if="type === 'success'" class="ti ti-check"></i>
|
||||
<i v-else-if="type === 'error'" class="ti ti-circle-x"></i>
|
||||
<i v-else-if="type === 'warning'" class="ti ti-alert-triangle"></i>
|
||||
<i v-else-if="type === 'info'" class="ti ti-info-circle"></i>
|
||||
<i v-else-if="type === 'question'" class="ti ti-question-circle"></i>
|
||||
<MkLoading v-else-if="type === 'waiting'" :em="true"/>
|
||||
</div>
|
||||
<header v-if="title"><Mfm :text="title"/></header>
|
||||
<div v-if="text" class="body"><Mfm :text="text"/></div>
|
||||
<MkInput v-if="input" v-model="inputValue" autofocus :type="input.type || 'text'" :placeholder="input.placeholder || undefined" @keydown="onInputKeydown">
|
||||
<template v-if="input.type === 'password'" #prefix><i class="ti ti-lock"></i></template>
|
||||
</MkInput>
|
||||
<MkSelect v-if="select" v-model="selectedValue" autofocus>
|
||||
<template v-if="select.items">
|
||||
<option v-for="item in select.items" :value="item.value">{{ item.text }}</option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<optgroup v-for="groupedItem in select.groupedItems" :label="groupedItem.label">
|
||||
<option v-for="item in groupedItem.items" :value="item.value">{{ item.text }}</option>
|
||||
</optgroup>
|
||||
</template>
|
||||
</MkSelect>
|
||||
<div v-if="(showOkButton || showCancelButton) && !actions" class="buttons">
|
||||
<MkButton v-if="showOkButton" inline primary :autofocus="!input && !select" @click="ok">{{ (showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt }}</MkButton>
|
||||
<MkButton v-if="showCancelButton || input || select" inline @click="cancel">{{ i18n.ts.cancel }}</MkButton>
|
||||
</div>
|
||||
<div v-if="actions" class="buttons">
|
||||
<MkButton v-for="action in actions" :key="action.text" inline :primary="action.primary" @click="() => { action.callback(); close(); }">{{ action.text }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/form/input.vue';
|
||||
import MkSelect from '@/components/form/select.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
type Input = {
|
||||
type: HTMLInputElement['type'];
|
||||
placeholder?: string | null;
|
||||
default: any | null;
|
||||
};
|
||||
|
||||
type Select = {
|
||||
items: {
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
groupedItems: {
|
||||
label: string;
|
||||
items: {
|
||||
value: string;
|
||||
text: string;
|
||||
}[];
|
||||
}[];
|
||||
default: string | null;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
type?: 'success' | 'error' | 'warning' | 'info' | 'question' | 'waiting';
|
||||
title: string;
|
||||
text?: string;
|
||||
input?: Input;
|
||||
select?: Select;
|
||||
icon?: string;
|
||||
actions?: {
|
||||
text: string;
|
||||
primary?: boolean,
|
||||
callback: (...args: any[]) => void;
|
||||
}[];
|
||||
showOkButton?: boolean;
|
||||
showCancelButton?: boolean;
|
||||
cancelableByBgClick?: boolean;
|
||||
}>(), {
|
||||
type: 'info',
|
||||
showOkButton: true,
|
||||
showCancelButton: false,
|
||||
cancelableByBgClick: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: { canceled: boolean; result: any }): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof MkModal>>();
|
||||
|
||||
const inputValue = ref(props.input?.default || null);
|
||||
const selectedValue = ref(props.select?.default || null);
|
||||
|
||||
function done(canceled: boolean, result?) {
|
||||
emit('done', { canceled, result });
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
async function ok() {
|
||||
if (!props.showOkButton) return;
|
||||
|
||||
const result =
|
||||
props.input ? inputValue.value :
|
||||
props.select ? selectedValue.value :
|
||||
true;
|
||||
done(false, result);
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
done(true);
|
||||
}
|
||||
/*
|
||||
function onBgClick() {
|
||||
if (props.cancelableByBgClick) cancel();
|
||||
}
|
||||
*/
|
||||
function onKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === 'Escape') cancel();
|
||||
}
|
||||
|
||||
function onInputKeydown(evt: KeyboardEvent) {
|
||||
if (evt.key === 'Enter') {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-dialog {
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
|
||||
> .icon {
|
||||
font-size: 24px;
|
||||
|
||||
&.info {
|
||||
color: #55c4dd;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
& + header {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> header {
|
||||
margin: 0 0 8px 0;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
|
||||
& + .body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
margin: 16px 0 0 0;
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
margin-top: 16px;
|
||||
|
||||
> * {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
77
packages/frontend/src/components/MkDigitalClock.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<span class="zjobosdg">
|
||||
<span v-text="hh"></span>
|
||||
<span class="colon" :class="{ showColon }">:</span>
|
||||
<span v-text="mm"></span>
|
||||
<span v-if="showS" class="colon" :class="{ showColon }">:</span>
|
||||
<span v-if="showS" v-text="ss"></span>
|
||||
<span v-if="showMs" class="colon" :class="{ showColon }">:</span>
|
||||
<span v-if="showMs" v-text="ms"></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showS?: boolean;
|
||||
showMs?: boolean;
|
||||
offset?: number;
|
||||
}>(), {
|
||||
showS: true,
|
||||
showMs: false,
|
||||
offset: 0 - new Date().getTimezoneOffset(),
|
||||
});
|
||||
|
||||
let intervalId;
|
||||
const hh = ref('');
|
||||
const mm = ref('');
|
||||
const ss = ref('');
|
||||
const ms = ref('');
|
||||
const showColon = ref(false);
|
||||
let prevSec: number | null = null;
|
||||
|
||||
watch(showColon, (v) => {
|
||||
if (v) {
|
||||
window.setTimeout(() => {
|
||||
showColon.value = false;
|
||||
}, 30);
|
||||
}
|
||||
});
|
||||
|
||||
const tick = () => {
|
||||
const now = new Date();
|
||||
now.setMinutes(now.getMinutes() + (new Date().getTimezoneOffset() + props.offset));
|
||||
hh.value = now.getHours().toString().padStart(2, '0');
|
||||
mm.value = now.getMinutes().toString().padStart(2, '0');
|
||||
ss.value = now.getSeconds().toString().padStart(2, '0');
|
||||
ms.value = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
|
||||
if (now.getSeconds() !== prevSec) showColon.value = true;
|
||||
prevSec = now.getSeconds();
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
watch(() => props.showMs, () => {
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
intervalId = window.setInterval(tick, props.showMs ? 10 : 1000);
|
||||
}, { immediate: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
window.clearInterval(intervalId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zjobosdg {
|
||||
> .colon {
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease;
|
||||
|
||||
&.showColon {
|
||||
opacity: 1;
|
||||
transition: opacity 0s;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
334
packages/frontend/src/components/MkDrive.file.vue
Normal file
|
@ -0,0 +1,334 @@
|
|||
<template>
|
||||
<div
|
||||
class="ncvczrfv"
|
||||
:class="{ isSelected }"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<div v-if="$i?.avatarId == file.id" class="label">
|
||||
<img src="/client-assets/label.svg"/>
|
||||
<p>{{ i18n.ts.avatar }}</p>
|
||||
</div>
|
||||
<div v-if="$i?.bannerId == file.id" class="label">
|
||||
<img src="/client-assets/label.svg"/>
|
||||
<p>{{ i18n.ts.banner }}</p>
|
||||
</div>
|
||||
<div v-if="file.isSensitive" class="label red">
|
||||
<img src="/client-assets/label-red.svg"/>
|
||||
<p>{{ i18n.ts.nsfw }}</p>
|
||||
</div>
|
||||
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
|
||||
|
||||
<p class="name">
|
||||
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
|
||||
<span v-if="file.name.lastIndexOf('.') != -1" class="ext">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import bytes from '@/filters/bytes';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(), {
|
||||
isSelected: false,
|
||||
selectMode: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', r: Misskey.entities.DriveFile): void;
|
||||
(ev: 'dragstart'): void;
|
||||
(ev: 'dragend'): void;
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
|
||||
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
|
||||
|
||||
function getMenu() {
|
||||
return [{
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ti ti-forms',
|
||||
action: rename,
|
||||
}, {
|
||||
text: props.file.isSensitive ? i18n.ts.unmarkAsSensitive : i18n.ts.markAsSensitive,
|
||||
icon: props.file.isSensitive ? 'ti ti-eye' : 'ti ti-eye-off',
|
||||
action: toggleSensitive,
|
||||
}, {
|
||||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: describe,
|
||||
}, null, {
|
||||
text: i18n.ts.copyUrl,
|
||||
icon: 'ti ti-link',
|
||||
action: copyUrl,
|
||||
}, {
|
||||
type: 'a',
|
||||
href: props.file.url,
|
||||
target: '_blank',
|
||||
text: i18n.ts.download,
|
||||
icon: 'ti ti-download',
|
||||
download: props.file.name,
|
||||
}, null, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: deleteFile,
|
||||
}];
|
||||
}
|
||||
|
||||
function onClick(ev: MouseEvent) {
|
||||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getMenu(), ev);
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
if (ev.dataTransfer) {
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
|
||||
}
|
||||
isDragging.value = true;
|
||||
|
||||
emit('dragstart');
|
||||
}
|
||||
|
||||
function onDragend() {
|
||||
isDragging.value = false;
|
||||
emit('dragend');
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFile,
|
||||
placeholder: i18n.ts.inputNewFileName,
|
||||
default: props.file.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api('drive/files/update', {
|
||||
fileId: props.file.id,
|
||||
name: name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function describe() {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
|
||||
default: props.file.comment != null ? props.file.comment : '',
|
||||
file: props.file,
|
||||
}, {
|
||||
done: caption => {
|
||||
os.api('drive/files/update', {
|
||||
fileId: props.file.id,
|
||||
comment: caption.length === 0 ? null : caption,
|
||||
});
|
||||
},
|
||||
}, 'closed');
|
||||
}
|
||||
|
||||
function toggleSensitive() {
|
||||
os.api('drive/files/update', {
|
||||
fileId: props.file.id,
|
||||
isSensitive: !props.file.isSensitive,
|
||||
});
|
||||
}
|
||||
|
||||
function copyUrl() {
|
||||
copyToClipboard(props.file.url);
|
||||
os.success();
|
||||
}
|
||||
/*
|
||||
function addApp() {
|
||||
alert('not implemented yet');
|
||||
}
|
||||
*/
|
||||
async function deleteFile() {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('driveFileDeleteConfirm', { name: props.file.name }),
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
os.api('drive/files/delete', {
|
||||
fileId: props.file.id,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ncvczrfv {
|
||||
position: relative;
|
||||
padding: 8px 0 0 0;
|
||||
min-height: 180px;
|
||||
border-radius: 8px;
|
||||
|
||||
&, * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(#000, 0.05);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #0b65a5;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: rgba(#000, 0.1);
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #0b588c;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #ce2212;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.isSelected {
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
}
|
||||
|
||||
> .label {
|
||||
&:before,
|
||||
&:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
> .label {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: #0c7ac9;
|
||||
}
|
||||
|
||||
&:before {
|
||||
top: 0;
|
||||
left: 57px;
|
||||
width: 28px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&:after {
|
||||
top: 57px;
|
||||
left: 0;
|
||||
width: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.red {
|
||||
&:before,
|
||||
&:after {
|
||||
background: #c12113;
|
||||
}
|
||||
}
|
||||
|
||||
> img {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
> p {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 19px;
|
||||
left: -28px;
|
||||
width: 120px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
line-height: 28px;
|
||||
color: #fff;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
> .name {
|
||||
display: block;
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
word-break: break-all;
|
||||
color: var(--fg);
|
||||
overflow: hidden;
|
||||
|
||||
> .ext {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
330
packages/frontend/src/components/MkDrive.folder.vue
Normal file
|
@ -0,0 +1,330 @@
|
|||
<template>
|
||||
<div
|
||||
class="rghtznwe"
|
||||
:class="{ draghover }"
|
||||
draggable="true"
|
||||
:title="title"
|
||||
@click="onClick"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
@mouseover="onMouseover"
|
||||
@mouseout="onMouseout"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter.prevent="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
@dragstart="onDragstart"
|
||||
@dragend="onDragend"
|
||||
>
|
||||
<p class="name">
|
||||
<template v-if="hover"><i class="ti ti-folder ti-fw"></i></template>
|
||||
<template v-if="!hover"><i class="ti ti-folder ti-fw"></i></template>
|
||||
{{ folder.name }}
|
||||
</p>
|
||||
<p v-if="defaultStore.state.uploadFolder == folder.id" class="upload">
|
||||
{{ i18n.ts.uploadFolder }}
|
||||
</p>
|
||||
<button v-if="selectMode" class="checkbox _button" :class="{ checked: isSelected }" @click.prevent.stop="checkboxClicked"></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
folder: Misskey.entities.DriveFolder;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(), {
|
||||
isSelected: false,
|
||||
selectMode: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'move', v: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
|
||||
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
|
||||
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
|
||||
(ev: 'dragstart'): void;
|
||||
(ev: 'dragend'): void;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const draghover = ref(false);
|
||||
const isDragging = ref(false);
|
||||
|
||||
const title = computed(() => props.folder.name);
|
||||
|
||||
function checkboxClicked() {
|
||||
emit('chosen', props.folder);
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function onMouseout() {
|
||||
hover.value = false;
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// 自分自身がドラッグされている場合
|
||||
if (isDragging.value) {
|
||||
// 自分自身にはドロップさせない
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
case 'move':
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
break;
|
||||
default:
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function onDragenter() {
|
||||
if (!isDragging.value) draghover.value = true;
|
||||
}
|
||||
|
||||
function onDragleave() {
|
||||
draghover.value = false;
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent) {
|
||||
draghover.value = false;
|
||||
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit('upload', file, props.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit('removeFile', file.id);
|
||||
os.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: props.folder.id,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.id === props.folder.id) return;
|
||||
|
||||
emit('removeFolder', folder.id);
|
||||
os.api('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder.id,
|
||||
}).then(() => {
|
||||
// noop
|
||||
}).catch(err => {
|
||||
switch (err) {
|
||||
case 'detected-circular-definition':
|
||||
os.alert({
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
|
||||
isDragging.value = true;
|
||||
|
||||
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
|
||||
// (=あなたの子供が、ドラッグを開始しましたよ)
|
||||
emit('dragstart');
|
||||
}
|
||||
|
||||
function onDragend() {
|
||||
isDragging.value = false;
|
||||
emit('dragend');
|
||||
}
|
||||
|
||||
function go() {
|
||||
emit('move', props.folder.id);
|
||||
}
|
||||
|
||||
function rename() {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFolder,
|
||||
placeholder: i18n.ts.inputNewFolderName,
|
||||
default: props.folder.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api('drive/folders/update', {
|
||||
folderId: props.folder.id,
|
||||
name: name,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFolder() {
|
||||
os.api('drive/folders/delete', {
|
||||
folderId: props.folder.id,
|
||||
}).then(() => {
|
||||
if (defaultStore.state.uploadFolder === props.folder.id) {
|
||||
defaultStore.set('uploadFolder', null);
|
||||
}
|
||||
}).catch(err => {
|
||||
switch (err.id) {
|
||||
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.unableToDelete,
|
||||
text: i18n.ts.hasChildFilesOrFolders,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.unableToDelete,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setAsUploadFolder() {
|
||||
defaultStore.set('uploadFolder', props.folder.id);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu([{
|
||||
text: i18n.ts.openInWindow,
|
||||
icon: 'ti ti-app-window',
|
||||
action: () => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkDriveWindow.vue')), {
|
||||
initialFolder: props.folder,
|
||||
}, {
|
||||
}, 'closed');
|
||||
},
|
||||
}, null, {
|
||||
text: i18n.ts.rename,
|
||||
icon: 'ti ti-forms',
|
||||
action: rename,
|
||||
}, null, {
|
||||
text: i18n.ts.delete,
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: deleteFolder,
|
||||
}], ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rghtznwe {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
height: 64px;
|
||||
background: var(--driveFolderBg);
|
||||
border-radius: 4px;
|
||||
|
||||
&, * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
*:not(.checkbox) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .checkbox {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #fff;
|
||||
border: solid 1px #000;
|
||||
|
||||
&.checked {
|
||||
background: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
&.draghover {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
bottom: -4px;
|
||||
left: -4px;
|
||||
border: 2px dashed var(--focus);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
> .name {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
margin-left: 2px;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
> .upload {
|
||||
margin: 4px 4px;
|
||||
font-size: 0.8em;
|
||||
text-align: right;
|
||||
color: var(--desktopDriveFolderFg);
|
||||
}
|
||||
}
|
||||
</style>
|
147
packages/frontend/src/components/MkDrive.navFolder.vue
Normal file
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<div class="drylbebk"
|
||||
:class="{ draghover }"
|
||||
@click="onClick"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.stop="onDrop"
|
||||
>
|
||||
<i v-if="folder == null" class="ti ti-cloud"></i>
|
||||
<span>{{ folder == null ? i18n.ts.drive : folder.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
folder?: Misskey.entities.DriveFolder;
|
||||
parentFolder: Misskey.entities.DriveFolder | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'move', v?: Misskey.entities.DriveFolder): void;
|
||||
(ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
|
||||
(ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
|
||||
(ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
|
||||
}>();
|
||||
|
||||
const hover = ref(false);
|
||||
const draghover = ref(false);
|
||||
|
||||
function onClick() {
|
||||
emit('move', props.folder);
|
||||
}
|
||||
|
||||
function onMouseover() {
|
||||
hover.value = true;
|
||||
}
|
||||
|
||||
function onMouseout() {
|
||||
hover.value = false;
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent) {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// このフォルダがルートかつカレントディレクトリならドロップ禁止
|
||||
if (props.folder == null && props.parentFolder == null) {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
case 'move':
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
break;
|
||||
default:
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onDragenter() {
|
||||
if (props.folder || props.parentFolder) draghover.value = true;
|
||||
}
|
||||
|
||||
function onDragleave() {
|
||||
if (props.folder || props.parentFolder) draghover.value = false;
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent) {
|
||||
draghover.value = false;
|
||||
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
emit('upload', file, props.folder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
emit('removeFile', file.id);
|
||||
os.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const folder = JSON.parse(driveFolder);
|
||||
// 移動先が自分自身ならreject
|
||||
if (props.folder && folder.id === props.folder.id) return;
|
||||
emit('removeFolder', folder.id);
|
||||
os.api('drive/folders/update', {
|
||||
folderId: folder.id,
|
||||
parentId: props.folder ? props.folder.id : null,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drylbebk {
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.draghover {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
801
packages/frontend/src/components/MkDrive.vue
Normal file
|
@ -0,0 +1,801 @@
|
|||
<template>
|
||||
<div class="yfudmmck">
|
||||
<nav>
|
||||
<div class="path" @contextmenu.prevent.stop="() => {}">
|
||||
<XNavFolder
|
||||
:class="{ current: folder == null }"
|
||||
:parent-folder="folder"
|
||||
@move="move"
|
||||
@upload="upload"
|
||||
@remove-file="removeFile"
|
||||
@remove-folder="removeFolder"
|
||||
/>
|
||||
<template v-for="f in hierarchyFolders">
|
||||
<span class="separator"><i class="ti ti-chevron-right"></i></span>
|
||||
<XNavFolder
|
||||
:folder="f"
|
||||
:parent-folder="folder"
|
||||
@move="move"
|
||||
@upload="upload"
|
||||
@remove-file="removeFile"
|
||||
@remove-folder="removeFolder"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="folder != null" class="separator"><i class="ti ti-chevron-right"></i></span>
|
||||
<span v-if="folder != null" class="folder current">{{ folder.name }}</span>
|
||||
</div>
|
||||
<button class="menu _button" @click="showMenu"><i class="ti ti-dots"></i></button>
|
||||
</nav>
|
||||
<div
|
||||
ref="main" class="main"
|
||||
:class="{ uploading: uploadings.length > 0, fetching }"
|
||||
@dragover.prevent.stop="onDragover"
|
||||
@dragenter="onDragenter"
|
||||
@dragleave="onDragleave"
|
||||
@drop.prevent.stop="onDrop"
|
||||
@contextmenu.stop="onContextmenu"
|
||||
>
|
||||
<div ref="contents" class="contents">
|
||||
<div v-show="folders.length > 0" ref="foldersContainer" class="folders">
|
||||
<XFolder
|
||||
v-for="(f, i) in folders"
|
||||
:key="f.id"
|
||||
v-anim="i"
|
||||
class="folder"
|
||||
:folder="f"
|
||||
:select-mode="select === 'folder'"
|
||||
:is-selected="selectedFolders.some(x => x.id === f.id)"
|
||||
@chosen="chooseFolder"
|
||||
@move="move"
|
||||
@upload="upload"
|
||||
@remove-file="removeFile"
|
||||
@remove-folder="removeFolder"
|
||||
@dragstart="isDragSource = true"
|
||||
@dragend="isDragSource = false"
|
||||
/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
|
||||
<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
|
||||
</div>
|
||||
<div v-show="files.length > 0" ref="filesContainer" class="files">
|
||||
<XFile
|
||||
v-for="(file, i) in files"
|
||||
:key="file.id"
|
||||
v-anim="i"
|
||||
class="file"
|
||||
:file="file"
|
||||
:select-mode="select === 'file'"
|
||||
:is-selected="selectedFiles.some(x => x.id === file.id)"
|
||||
@chosen="chooseFile"
|
||||
@dragstart="isDragSource = true"
|
||||
@dragend="isDragSource = false"
|
||||
/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div v-for="(n, i) in 16" :key="i" class="padding"></div>
|
||||
<MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
|
||||
</div>
|
||||
<div v-if="files.length == 0 && folders.length == 0 && !fetching" class="empty">
|
||||
<p v-if="draghover">{{ i18n.t('empty-draghover') }}</p>
|
||||
<p v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.t('empty-drive-description') }}</p>
|
||||
<p v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<MkLoading v-if="fetching"/>
|
||||
</div>
|
||||
<div v-if="draghover" class="dropzone"></div>
|
||||
<input ref="fileInput" type="file" accept="*/*" multiple tabindex="-1" @change="onChangeFileInput"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { markRaw, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import MkButton from './MkButton.vue';
|
||||
import XNavFolder from '@/components/MkDrive.navFolder.vue';
|
||||
import XFolder from '@/components/MkDrive.folder.vue';
|
||||
import XFile from '@/components/MkDrive.file.vue';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { uploadFile, uploads } from '@/scripts/upload';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder;
|
||||
type?: string;
|
||||
multiple?: boolean;
|
||||
select?: 'file' | 'folder' | null;
|
||||
}>(), {
|
||||
multiple: false,
|
||||
select: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void;
|
||||
(ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
|
||||
(ev: 'move-root'): void;
|
||||
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
|
||||
(ev: 'open-folder', v: Misskey.entities.DriveFolder): void;
|
||||
}>();
|
||||
|
||||
const loadMoreFiles = ref<InstanceType<typeof MkButton>>();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
|
||||
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||
const files = ref<Misskey.entities.DriveFile[]>([]);
|
||||
const folders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const moreFiles = ref(false);
|
||||
const moreFolders = ref(false);
|
||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
|
||||
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||
const uploadings = uploads;
|
||||
const connection = stream.useChannel('drive');
|
||||
const keepOriginal = ref<boolean>(defaultStore.state.keepOriginalUploading); // 外部渡しが多いので$refは使わないほうがよい
|
||||
|
||||
// ドロップされようとしているか
|
||||
const draghover = ref(false);
|
||||
|
||||
// 自身の所有するアイテムがドラッグをスタートさせたか
|
||||
// (自分自身の階層にドロップできないようにするためのフラグ)
|
||||
const isDragSource = ref(false);
|
||||
|
||||
const fetching = ref(true);
|
||||
|
||||
const ilFilesObserver = new IntersectionObserver(
|
||||
(entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
|
||||
);
|
||||
|
||||
watch(folder, () => emit('cd', folder.value));
|
||||
|
||||
function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
|
||||
addFile(file, true);
|
||||
}
|
||||
|
||||
function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== file.folderId) {
|
||||
removeFile(file);
|
||||
} else {
|
||||
addFile(file, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamDriveFileDeleted(fileId: string) {
|
||||
removeFile(fileId);
|
||||
}
|
||||
|
||||
function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) {
|
||||
addFolder(createdFolder, true);
|
||||
}
|
||||
|
||||
function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== updatedFolder.parentId) {
|
||||
removeFolder(updatedFolder);
|
||||
} else {
|
||||
addFolder(updatedFolder, true);
|
||||
}
|
||||
}
|
||||
|
||||
function onStreamDriveFolderDeleted(folderId: string) {
|
||||
removeFolder(folderId);
|
||||
}
|
||||
|
||||
function onDragover(ev: DragEvent): any {
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ドラッグ元が自分自身の所有するアイテムだったら
|
||||
if (isDragSource.value) {
|
||||
// 自分自身にはドロップさせない
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const isFile = ev.dataTransfer.items[0].kind === 'file';
|
||||
const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
|
||||
const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
|
||||
if (isFile || isDriveFile || isDriveFolder) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
case 'move':
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
break;
|
||||
default:
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
ev.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function onDragenter() {
|
||||
if (!isDragSource.value) draghover.value = true;
|
||||
}
|
||||
|
||||
function onDragleave() {
|
||||
draghover.value = false;
|
||||
}
|
||||
|
||||
function onDrop(ev: DragEvent): any {
|
||||
draghover.value = false;
|
||||
|
||||
if (!ev.dataTransfer) return;
|
||||
|
||||
// ドロップされてきたものがファイルだったら
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
for (const file of Array.from(ev.dataTransfer.files)) {
|
||||
upload(file, folder.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
//#region ドライブのファイル
|
||||
const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
|
||||
if (driveFile != null && driveFile !== '') {
|
||||
const file = JSON.parse(driveFile);
|
||||
if (files.value.some(f => f.id === file.id)) return;
|
||||
removeFile(file.id);
|
||||
os.api('drive/files/update', {
|
||||
fileId: file.id,
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region ドライブのフォルダ
|
||||
const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
|
||||
if (driveFolder != null && driveFolder !== '') {
|
||||
const droppedFolder = JSON.parse(driveFolder);
|
||||
|
||||
// 移動先が自分自身ならreject
|
||||
if (folder.value && droppedFolder.id === folder.value.id) return false;
|
||||
if (folders.value.some(f => f.id === droppedFolder.id)) return false;
|
||||
removeFolder(droppedFolder.id);
|
||||
os.api('drive/folders/update', {
|
||||
folderId: droppedFolder.id,
|
||||
parentId: folder.value ? folder.value.id : null,
|
||||
}).then(() => {
|
||||
// noop
|
||||
}).catch(err => {
|
||||
switch (err) {
|
||||
case 'detected-circular-definition':
|
||||
os.alert({
|
||||
title: i18n.ts.unableToProcess,
|
||||
text: i18n.ts.circularReferenceFolder,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.somethingHappened,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
function selectLocalFile() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
function urlUpload() {
|
||||
os.inputText({
|
||||
title: i18n.ts.uploadFromUrl,
|
||||
type: 'url',
|
||||
placeholder: i18n.ts.uploadFromUrlDescription,
|
||||
}).then(({ canceled, result: url }) => {
|
||||
if (canceled || !url) return;
|
||||
os.api('drive/files/upload-from-url', {
|
||||
url: url,
|
||||
folderId: folder.value ? folder.value.id : undefined,
|
||||
});
|
||||
|
||||
os.alert({
|
||||
title: i18n.ts.uploadFromUrlRequested,
|
||||
text: i18n.ts.uploadFromUrlMayTakeTime,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createFolder() {
|
||||
os.inputText({
|
||||
title: i18n.ts.createFolder,
|
||||
placeholder: i18n.ts.folderName,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api('drive/folders/create', {
|
||||
name: name,
|
||||
parentId: folder.value ? folder.value.id : undefined,
|
||||
}).then(createdFolder => {
|
||||
addFolder(createdFolder, true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
|
||||
os.inputText({
|
||||
title: i18n.ts.renameFolder,
|
||||
placeholder: i18n.ts.inputNewFolderName,
|
||||
default: folderToRename.name,
|
||||
}).then(({ canceled, result: name }) => {
|
||||
if (canceled) return;
|
||||
os.api('drive/folders/update', {
|
||||
folderId: folderToRename.id,
|
||||
name: name,
|
||||
}).then(updatedFolder => {
|
||||
// FIXME: 画面を更新するために自分自身に移動
|
||||
move(updatedFolder);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
|
||||
os.api('drive/folders/delete', {
|
||||
folderId: folderToDelete.id,
|
||||
}).then(() => {
|
||||
// 削除時に親フォルダに移動
|
||||
move(folderToDelete.parentId);
|
||||
}).catch(err => {
|
||||
switch (err.id) {
|
||||
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: i18n.ts.unableToDelete,
|
||||
text: i18n.ts.hasChildFilesOrFolders,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: i18n.ts.unableToDelete,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onChangeFileInput() {
|
||||
if (!fileInput.value?.files) return;
|
||||
for (const file of Array.from(fileInput.value.files)) {
|
||||
upload(file, folder.value);
|
||||
}
|
||||
}
|
||||
|
||||
function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null) {
|
||||
uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal.value).then(res => {
|
||||
addFile(res, true);
|
||||
});
|
||||
}
|
||||
|
||||
function chooseFile(file: Misskey.entities.DriveFile) {
|
||||
const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id);
|
||||
if (props.multiple) {
|
||||
if (isAlreadySelected) {
|
||||
selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
|
||||
} else {
|
||||
selectedFiles.value.push(file);
|
||||
}
|
||||
emit('change-selection', selectedFiles.value);
|
||||
} else {
|
||||
if (isAlreadySelected) {
|
||||
emit('selected', file);
|
||||
} else {
|
||||
selectedFiles.value = [file];
|
||||
emit('change-selection', [file]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
|
||||
const isAlreadySelected = selectedFolders.value.some(f => f.id === folderToChoose.id);
|
||||
if (props.multiple) {
|
||||
if (isAlreadySelected) {
|
||||
selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToChoose.id);
|
||||
} else {
|
||||
selectedFolders.value.push(folderToChoose);
|
||||
}
|
||||
emit('change-selection', selectedFolders.value);
|
||||
} else {
|
||||
if (isAlreadySelected) {
|
||||
emit('selected', folderToChoose);
|
||||
} else {
|
||||
selectedFolders.value = [folderToChoose];
|
||||
emit('change-selection', [folderToChoose]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function move(target?: Misskey.entities.DriveFolder) {
|
||||
if (!target) {
|
||||
goRoot();
|
||||
return;
|
||||
} else if (typeof target === 'object') {
|
||||
target = target.id;
|
||||
}
|
||||
|
||||
fetching.value = true;
|
||||
|
||||
os.api('drive/folders/show', {
|
||||
folderId: target,
|
||||
}).then(folderToMove => {
|
||||
folder.value = folderToMove;
|
||||
hierarchyFolders.value = [];
|
||||
|
||||
const dive = folderToDive => {
|
||||
hierarchyFolders.value.unshift(folderToDive);
|
||||
if (folderToDive.parent) dive(folderToDive.parent);
|
||||
};
|
||||
|
||||
if (folderToMove.parent) dive(folderToMove.parent);
|
||||
|
||||
emit('open-folder', folderToMove);
|
||||
fetch();
|
||||
});
|
||||
}
|
||||
|
||||
function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== folderToAdd.parentId) return;
|
||||
|
||||
if (folders.value.some(f => f.id === folderToAdd.id)) {
|
||||
const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id);
|
||||
folders.value[exist] = folderToAdd;
|
||||
return;
|
||||
}
|
||||
|
||||
if (unshift) {
|
||||
folders.value.unshift(folderToAdd);
|
||||
} else {
|
||||
folders.value.push(folderToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
|
||||
const current = folder.value ? folder.value.id : null;
|
||||
if (current !== fileToAdd.folderId) return;
|
||||
|
||||
if (files.value.some(f => f.id === fileToAdd.id)) {
|
||||
const exist = files.value.map(f => f.id).indexOf(fileToAdd.id);
|
||||
files.value[exist] = fileToAdd;
|
||||
return;
|
||||
}
|
||||
|
||||
if (unshift) {
|
||||
files.value.unshift(fileToAdd);
|
||||
} else {
|
||||
files.value.push(fileToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) {
|
||||
const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove;
|
||||
folders.value = folders.value.filter(f => f.id !== folderIdToRemove);
|
||||
}
|
||||
|
||||
function removeFile(file: Misskey.entities.DriveFile | string) {
|
||||
const fileId = typeof file === 'object' ? file.id : file;
|
||||
files.value = files.value.filter(f => f.id !== fileId);
|
||||
}
|
||||
|
||||
function appendFile(file: Misskey.entities.DriveFile) {
|
||||
addFile(file);
|
||||
}
|
||||
|
||||
function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
|
||||
addFolder(folderToAppend);
|
||||
}
|
||||
/*
|
||||
function prependFile(file: Misskey.entities.DriveFile) {
|
||||
addFile(file, true);
|
||||
}
|
||||
|
||||
function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) {
|
||||
addFolder(folderToPrepend, true);
|
||||
}
|
||||
*/
|
||||
function goRoot() {
|
||||
// 既にrootにいるなら何もしない
|
||||
if (folder.value == null) return;
|
||||
|
||||
folder.value = null;
|
||||
hierarchyFolders.value = [];
|
||||
emit('move-root');
|
||||
fetch();
|
||||
}
|
||||
|
||||
async function fetch() {
|
||||
folders.value = [];
|
||||
files.value = [];
|
||||
moreFolders.value = false;
|
||||
moreFiles.value = false;
|
||||
fetching.value = true;
|
||||
|
||||
const foldersMax = 30;
|
||||
const filesMax = 30;
|
||||
|
||||
const foldersPromise = os.api('drive/folders', {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
limit: foldersMax + 1,
|
||||
}).then(fetchedFolders => {
|
||||
if (fetchedFolders.length === foldersMax + 1) {
|
||||
moreFolders.value = true;
|
||||
fetchedFolders.pop();
|
||||
}
|
||||
return fetchedFolders;
|
||||
});
|
||||
|
||||
const filesPromise = os.api('drive/files', {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
limit: filesMax + 1,
|
||||
}).then(fetchedFiles => {
|
||||
if (fetchedFiles.length === filesMax + 1) {
|
||||
moreFiles.value = true;
|
||||
fetchedFiles.pop();
|
||||
}
|
||||
return fetchedFiles;
|
||||
});
|
||||
|
||||
const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]);
|
||||
|
||||
for (const x of fetchedFolders) appendFolder(x);
|
||||
for (const x of fetchedFiles) appendFile(x);
|
||||
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
function fetchMoreFiles() {
|
||||
fetching.value = true;
|
||||
|
||||
const max = 30;
|
||||
|
||||
// ファイル一覧取得
|
||||
os.api('drive/files', {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
untilId: files.value[files.value.length - 1].id,
|
||||
limit: max + 1,
|
||||
}).then(files => {
|
||||
if (files.length === max + 1) {
|
||||
moreFiles.value = true;
|
||||
files.pop();
|
||||
} else {
|
||||
moreFiles.value = false;
|
||||
}
|
||||
for (const x of files) appendFile(x);
|
||||
fetching.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function getMenu() {
|
||||
return [{
|
||||
type: 'switch',
|
||||
text: i18n.ts.keepOriginalUploading,
|
||||
ref: keepOriginal,
|
||||
}, null, {
|
||||
text: i18n.ts.addFile,
|
||||
type: 'label',
|
||||
}, {
|
||||
text: i18n.ts.upload,
|
||||
icon: 'ti ti-upload',
|
||||
action: () => { selectLocalFile(); },
|
||||
}, {
|
||||
text: i18n.ts.fromUrl,
|
||||
icon: 'ti ti-link',
|
||||
action: () => { urlUpload(); },
|
||||
}, null, {
|
||||
text: folder.value ? folder.value.name : i18n.ts.drive,
|
||||
type: 'label',
|
||||
}, folder.value ? {
|
||||
text: i18n.ts.renameFolder,
|
||||
icon: 'ti ti-forms',
|
||||
action: () => { renameFolder(folder.value); },
|
||||
} : undefined, folder.value ? {
|
||||
text: i18n.ts.deleteFolder,
|
||||
icon: 'ti ti-trash',
|
||||
action: () => { deleteFolder(folder.value as Misskey.entities.DriveFolder); },
|
||||
} : undefined, {
|
||||
text: i18n.ts.createFolder,
|
||||
icon: 'ti ti-folder-plus',
|
||||
action: () => { createFolder(); },
|
||||
}];
|
||||
}
|
||||
|
||||
function showMenu(ev: MouseEvent) {
|
||||
os.popupMenu(getMenu(), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getMenu(), ev);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (defaultStore.state.enableInfiniteScroll && loadMoreFiles.value) {
|
||||
nextTick(() => {
|
||||
ilFilesObserver.observe(loadMoreFiles.value?.$el);
|
||||
});
|
||||
}
|
||||
|
||||
connection.on('fileCreated', onStreamDriveFileCreated);
|
||||
connection.on('fileUpdated', onStreamDriveFileUpdated);
|
||||
connection.on('fileDeleted', onStreamDriveFileDeleted);
|
||||
connection.on('folderCreated', onStreamDriveFolderCreated);
|
||||
connection.on('folderUpdated', onStreamDriveFolderUpdated);
|
||||
connection.on('folderDeleted', onStreamDriveFolderDeleted);
|
||||
|
||||
if (props.initialFolder) {
|
||||
move(props.initialFolder);
|
||||
} else {
|
||||
fetch();
|
||||
}
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
if (defaultStore.state.enableInfiniteScroll) {
|
||||
nextTick(() => {
|
||||
ilFilesObserver.observe(loadMoreFiles.value?.$el);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
connection.dispose();
|
||||
ilFilesObserver.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.yfudmmck {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
> nav {
|
||||
display: flex;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
font-size: 0.9em;
|
||||
box-shadow: 0 1px 0 var(--divider);
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
> .path {
|
||||
display: inline-block;
|
||||
vertical-align: bottom;
|
||||
line-height: 42px;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
padding: 0 8px;
|
||||
line-height: 42px;
|
||||
cursor: pointer;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.current {
|
||||
font-weight: bold;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.separator {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
|
||||
> i {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .menu {
|
||||
margin-left: auto;
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
> .main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: var(--margin);
|
||||
|
||||
&, * {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&.fetching {
|
||||
cursor: wait !important;
|
||||
|
||||
* {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> .contents {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
height: calc(100% - 38px - 100px);
|
||||
}
|
||||
|
||||
> .contents {
|
||||
|
||||
> .folders,
|
||||
> .files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .folder,
|
||||
> .file {
|
||||
flex-grow: 1;
|
||||
width: 128px;
|
||||
margin: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
> .padding {
|
||||
flex-grow: 1;
|
||||
pointer-events: none;
|
||||
width: 128px + 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> .empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .dropzone {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 38px;
|
||||
width: 100%;
|
||||
height: calc(100% - 38px);
|
||||
border: dashed 2px var(--focus);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
80
packages/frontend/src/components/MkDriveFileThumbnail.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div ref="thumbnail" class="zdjebgpv">
|
||||
<ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/>
|
||||
<i v-else-if="is === 'image'" class="ti ti-photo icon"></i>
|
||||
<i v-else-if="is === 'video'" class="ti ti-video icon"></i>
|
||||
<i v-else-if="is === 'audio' || is === 'midi'" class="ti ti-file-music icon"></i>
|
||||
<i v-else-if="is === 'csv'" class="ti ti-file-text icon"></i>
|
||||
<i v-else-if="is === 'pdf'" class="ti ti-file-text icon"></i>
|
||||
<i v-else-if="is === 'textfile'" class="ti ti-file-text icon"></i>
|
||||
<i v-else-if="is === 'archive'" class="ti ti-file-zip icon"></i>
|
||||
<i v-else class="ti ti-file icon"></i>
|
||||
|
||||
<i v-if="isThumbnailAvailable && is === 'video'" class="ti ti-video icon-sub"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
fit: string;
|
||||
}>();
|
||||
|
||||
const is = computed(() => {
|
||||
if (props.file.type.startsWith('image/')) return 'image';
|
||||
if (props.file.type.startsWith('video/')) return 'video';
|
||||
if (props.file.type === 'audio/midi') return 'midi';
|
||||
if (props.file.type.startsWith('audio/')) return 'audio';
|
||||
if (props.file.type.endsWith('/csv')) return 'csv';
|
||||
if (props.file.type.endsWith('/pdf')) return 'pdf';
|
||||
if (props.file.type.startsWith('text/')) return 'textfile';
|
||||
if ([
|
||||
'application/zip',
|
||||
'application/x-cpio',
|
||||
'application/x-bzip',
|
||||
'application/x-bzip2',
|
||||
'application/java-archive',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip',
|
||||
'application/x-7z-compressed',
|
||||
].some(archiveType => archiveType === props.file.type)) return 'archive';
|
||||
return 'unknown';
|
||||
});
|
||||
|
||||
const isThumbnailAvailable = computed(() => {
|
||||
return props.file.thumbnailUrl
|
||||
? (is.value === 'image' as const || is.value === 'video')
|
||||
: false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.zdjebgpv {
|
||||
position: relative;
|
||||
display: flex;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
overflow: clip;
|
||||
|
||||
> .icon-sub {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
right: 4%;
|
||||
bottom: 4%;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
pointer-events: none;
|
||||
margin: auto;
|
||||
font-size: 32px;
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
</style>
|
58
packages/frontend/src/components/MkDriveSelectDialog.vue
Normal file
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="800"
|
||||
:height="500"
|
||||
:with-ok-button="true"
|
||||
:ok-button-disabled="(type === 'file') && (selected.length === 0)"
|
||||
@click="cancel()"
|
||||
@close="cancel()"
|
||||
@ok="ok()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
|
||||
<span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
|
||||
</template>
|
||||
<XDrive :multiple="multiple" :select="type" @change-selection="onChangeSelection" @selected="ok()"/>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDrive from '@/components/MkDrive.vue';
|
||||
import XModalWindow from '@/components/MkModalWindow.vue';
|
||||
import number from '@/filters/number';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
type?: 'file' | 'folder';
|
||||
multiple: boolean;
|
||||
}>(), {
|
||||
type: 'file',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', r?: Misskey.entities.DriveFile[]): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = ref<InstanceType<typeof XModalWindow>>();
|
||||
|
||||
const selected = ref<Misskey.entities.DriveFile[]>([]);
|
||||
|
||||
function ok() {
|
||||
emit('done', selected.value);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit('done');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onChangeSelection(files: Misskey.entities.DriveFile[]) {
|
||||
selected.value = files;
|
||||
}
|
||||
</script>
|
30
packages/frontend/src/components/MkDriveWindow.vue
Normal file
|
@ -0,0 +1,30 @@
|
|||
<template>
|
||||
<XWindow
|
||||
ref="window"
|
||||
:initial-width="800"
|
||||
:initial-height="500"
|
||||
:can-resize="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ i18n.ts.drive }}
|
||||
</template>
|
||||
<XDrive :initial-folder="initialFolder"/>
|
||||
</XWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XDrive from '@/components/MkDrive.vue';
|
||||
import XWindow from '@/components/MkWindow.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
defineProps<{
|
||||
initialFolder?: Misskey.entities.DriveFolder;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
</script>
|
36
packages/frontend/src/components/MkEmojiPicker.section.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<template>
|
||||
<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと -->
|
||||
<section>
|
||||
<header class="_acrylic" @click="shown = !shown">
|
||||
<i class="toggle ti-fw" :class="shown ? 'ti ti-chevron-down' : 'ti ti-chevron-up'"></i> <slot></slot> ({{ emojis.length }})
|
||||
</header>
|
||||
<div v-if="shown" class="body">
|
||||
<button
|
||||
v-for="emoji in emojis"
|
||||
:key="emoji"
|
||||
class="_button item"
|
||||
@click="emit('chosen', emoji, $event)"
|
||||
>
|
||||
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
emojis: string[];
|
||||
initialShown?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: string, event: MouseEvent): void;
|
||||
}>();
|
||||
|
||||
const shown = ref(!!props.initialShown);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
569
packages/frontend/src/components/MkEmojiPicker.vue
Normal file
|
@ -0,0 +1,569 @@
|
|||
<template>
|
||||
<div class="omfetrab" :class="['s' + size, 'w' + width, 'h' + height, { asDrawer }]" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : undefined }">
|
||||
<input ref="search" :value="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @input="input()" @paste.stop="paste" @keyup.enter="done()">
|
||||
<div ref="emojis" class="emojis">
|
||||
<section class="result">
|
||||
<div v-if="searchResultCustom.length > 0" class="body">
|
||||
<button
|
||||
v-for="emoji in searchResultCustom"
|
||||
:key="emoji.id"
|
||||
class="_button item"
|
||||
:title="emoji.name"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>-->
|
||||
<img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchResultUnicode.length > 0" class="body">
|
||||
<button
|
||||
v-for="emoji in searchResultUnicode"
|
||||
:key="emoji.name"
|
||||
class="_button item"
|
||||
:title="emoji.name"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkEmoji class="emoji" :emoji="emoji.char"/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="tab === 'index'" class="group index">
|
||||
<section v-if="showPinned">
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="emoji in pinned"
|
||||
:key="emoji"
|
||||
class="_button item"
|
||||
tabindex="0"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<header class="_acrylic"><i class="ti ti-clock ti-fw"></i> {{ i18n.ts.recentUsed }}</header>
|
||||
<div class="body">
|
||||
<button
|
||||
v-for="emoji in recentlyUsedEmojis"
|
||||
:key="emoji"
|
||||
class="_button item"
|
||||
@click="chosen(emoji, $event)"
|
||||
>
|
||||
<MkEmoji class="emoji" :emoji="emoji" :normal="true"/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-once class="group">
|
||||
<header class="_acrylic">{{ i18n.ts.customEmojis }}</header>
|
||||
<XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection>
|
||||
</div>
|
||||
<div v-once class="group">
|
||||
<header class="_acrylic">{{ i18n.ts.emoji }}</header>
|
||||
<XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="_button tab" :class="{ active: tab === 'index' }" @click="tab = 'index'"><i class="ti ti-asterisk ti-fw"></i></button>
|
||||
<button class="_button tab" :class="{ active: tab === 'custom' }" @click="tab = 'custom'"><i class="ti ti-mood-happy ti-fw"></i></button>
|
||||
<button class="_button tab" :class="{ active: tab === 'unicode' }" @click="tab = 'unicode'"><i class="ti ti-leaf ti-fw"></i></button>
|
||||
<button class="_button tab" :class="{ active: tab === 'tags' }" @click="tab = 'tags'"><i class="ti ti-hash ti-fw"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XSection from '@/components/MkEmojiPicker.section.vue';
|
||||
import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist';
|
||||
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
|
||||
import Ripple from '@/components/MkRipple.vue';
|
||||
import * as os from '@/os';
|
||||
import { isTouchUsing } from '@/scripts/touch';
|
||||
import { deviceKind } from '@/scripts/device-kind';
|
||||
import { emojiCategories, instance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
maxHeight?: number;
|
||||
asDrawer?: boolean;
|
||||
}>(), {
|
||||
showPinned: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: string): void;
|
||||
}>();
|
||||
|
||||
const search = ref<HTMLInputElement>();
|
||||
const emojis = ref<HTMLDivElement>();
|
||||
|
||||
const {
|
||||
reactions: pinned,
|
||||
reactionPickerSize,
|
||||
reactionPickerWidth,
|
||||
reactionPickerHeight,
|
||||
disableShowingAnimatedImages,
|
||||
recentlyUsedEmojis,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const size = computed(() => props.asReactionPicker ? reactionPickerSize.value : 1);
|
||||
const width = computed(() => props.asReactionPicker ? reactionPickerWidth.value : 3);
|
||||
const height = computed(() => props.asReactionPicker ? reactionPickerHeight.value : 2);
|
||||
const customEmojiCategories = emojiCategories;
|
||||
const customEmojis = instance.emojis;
|
||||
const q = ref<string>('');
|
||||
const searchResultCustom = ref<Misskey.entities.CustomEmoji[]>([]);
|
||||
const searchResultUnicode = ref<UnicodeEmojiDef[]>([]);
|
||||
const tab = ref<'index' | 'custom' | 'unicode' | 'tags'>('index');
|
||||
|
||||
watch(q, () => {
|
||||
if (emojis.value) emojis.value.scrollTop = 0;
|
||||
|
||||
if (q.value === '') {
|
||||
searchResultCustom.value = [];
|
||||
searchResultUnicode.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const newQ = q.value.replace(/:/g, '').toLowerCase();
|
||||
|
||||
const searchCustom = () => {
|
||||
const max = 8;
|
||||
const emojis = customEmojis;
|
||||
const matches = new Set<Misskey.entities.CustomEmoji>();
|
||||
|
||||
const exactMatch = emojis.find(emoji => emoji.name === newQ);
|
||||
if (exactMatch) matches.add(exactMatch);
|
||||
|
||||
if (newQ.includes(' ')) { // AND検索
|
||||
const keywords = newQ.split(' ');
|
||||
|
||||
// 名前にキーワードが含まれている
|
||||
for (const emoji of emojis) {
|
||||
if (keywords.every(keyword => emoji.name.includes(keyword))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
// 名前またはエイリアスにキーワードが含まれている
|
||||
for (const emoji of emojis) {
|
||||
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.aliases.some(alias => alias.includes(keyword)))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.name.startsWith(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.aliases.some(alias => alias.startsWith(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.name.includes(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.aliases.some(alias => alias.includes(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
const searchUnicode = () => {
|
||||
const max = 8;
|
||||
const emojis = emojilist;
|
||||
const matches = new Set<UnicodeEmojiDef>();
|
||||
|
||||
const exactMatch = emojis.find(emoji => emoji.name === newQ);
|
||||
if (exactMatch) matches.add(exactMatch);
|
||||
|
||||
if (newQ.includes(' ')) { // AND検索
|
||||
const keywords = newQ.split(' ');
|
||||
|
||||
// 名前にキーワードが含まれている
|
||||
for (const emoji of emojis) {
|
||||
if (keywords.every(keyword => emoji.name.includes(keyword))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
// 名前またはエイリアスにキーワードが含まれている
|
||||
for (const emoji of emojis) {
|
||||
if (keywords.every(keyword => emoji.name.includes(keyword) || emoji.keywords.some(alias => alias.includes(keyword)))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.name.startsWith(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.keywords.some(keyword => keyword.startsWith(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.name.includes(newQ)) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
if (matches.size >= max) return matches;
|
||||
|
||||
for (const emoji of emojis) {
|
||||
if (emoji.keywords.some(keyword => keyword.includes(newQ))) {
|
||||
matches.add(emoji);
|
||||
if (matches.size >= max) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
searchResultCustom.value = Array.from(searchCustom());
|
||||
searchResultUnicode.value = Array.from(searchUnicode());
|
||||
});
|
||||
|
||||
function focus() {
|
||||
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
|
||||
search.value?.focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (emojis.value) emojis.value.scrollTop = 0;
|
||||
q.value = '';
|
||||
}
|
||||
|
||||
function getKey(emoji: string | Misskey.entities.CustomEmoji | UnicodeEmojiDef): string {
|
||||
return typeof emoji === 'string' ? emoji : 'char' in emoji ? emoji.char : `:${emoji.name}:`;
|
||||
}
|
||||
|
||||
function chosen(emoji: any, ev?: MouseEvent) {
|
||||
const el = ev && (ev.currentTarget ?? ev.target) as HTMLElement | null | undefined;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = rect.left + (el.offsetWidth / 2);
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(Ripple, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
const key = getKey(emoji);
|
||||
emit('chosen', key);
|
||||
|
||||
// 最近使った絵文字更新
|
||||
if (!pinned.value.includes(key)) {
|
||||
let recents = defaultStore.state.recentlyUsedEmojis;
|
||||
recents = recents.filter((emoji: any) => emoji !== key);
|
||||
recents.unshift(key);
|
||||
defaultStore.set('recentlyUsedEmojis', recents.splice(0, 32));
|
||||
}
|
||||
}
|
||||
|
||||
function input(): void {
|
||||
// Using custom input event instead of v-model to respond immediately on
|
||||
// Android, where composition happens on all languages
|
||||
// (v-model does not update during composition)
|
||||
q.value = search.value?.value.trim() ?? '';
|
||||
}
|
||||
|
||||
function paste(event: ClipboardEvent): void {
|
||||
const pasted = event.clipboardData?.getData('text') ?? '';
|
||||
if (done(pasted)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function done(query?: string): boolean | void {
|
||||
if (query == null) query = q.value;
|
||||
if (query == null || typeof query !== 'string') return;
|
||||
|
||||
const q2 = query.replace(/:/g, '');
|
||||
const exactMatchCustom = customEmojis.find(emoji => emoji.name === q2);
|
||||
if (exactMatchCustom) {
|
||||
chosen(exactMatchCustom);
|
||||
return true;
|
||||
}
|
||||
const exactMatchUnicode = emojilist.find(emoji => emoji.char === q2 || emoji.name === q2);
|
||||
if (exactMatchUnicode) {
|
||||
chosen(exactMatchUnicode);
|
||||
return true;
|
||||
}
|
||||
if (searchResultCustom.value.length > 0) {
|
||||
chosen(searchResultCustom.value[0]);
|
||||
return true;
|
||||
}
|
||||
if (searchResultUnicode.value.length > 0) {
|
||||
chosen(searchResultUnicode.value[0]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
focus();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus,
|
||||
reset,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omfetrab {
|
||||
$pad: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.s1 {
|
||||
--eachSize: 40px;
|
||||
}
|
||||
|
||||
&.s2 {
|
||||
--eachSize: 45px;
|
||||
}
|
||||
|
||||
&.s3 {
|
||||
--eachSize: 50px;
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w2 {
|
||||
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w3 {
|
||||
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w4 {
|
||||
width: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.w5 {
|
||||
width: calc((var(--eachSize) * 9) + (#{$pad} * 2));
|
||||
--columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h4 {
|
||||
height: calc((var(--eachSize) * 10) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.asDrawer {
|
||||
width: 100% !important;
|
||||
|
||||
> .emojis {
|
||||
::v-deep(section) {
|
||||
> header {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--columns);
|
||||
font-size: 30px;
|
||||
|
||||
> .item {
|
||||
aspect-ratio: 1 / 1;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .search {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1em;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
|
||||
&:not(.filled) {
|
||||
order: 1;
|
||||
z-index: 2;
|
||||
box-shadow: 0px -1px 0 0px var(--divider);
|
||||
}
|
||||
}
|
||||
|
||||
> .tabs {
|
||||
display: flex;
|
||||
display: none;
|
||||
|
||||
> .tab {
|
||||
flex: 1;
|
||||
height: 38px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
|
||||
&.active {
|
||||
border-top: solid 1px var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .group {
|
||||
&:not(.index) {
|
||||
padding: 4px 0 8px 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
|
||||
> header {
|
||||
/*position: sticky;
|
||||
top: 0;
|
||||
left: 0;*/
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
z-index: 2;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep(section) {
|
||||
> header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
z-index: 1;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
> .body {
|
||||
position: relative;
|
||||
padding: $pad;
|
||||
|
||||
> .item {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: var(--eachSize);
|
||||
height: var(--eachSize);
|
||||
contain: strict;
|
||||
border-radius: 4px;
|
||||
font-size: 24px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
|
||||
}
|
||||
|
||||
> .emoji {
|
||||
height: 1.25em;
|
||||
vertical-align: -.25em;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.result {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
73
packages/frontend/src/components/MkEmojiPickerDialog.vue
Normal file
|
@ -0,0 +1,73 @@
|
|||
<template>
|
||||
<MkModal
|
||||
ref="modal"
|
||||
v-slot="{ type, maxHeight }"
|
||||
:z-priority="'middle'"
|
||||
:prefer-type="asReactionPicker && defaultStore.state.reactionPickerUseDrawerForMobile === false ? 'popup' : 'auto'"
|
||||
:transparent-bg="true"
|
||||
:manual-showing="manualShowing"
|
||||
:src="src"
|
||||
@click="modal?.close()"
|
||||
@opening="opening"
|
||||
@close="emit('close')"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<MkEmojiPicker
|
||||
ref="picker"
|
||||
class="ryghynhb _popup _shadow"
|
||||
:class="{ drawer: type === 'drawer' }"
|
||||
:show-pinned="showPinned"
|
||||
:as-reaction-picker="asReactionPicker"
|
||||
:as-drawer="type === 'drawer'"
|
||||
:max-height="maxHeight"
|
||||
@chosen="chosen"
|
||||
/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
manualShowing?: boolean | null;
|
||||
src?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
}>(), {
|
||||
manualShowing: null,
|
||||
showPinned: true,
|
||||
asReactionPicker: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: any): void;
|
||||
(ev: 'close'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof MkModal>>();
|
||||
const picker = ref<InstanceType<typeof MkEmojiPicker>>();
|
||||
|
||||
function chosen(emoji: any) {
|
||||
emit('done', emoji);
|
||||
modal.value?.close();
|
||||
}
|
||||
|
||||
function opening() {
|
||||
picker.value?.reset();
|
||||
picker.value?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ryghynhb {
|
||||
&.drawer {
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
180
packages/frontend/src/components/MkEmojiPickerWindow.vue
Normal file
|
@ -0,0 +1,180 @@
|
|||
<template>
|
||||
<MkWindow ref="window"
|
||||
:initial-width="null"
|
||||
:initial-height="null"
|
||||
:can-resize="false"
|
||||
:mini="true"
|
||||
:front="true"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<MkEmojiPicker :show-pinned="showPinned" :as-reaction-picker="asReactionPicker" @chosen="chosen"/>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import MkEmojiPicker from '@/components/MkEmojiPicker.vue';
|
||||
|
||||
withDefaults(defineProps<{
|
||||
src?: HTMLElement;
|
||||
showPinned?: boolean;
|
||||
asReactionPicker?: boolean;
|
||||
}>(), {
|
||||
showPinned: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'chosen', v: any): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
function chosen(emoji: any) {
|
||||
emit('chosen', emoji);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.omfetrab {
|
||||
$pad: 8px;
|
||||
--eachSize: 40px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
contain: content;
|
||||
|
||||
&.big {
|
||||
--eachSize: 44px;
|
||||
}
|
||||
|
||||
&.w1 {
|
||||
width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.w2 {
|
||||
width: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.w3 {
|
||||
width: calc((var(--eachSize) * 7) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h1 {
|
||||
--height: calc((var(--eachSize) * 4) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h2 {
|
||||
--height: calc((var(--eachSize) * 6) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
&.h3 {
|
||||
--height: calc((var(--eachSize) * 8) + (#{$pad} * 2));
|
||||
}
|
||||
|
||||
> .search {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
font-size: 1em;
|
||||
outline: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
|
||||
&:not(.filled) {
|
||||
order: 1;
|
||||
z-index: 2;
|
||||
box-shadow: 0px -1px 0 0px var(--divider);
|
||||
}
|
||||
}
|
||||
|
||||
> .emojis {
|
||||
height: var(--height);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> .index {
|
||||
min-height: var(--height);
|
||||
position: relative;
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
|
||||
> .arrow {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 16px 0;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
> header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> div {
|
||||
padding: $pad;
|
||||
|
||||
> button {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: var(--eachSize);
|
||||
height: var(--eachSize);
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: solid 2px var(--focus);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15);
|
||||
}
|
||||
|
||||
> * {
|
||||
font-size: 24px;
|
||||
height: 1.25em;
|
||||
vertical-align: -.25em;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.result {
|
||||
border-bottom: solid 0.5px var(--divider);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unicode {
|
||||
min-height: 384px;
|
||||
}
|
||||
|
||||
&.custom {
|
||||
min-height: 64px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
22
packages/frontend/src/components/MkFeaturedPhotos.vue
Normal file
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div v-if="meta" class="xfbouadm" :style="{ backgroundImage: `url(${ meta.backgroundImageUrl })` }"></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
|
||||
const meta = ref<Misskey.entities.DetailedInstanceMetadata>();
|
||||
|
||||
os.api('meta', { detail: true }).then(gotMeta => {
|
||||
meta.value = gotMeta;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xfbouadm {
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
175
packages/frontend/src/components/MkFileCaptionEditWindow.vue
Normal file
|
@ -0,0 +1,175 @@
|
|||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="400"
|
||||
:height="450"
|
||||
:with-ok-button="true"
|
||||
:ok-button-disabled="false"
|
||||
@ok="ok()"
|
||||
@close="dialog.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.describeFile }}</template>
|
||||
<div>
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain" style="height: 100px;"/>
|
||||
<MkTextarea v-model="caption" autofocus :placeholder="i18n.ts.inputNewDescription">
|
||||
<template #label>{{ i18n.ts.caption }}</template>
|
||||
</MkTextarea>
|
||||
</div>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import XModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
default: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', v: string): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const dialog = $ref<InstanceType<typeof XModalWindow>>();
|
||||
|
||||
let caption = $ref(props.default);
|
||||
|
||||
async function ok() {
|
||||
emit('done', caption);
|
||||
dialog.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
overflow: scroll;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
.top-caption {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
}
|
||||
.fullwidth {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
.mk-dialog {
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
min-width: 320px;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
margin: auto;
|
||||
|
||||
> header {
|
||||
margin: 0 0 8px 0;
|
||||
position: relative;
|
||||
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
> .text-count {
|
||||
opacity: 0.7;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> .buttons {
|
||||
margin-top: 16px;
|
||||
|
||||
> * {
|
||||
margin: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
> textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
padding: 0 24px;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font-family: inherit;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
min-height: 90px;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
.hdrwpsaf {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
> header,
|
||||
> footer {
|
||||
align-self: center;
|
||||
display: inline-block;
|
||||
padding: 6px 9px;
|
||||
font-size: 90%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
> img {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
cursor: zoom-out;
|
||||
image-orientation: from-image;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
|
||||
> span + span {
|
||||
margin-left: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
border-left: solid 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
117
packages/frontend/src/components/MkFileListForAdmin.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div>
|
||||
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }">
|
||||
<MkA
|
||||
v-for="file in items"
|
||||
:key="file.id"
|
||||
v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`"
|
||||
:to="`/admin/file/${file.id}`"
|
||||
class="file _button"
|
||||
>
|
||||
<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
|
||||
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
|
||||
<div v-if="viewMode === 'list'" class="body">
|
||||
<div>
|
||||
<small style="opacity: 0.7;">{{ file.name }}</small>
|
||||
</div>
|
||||
<div>
|
||||
<MkAcct v-if="file.user" :user="file.user"/>
|
||||
<div v-else>{{ i18n.ts.system }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="margin-right: 1em;">{{ file.type }}</span>
|
||||
<span>{{ bytes(file.size) }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</MkA>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as Acct from 'misskey-js/built/acct';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import bytes from '@/filters/bytes';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = defineProps<{
|
||||
pagination: any;
|
||||
viewMode: 'grid' | 'list';
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@keyframes sensitive-blink {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.urempief {
|
||||
margin-top: var(--margin);
|
||||
|
||||
&.list {
|
||||
> .file {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
text-align: left;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
> .body {
|
||||
margin-left: 0.3em;
|
||||
padding: 8px;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin: var(--margin) 0;
|
||||
|
||||
> .file {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> .sensitive-label {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
padding: 2px 4px;
|
||||
background: #ff0000bf;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 85%;
|
||||
animation: sensitive-blink 1s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
159
packages/frontend/src/components/MkFolder.vue
Normal file
|
@ -0,0 +1,159 @@
|
|||
<template>
|
||||
<div v-size="{ max: [500] }" class="ssazuxis">
|
||||
<header class="_button" :style="{ background: bg }" @click="showBody = !showBody">
|
||||
<div class="title"><slot name="header"></slot></div>
|
||||
<div class="divider"></div>
|
||||
<button class="_button">
|
||||
<template v-if="showBody"><i class="ti ti-chevron-up"></i></template>
|
||||
<template v-else><i class="ti ti-chevron-down"></i></template>
|
||||
</button>
|
||||
</header>
|
||||
<transition
|
||||
:name="$store.state.animation ? 'folder-toggle' : ''"
|
||||
@enter="enter"
|
||||
@after-enter="afterEnter"
|
||||
@leave="leave"
|
||||
@after-leave="afterLeave"
|
||||
>
|
||||
<div v-show="showBody">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
const localStoragePrefix = 'ui:folder:';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
persistKey: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bg: null,
|
||||
showBody: (this.persistKey && localStorage.getItem(localStoragePrefix + this.persistKey)) ? localStorage.getItem(localStoragePrefix + this.persistKey) === 't' : this.expanded,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
showBody() {
|
||||
if (this.persistKey) {
|
||||
localStorage.setItem(localStoragePrefix + this.persistKey, this.showBody ? 't' : 'f');
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
function getParentBg(el: Element | null): string {
|
||||
if (el == null || el.tagName === 'BODY') return 'var(--bg)';
|
||||
const bg = el.style.background || el.style.backgroundColor;
|
||||
if (bg) {
|
||||
return bg;
|
||||
} else {
|
||||
return getParentBg(el.parentElement);
|
||||
}
|
||||
}
|
||||
const rawBg = getParentBg(this.$el);
|
||||
const bg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg);
|
||||
bg.setAlpha(0.85);
|
||||
this.bg = bg.toRgbString();
|
||||
},
|
||||
methods: {
|
||||
toggleContent(show: boolean) {
|
||||
this.showBody = show;
|
||||
},
|
||||
|
||||
enter(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = 0;
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = elementHeight + 'px';
|
||||
},
|
||||
afterEnter(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
leave(el) {
|
||||
const elementHeight = el.getBoundingClientRect().height;
|
||||
el.style.height = elementHeight + 'px';
|
||||
el.offsetHeight; // reflow
|
||||
el.style.height = 0;
|
||||
},
|
||||
afterLeave(el) {
|
||||
el.style.height = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.folder-toggle-enter-active, .folder-toggle-leave-active {
|
||||
overflow-y: hidden;
|
||||
transition: opacity 0.5s, height 0.5s !important;
|
||||
}
|
||||
.folder-toggle-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
.folder-toggle-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ssazuxis {
|
||||
position: relative;
|
||||
|
||||
> header {
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
top: var(--stickyTop, 0px);
|
||||
padding: var(--x-padding);
|
||||
-webkit-backdrop-filter: var(--blur, blur(8px));
|
||||
backdrop-filter: var(--blur, blur(20px));
|
||||
|
||||
> .title {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
margin: 0;
|
||||
padding: 12px 16px 12px 0;
|
||||
|
||||
> i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
> .divider {
|
||||
flex: 1;
|
||||
margin: auto;
|
||||
height: 1px;
|
||||
background: var(--divider);
|
||||
}
|
||||
|
||||
> button {
|
||||
padding: 12px 0 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&.max-width_500px {
|
||||
> header {
|
||||
> .title {
|
||||
padding: 8px 10px 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
187
packages/frontend/src/components/MkFollowButton.vue
Normal file
|
@ -0,0 +1,187 @@
|
|||
<template>
|
||||
<button
|
||||
class="kpoogebi _button"
|
||||
:class="{ wait, active: isFollowing || hasPendingFollowRequestFromYou, full, large }"
|
||||
:disabled="wait"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="!wait">
|
||||
<template v-if="hasPendingFollowRequestFromYou && user.isLocked">
|
||||
<span v-if="full">{{ i18n.ts.followRequestPending }}</span><i class="ti ti-hourglass-empty"></i>
|
||||
</template>
|
||||
<template v-else-if="hasPendingFollowRequestFromYou && !user.isLocked">
|
||||
<!-- つまりリモートフォローの場合。 -->
|
||||
<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
|
||||
</template>
|
||||
<template v-else-if="isFollowing">
|
||||
<span v-if="full">{{ i18n.ts.unfollow }}</span><i class="ti ti-minus"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && user.isLocked">
|
||||
<span v-if="full">{{ i18n.ts.followRequest }}</span><i class="ti ti-plus"></i>
|
||||
</template>
|
||||
<template v-else-if="!isFollowing && !user.isLocked">
|
||||
<span v-if="full">{{ i18n.ts.follow }}</span><i class="ti ti-plus"></i>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-if="full">{{ i18n.ts.processing }}</span><MkLoading :em="true" :colored="false"/>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import * as Misskey from 'misskey-js';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
user: Misskey.entities.UserDetailed,
|
||||
full?: boolean,
|
||||
large?: boolean,
|
||||
}>(), {
|
||||
full: false,
|
||||
large: false,
|
||||
});
|
||||
|
||||
let isFollowing = $ref(props.user.isFollowing);
|
||||
let hasPendingFollowRequestFromYou = $ref(props.user.hasPendingFollowRequestFromYou);
|
||||
let wait = $ref(false);
|
||||
const connection = stream.useChannel('main');
|
||||
|
||||
if (props.user.isFollowing == null) {
|
||||
os.api('users/show', {
|
||||
userId: props.user.id,
|
||||
})
|
||||
.then(onFollowChange);
|
||||
}
|
||||
|
||||
function onFollowChange(user: Misskey.entities.UserDetailed) {
|
||||
if (user.id === props.user.id) {
|
||||
isFollowing = user.isFollowing;
|
||||
hasPendingFollowRequestFromYou = user.hasPendingFollowRequestFromYou;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClick() {
|
||||
wait = true;
|
||||
|
||||
try {
|
||||
if (isFollowing) {
|
||||
const { canceled } = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.t('unfollowConfirm', { name: props.user.name || props.user.username }),
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
await os.api('following/delete', {
|
||||
userId: props.user.id,
|
||||
});
|
||||
} else {
|
||||
if (hasPendingFollowRequestFromYou) {
|
||||
await os.api('following/requests/cancel', {
|
||||
userId: props.user.id,
|
||||
});
|
||||
hasPendingFollowRequestFromYou = false;
|
||||
} else {
|
||||
await os.api('following/create', {
|
||||
userId: props.user.id,
|
||||
});
|
||||
hasPendingFollowRequestFromYou = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
wait = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
connection.on('follow', onFollowChange);
|
||||
connection.on('unfollow', onFollowChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
connection.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.kpoogebi {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: solid 1px var(--accent);
|
||||
padding: 0;
|
||||
height: 31px;
|
||||
font-size: 16px;
|
||||
border-radius: 32px;
|
||||
background: #fff;
|
||||
|
||||
&.full {
|
||||
padding: 0 8px 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 16px;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 16px;
|
||||
}
|
||||
|
||||
&:not(.full) {
|
||||
width: 31px;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
&:after {
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
left: -5px;
|
||||
border: 2px solid var(--focus);
|
||||
border-radius: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
//background: mix($primary, #fff, 20);
|
||||
}
|
||||
|
||||
&:active {
|
||||
//background: mix($primary, #fff, 40);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
|
||||
&:hover {
|
||||
background: var(--accentLighten);
|
||||
border-color: var(--accentLighten);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accentDarken);
|
||||
border-color: var(--accentDarken);
|
||||
}
|
||||
}
|
||||
|
||||
&.wait {
|
||||
cursor: wait !important;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> span {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
80
packages/frontend/src/components/MkForgotPassword.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<XModalWindow ref="dialog"
|
||||
:width="370"
|
||||
:height="400"
|
||||
@close="dialog.close()"
|
||||
@closed="emit('closed')"
|
||||
>
|
||||
<template #header>{{ i18n.ts.forgotPassword }}</template>
|
||||
|
||||
<form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit">
|
||||
<div class="main _formRoot">
|
||||
<MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required>
|
||||
<template #label>{{ i18n.ts.username }}</template>
|
||||
<template #prefix>@</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="email" class="_formBlock" type="email" :spellcheck="false" required>
|
||||
<template #label>{{ i18n.ts.emailAddress }}</template>
|
||||
<template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template>
|
||||
</MkInput>
|
||||
|
||||
<MkButton class="_formBlock" type="submit" :disabled="processing" primary style="margin: 0 auto;">{{ i18n.ts.send }}</MkButton>
|
||||
</div>
|
||||
<div class="sub">
|
||||
<MkA to="/about" class="_link">{{ i18n.ts._forgotPassword.ifNoEmail }}</MkA>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="bafecedb">
|
||||
{{ i18n.ts._forgotPassword.contactAdmin }}
|
||||
</div>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import XModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/form/input.vue';
|
||||
import * as os from '@/os';
|
||||
import { instance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
let dialog: InstanceType<typeof XModalWindow> = $ref();
|
||||
|
||||
let username = $ref('');
|
||||
let email = $ref('');
|
||||
let processing = $ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
processing = true;
|
||||
await os.apiWithDialog('request-reset-password', {
|
||||
username,
|
||||
email,
|
||||
});
|
||||
emit('done');
|
||||
dialog.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bafeceda {
|
||||
> .main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
> .sub {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.bafecedb {
|
||||
padding: 24px;
|
||||
}
|
||||
</style>
|
127
packages/frontend/src/components/MkFormDialog.vue
Normal file
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<XModalWindow
|
||||
ref="dialog"
|
||||
:width="450"
|
||||
:can-close="false"
|
||||
:with-ok-button="true"
|
||||
:ok-button-disabled="false"
|
||||
@click="cancel()"
|
||||
@ok="ok()"
|
||||
@close="cancel()"
|
||||
@closed="$emit('closed')"
|
||||
>
|
||||
<template #header>
|
||||
{{ title }}
|
||||
</template>
|
||||
|
||||
<MkSpacer :margin-min="20" :margin-max="32">
|
||||
<div class="xkpnjxcv _formRoot">
|
||||
<template v-for="item in Object.keys(form).filter(item => !form[item].hidden)">
|
||||
<FormInput v-if="form[item].type === 'number'" v-model="values[item]" type="number" :step="form[item].step || 1" class="_formBlock">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</FormInput>
|
||||
<FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model="values[item]" type="text" class="_formBlock">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</FormInput>
|
||||
<FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model="values[item]" class="_formBlock">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</FormTextarea>
|
||||
<FormSwitch v-else-if="form[item].type === 'boolean'" v-model="values[item]" class="_formBlock">
|
||||
<span v-text="form[item].label || item"></span>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</FormSwitch>
|
||||
<FormSelect v-else-if="form[item].type === 'enum'" v-model="values[item]" class="_formBlock">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</FormSelect>
|
||||
<FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
|
||||
</FormRadios>
|
||||
<FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock">
|
||||
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
|
||||
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
|
||||
</FormRange>
|
||||
<MkButton v-else-if="form[item].type === 'button'" class="_formBlock" @click="form[item].action($event, values)">
|
||||
<span v-text="form[item].content || item"></span>
|
||||
</MkButton>
|
||||
</template>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</XModalWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import FormInput from './form/input.vue';
|
||||
import FormTextarea from './form/textarea.vue';
|
||||
import FormSwitch from './form/switch.vue';
|
||||
import FormSelect from './form/select.vue';
|
||||
import FormRange from './form/range.vue';
|
||||
import MkButton from './MkButton.vue';
|
||||
import FormRadios from './form/radios.vue';
|
||||
import XModalWindow from '@/components/MkModalWindow.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XModalWindow,
|
||||
FormInput,
|
||||
FormTextarea,
|
||||
FormSwitch,
|
||||
FormSelect,
|
||||
FormRange,
|
||||
MkButton,
|
||||
FormRadios,
|
||||
},
|
||||
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
form: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
emits: ['done'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
values: {},
|
||||
};
|
||||
},
|
||||
|
||||
created() {
|
||||
for (const item in this.form) {
|
||||
this.values[item] = this.form[item].default ?? null;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
ok() {
|
||||
this.$emit('done', {
|
||||
result: this.values,
|
||||
});
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
|
||||
cancel() {
|
||||
this.$emit('done', {
|
||||
canceled: true,
|
||||
});
|
||||
this.$refs.dialog.close();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xkpnjxcv {
|
||||
|
||||
}
|
||||
</style>
|
24
packages/frontend/src/components/MkFormula.vue
Normal file
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<XFormula :formula="formula" :block="block"/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, defineAsyncComponent } from 'vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
XFormula: defineAsyncComponent(() => import('@/components/MkFormulaCore.vue')),
|
||||
},
|
||||
props: {
|
||||
formula: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
34
packages/frontend/src/components/MkFormulaCore.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div v-if="block" v-html="compiledFormula"></div>
|
||||
<span v-else v-html="compiledFormula"></span>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import katex from 'katex';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
formula: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
block: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
compiledFormula(): any {
|
||||
return katex.renderToString(this.formula, {
|
||||
throwOnError: false,
|
||||
} as any);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "../../node_modules/katex/dist/katex.min.css";
|
||||
</style>
|
115
packages/frontend/src/components/MkGalleryPostPreview.vue
Normal file
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<MkA :to="`/gallery/${post.id}`" class="ttasepnz _panel" tabindex="-1">
|
||||
<div class="thumbnail">
|
||||
<ImgWithBlurhash class="img" :src="post.files[0].thumbnailUrl" :hash="post.files[0].blurhash"/>
|
||||
</div>
|
||||
<article>
|
||||
<header>
|
||||
<MkAvatar :user="post.user" class="avatar"/>
|
||||
</header>
|
||||
<footer>
|
||||
<span class="title">{{ post.title }}</span>
|
||||
</footer>
|
||||
</article>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { userName } from '@/filters/user';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
post: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ttasepnz {
|
||||
display: block;
|
||||
position: relative;
|
||||
height: 200px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
|
||||
> .thumbnail {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
> article {
|
||||
> footer {
|
||||
&:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
transition: all 0.5s ease;
|
||||
|
||||
> .img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
|
||||
> article {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
|
||||
> .avatar {
|
||||
margin-left: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
> footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 8px #000;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.4), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
> .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
51
packages/frontend/src/components/MkGoogle.vue
Normal file
|
@ -0,0 +1,51 @@
|
|||
<template>
|
||||
<div class="mk-google">
|
||||
<input v-model="query" type="search" :placeholder="q">
|
||||
<button @click="search"><i class="ti ti-search"></i> {{ $ts.searchByGoogle }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
q: string;
|
||||
}>();
|
||||
|
||||
const query = ref(props.q);
|
||||
|
||||
const search = () => {
|
||||
window.open(`https://www.google.com/search?q=${query.value}`, '_blank');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-google {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
|
||||
> input {
|
||||
flex-shrink: 1;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
border: solid 1px var(--divider);
|
||||
border-radius: 4px 0 0 4px;
|
||||
-webkit-appearance: textfield;
|
||||
}
|
||||
|
||||
> button {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
border: solid 1px var(--divider);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
|
||||
&:active {
|
||||
box-shadow: 0 2px 4px rgba(#000, 0.15) inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
77
packages/frontend/src/components/MkImageViewer.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<MkModal ref="modal" :z-priority="'middle'" @click="modal.close()" @closed="emit('closed')">
|
||||
<div class="xubzgfga">
|
||||
<header>{{ image.name }}</header>
|
||||
<img :src="image.url" :alt="image.comment" :title="image.comment" @click="modal.close()"/>
|
||||
<footer>
|
||||
<span>{{ image.type }}</span>
|
||||
<span>{{ bytes(image.size) }}</span>
|
||||
<span v-if="image.properties && image.properties.width">{{ number(image.properties.width) }}px × {{ number(image.properties.height) }}px</span>
|
||||
</footer>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import bytes from '@/filters/bytes';
|
||||
import number from '@/filters/number';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
image: misskey.entities.DriveFile;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const modal = $ref<InstanceType<typeof MkModal>>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xubzgfga {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
> header,
|
||||
> footer {
|
||||
align-self: center;
|
||||
display: inline-block;
|
||||
padding: 6px 9px;
|
||||
font-size: 90%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
> img {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
cursor: zoom-out;
|
||||
image-orientation: from-image;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 8px;
|
||||
opacity: 0.8;
|
||||
|
||||
> span + span {
|
||||
margin-left: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
border-left: solid 1px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
76
packages/frontend/src/components/MkImgWithBlurhash.vue
Normal file
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<div class="xubzgfgb" :class="{ cover }" :title="title">
|
||||
<canvas v-if="!loaded" ref="canvas" :width="size" :height="size" :title="title"/>
|
||||
<img v-if="src" :src="src" :title="title" :alt="alt" @load="onLoad"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { decode } from 'blurhash';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src?: string | null;
|
||||
hash?: string;
|
||||
alt?: string;
|
||||
title?: string | null;
|
||||
size?: number;
|
||||
cover?: boolean;
|
||||
}>(), {
|
||||
src: null,
|
||||
alt: '',
|
||||
title: null,
|
||||
size: 64,
|
||||
cover: true,
|
||||
});
|
||||
|
||||
const canvas = $ref<HTMLCanvasElement>();
|
||||
let loaded = $ref(false);
|
||||
|
||||
function draw() {
|
||||
if (props.hash == null) return;
|
||||
const pixels = decode(props.hash, props.size, props.size);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const imageData = ctx!.createImageData(props.size, props.size);
|
||||
imageData.data.set(pixels);
|
||||
ctx!.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function onLoad() {
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
draw();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xubzgfgb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> canvas,
|
||||
> img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> canvas {
|
||||
position: absolute;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
> img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
&.cover {
|
||||
> img {
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
34
packages/frontend/src/components/MkInfo.vue
Normal file
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<div class="fpezltsf" :class="{ warn }">
|
||||
<i v-if="warn" class="ti ti-alert-triangle"></i>
|
||||
<i v-else class="ti ti-info-circle"></i>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
warn?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fpezltsf {
|
||||
padding: 12px 14px;
|
||||
font-size: 90%;
|
||||
background: var(--infoBg);
|
||||
color: var(--infoFg);
|
||||
border-radius: var(--radius);
|
||||
|
||||
&.warn {
|
||||
background: var(--infoWarnBg);
|
||||
color: var(--infoWarnFg);
|
||||
}
|
||||
|
||||
> i {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
105
packages/frontend/src/components/MkInstanceCardMini.vue
Normal file
|
@ -0,0 +1,105 @@
|
|||
<template>
|
||||
<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]">
|
||||
<img class="icon" :src="getInstanceIcon(instance)" alt=""/>
|
||||
<div class="body">
|
||||
<span class="host">{{ instance.name ?? instance.host }}</span>
|
||||
<span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span>
|
||||
</div>
|
||||
<MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkMiniChart from '@/components/MkMiniChart.vue';
|
||||
import * as os from '@/os';
|
||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||
|
||||
const props = defineProps<{
|
||||
instance: misskey.entities.Instance;
|
||||
}>();
|
||||
|
||||
let chartValues = $ref<number[] | null>(null);
|
||||
|
||||
os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => {
|
||||
// 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く
|
||||
res.requests.received.splice(0, 1);
|
||||
chartValues = res.requests.received;
|
||||
});
|
||||
|
||||
function getInstanceIcon(instance): string {
|
||||
return getProxiedImageUrlNullable(instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.faviconUrl, 'preview') ?? '/client-assets/dummy.png';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
$bodyTitleHieght: 18px;
|
||||
$bodyInfoHieght: 16px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: var(--panel);
|
||||
border-radius: 8px;
|
||||
|
||||
> :global(.icon) {
|
||||
display: block;
|
||||
width: ($bodyTitleHieght + $bodyInfoHieght);
|
||||
height: ($bodyTitleHieght + $bodyInfoHieght);
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
> :global(.body) {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 0.9em;
|
||||
color: var(--fg);
|
||||
padding-right: 8px;
|
||||
|
||||
> :global(.host) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: $bodyTitleHieght;
|
||||
}
|
||||
|
||||
> :global(.sub) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 80%;
|
||||
opacity: 0.7;
|
||||
line-height: $bodyInfoHieght;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
> :global(.chart) {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
&:global(.yellow) {
|
||||
--c: rgb(255 196 0 / 15%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
&:global(.red) {
|
||||
--c: rgb(255 0 0 / 15%);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
&:global(.gray) {
|
||||
--c: var(--bg);
|
||||
background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
255
packages/frontend/src/components/MkInstanceStats.vue
Normal file
|
@ -0,0 +1,255 @@
|
|||
<template>
|
||||
<div :class="$style.root">
|
||||
<MkFolder class="item">
|
||||
<template #header>Chart</template>
|
||||
<div :class="$style.chart">
|
||||
<div class="selects">
|
||||
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
|
||||
<optgroup :label="i18n.ts.federation">
|
||||
<option value="federation">{{ i18n.ts._charts.federation }}</option>
|
||||
<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts.users">
|
||||
<option value="users">{{ i18n.ts._charts.usersIncDec }}</option>
|
||||
<option value="users-total">{{ i18n.ts._charts.usersTotal }}</option>
|
||||
<option value="active-users">{{ i18n.ts._charts.activeUsers }}</option>
|
||||
</optgroup>
|
||||
<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 value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
|
||||
</optgroup>
|
||||
<optgroup :label="i18n.ts.drive">
|
||||
<option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option>
|
||||
<option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option>
|
||||
</optgroup>
|
||||
</MkSelect>
|
||||
<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
|
||||
<option value="hour">{{ i18n.ts.perHour }}</option>
|
||||
<option value="day">{{ i18n.ts.perDay }}</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
<div class="chart _panel">
|
||||
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder class="item">
|
||||
<template #header>Active users heatmap</template>
|
||||
<div class="_panel" :class="$style.heatmap">
|
||||
<MkActiveUsersHeatmap/>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder class="item">
|
||||
<template #header>Federation</template>
|
||||
<div :class="$style.federation">
|
||||
<div class="pies">
|
||||
<div class="sub">
|
||||
<div class="title">Sub</div>
|
||||
<canvas ref="subDoughnutEl"></canvas>
|
||||
</div>
|
||||
<div class="pub">
|
||||
<div class="title">Pub</div>
|
||||
<canvas ref="pubDoughnutEl"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import {
|
||||
Chart,
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
DoughnutController,
|
||||
} from 'chart.js';
|
||||
import MkSelect from '@/components/form/select.vue';
|
||||
import MkChart from '@/components/MkChart.vue';
|
||||
import { useChartTooltip } from '@/scripts/use-chart-tooltip';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkActiveUsersHeatmap from '@/components/MkActiveUsersHeatmap.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
|
||||
Chart.register(
|
||||
ArcElement,
|
||||
LineElement,
|
||||
BarElement,
|
||||
PointElement,
|
||||
BarController,
|
||||
LineController,
|
||||
DoughnutController,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Legend,
|
||||
Title,
|
||||
Tooltip,
|
||||
SubTitle,
|
||||
Filler,
|
||||
);
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
chartLimit?: number;
|
||||
detailed?: boolean;
|
||||
}>(), {
|
||||
chartLimit: 90,
|
||||
});
|
||||
|
||||
const chartSpan = $ref<'hour' | 'day'>('hour');
|
||||
const chartSrc = $ref('active-users');
|
||||
let subDoughnutEl = $ref<HTMLCanvasElement>();
|
||||
let pubDoughnutEl = $ref<HTMLCanvasElement>();
|
||||
|
||||
const { handler: externalTooltipHandler1 } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
const { handler: externalTooltipHandler2 } = useChartTooltip({
|
||||
position: 'middle',
|
||||
});
|
||||
|
||||
function createDoughnut(chartEl, tooltip, data) {
|
||||
const chartInstance = new Chart(chartEl, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.map(x => x.name),
|
||||
datasets: [{
|
||||
backgroundColor: data.map(x => x.color),
|
||||
borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'),
|
||||
borderWidth: 2,
|
||||
hoverOffset: 0,
|
||||
data: data.map(x => x.value),
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
},
|
||||
},
|
||||
onClick: (ev) => {
|
||||
const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0];
|
||||
if (hit && data[hit.index].onClick) {
|
||||
data[hit.index].onClick();
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
mode: 'index',
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
external: tooltip,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return chartInstance;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
os.apiGet('federation/stats', { limit: 30 }).then(fedStats => {
|
||||
createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({
|
||||
name: x.host,
|
||||
color: x.themeColor,
|
||||
value: x.followersCount,
|
||||
onClick: () => {
|
||||
os.pageWindow(`/instance-info/${x.host}`);
|
||||
},
|
||||
})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }]));
|
||||
|
||||
createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({
|
||||
name: x.host,
|
||||
color: x.themeColor,
|
||||
value: x.followingCount,
|
||||
onClick: () => {
|
||||
os.pageWindow(`/instance-info/${x.host}`);
|
||||
},
|
||||
})).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }]));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
&:global {
|
||||
> .item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
&:global {
|
||||
> .selects {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
> .chart {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.heatmap {
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.federation {
|
||||
&:global {
|
||||
> .pies {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
> .sub, > .pub {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
background: var(--panel);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
max-height: 300px;
|
||||
|
||||
> .title {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
80
packages/frontend/src/components/MkInstanceTicker.vue
Normal file
|
@ -0,0 +1,80 @@
|
|||
<template>
|
||||
<div :class="$style.root" :style="bg">
|
||||
<img v-if="faviconUrl" :class="$style.icon" :src="faviconUrl"/>
|
||||
<div :class="$style.name">{{ instance.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import { instanceName } from '@/config';
|
||||
import { instance as Instance } from '@/instance';
|
||||
import { getProxiedImageUrlNullable } from '@/scripts/media-proxy';
|
||||
|
||||
const props = defineProps<{
|
||||
instance?: {
|
||||
faviconUrl?: string
|
||||
name: string
|
||||
themeColor?: string
|
||||
}
|
||||
}>();
|
||||
|
||||
// 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 faviconUrl = $computed(() => props.instance ? getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : getProxiedImageUrlNullable(Instance.iconUrl, 'preview') ?? getProxiedImageUrlNullable(Instance.faviconUrl, 'preview') ?? '/favicon.ico');
|
||||
|
||||
const themeColor = instance.themeColor ?? '#777777';
|
||||
|
||||
const bg = {
|
||||
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
$height: 2ex;
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: $height;
|
||||
border-radius: 4px 0 0 4px;
|
||||
overflow: clip;
|
||||
color: #fff;
|
||||
text-shadow: /* .866 ≈ sin(60deg) */
|
||||
1px 0 1px #000,
|
||||
.866px .5px 1px #000,
|
||||
.5px .866px 1px #000,
|
||||
0 1px 1px #000,
|
||||
-.5px .866px 1px #000,
|
||||
-.866px .5px 1px #000,
|
||||
-1px 0 1px #000,
|
||||
-.866px -.5px 1px #000,
|
||||
-.5px -.866px 1px #000,
|
||||
0 -1px 1px #000,
|
||||
.5px -.866px 1px #000,
|
||||
.866px -.5px 1px #000;
|
||||
mask-image: linear-gradient(90deg,
|
||||
rgb(0,0,0),
|
||||
rgb(0,0,0) calc(100% - 16px),
|
||||
rgba(0,0,0,0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: $height;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-left: 4px;
|
||||
line-height: 1;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
58
packages/frontend/src/components/MkKeyValue.vue
Normal file
|
@ -0,0 +1,58 @@
|
|||
<template>
|
||||
<div class="alqyeyti" :class="{ oneline }">
|
||||
<div class="key">
|
||||
<slot name="key"></slot>
|
||||
</div>
|
||||
<div class="value">
|
||||
<slot name="value"></slot>
|
||||
<button v-if="copy" v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copy_"><i class="ti ti-copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
copy?: string | null;
|
||||
oneline?: boolean;
|
||||
}>(), {
|
||||
copy: null,
|
||||
oneline: false,
|
||||
});
|
||||
|
||||
const copy_ = () => {
|
||||
copyToClipboard(props.copy);
|
||||
os.success();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.alqyeyti {
|
||||
> .key {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 0.25em 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&.oneline {
|
||||
display: flex;
|
||||
|
||||
> .key {
|
||||
width: 30%;
|
||||
font-size: 1em;
|
||||
padding: 0 8px 0 0;
|
||||
}
|
||||
|
||||
> .value {
|
||||
width: 70%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
138
packages/frontend/src/components/MkLaunchPad.vue
Normal file
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<MkModal ref="modal" v-slot="{ type, maxHeight }" :prefer-type="preferedModalType" :anchor="anchor" :transparent-bg="true" :src="src" @click="modal.close()" @closed="emit('closed')">
|
||||
<div class="szkkfdyq _popup _shadow" :class="{ asDrawer: type === 'drawer' }" :style="{ maxHeight: maxHeight ? maxHeight + 'px' : '' }">
|
||||
<div class="main">
|
||||
<template v-for="item in items">
|
||||
<button v-if="item.action" v-click-anime class="_button" @click="$event => { item.action($event); close(); }">
|
||||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
</button>
|
||||
<MkA v-else v-click-anime :to="item.to" @click.passive="close()">
|
||||
<i class="icon" :class="item.icon"></i>
|
||||
<div class="text">{{ item.text }}</div>
|
||||
<span v-if="item.indicate" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
</MkA>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkModal from '@/components/MkModal.vue';
|
||||
import { navbarItemDef } from '@/navbar';
|
||||
import { instanceName } from '@/config';
|
||||
import { defaultStore } from '@/store';
|
||||
import { i18n } from '@/i18n';
|
||||
import { deviceKind } from '@/scripts/device-kind';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
src?: HTMLElement;
|
||||
anchor?: { x: string; y: string; };
|
||||
}>(), {
|
||||
anchor: () => ({ x: 'right', y: 'center' }),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const preferedModalType = (deviceKind === 'desktop' && props.src != null) ? 'popup' :
|
||||
deviceKind === 'smartphone' ? 'drawer' :
|
||||
'dialog';
|
||||
|
||||
const modal = $ref<InstanceType<typeof MkModal>>();
|
||||
|
||||
const menu = defaultStore.state.menu;
|
||||
|
||||
const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
|
||||
type: def.to ? 'link' : 'button',
|
||||
text: i18n.ts[def.title],
|
||||
icon: def.icon,
|
||||
to: def.to,
|
||||
action: def.action,
|
||||
indicate: def.indicated,
|
||||
}));
|
||||
|
||||
function close() {
|
||||
modal.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.szkkfdyq {
|
||||
max-height: 100%;
|
||||
width: min(460px, 100vw);
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
text-align: left;
|
||||
border-radius: 16px;
|
||||
|
||||
&.asDrawer {
|
||||
width: 100%;
|
||||
padding: 16px 16px calc(env(safe-area-inset-bottom, 0px) + 16px) 16px;
|
||||
border-radius: 24px;
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .main, > .sub {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
vertical-align: bottom;
|
||||
height: 100px;
|
||||
border-radius: 10px;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent);
|
||||
background: var(--accentedBg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
> .text {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
> .indicator {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: 32px;
|
||||
color: var(--indicator);
|
||||
font-size: 8px;
|
||||
animation: blink 1s infinite;
|
||||
|
||||
@media (max-width: 500px) {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .sub {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
}
|
||||
}
|
||||
</style>
|
47
packages/frontend/src/components/MkLink.vue
Normal file
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<component
|
||||
:is="self ? 'MkA' : 'a'" ref="el" class="xlcxczvw _link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
|
||||
:title="url"
|
||||
>
|
||||
<slot></slot>
|
||||
<i v-if="target === '_blank'" class="ti ti-external-link icon"></i>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import { url as local } from '@/config';
|
||||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
rel?: null | string;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const self = props.url.startsWith(local);
|
||||
const attr = self ? 'to' : 'href';
|
||||
const target = self ? null : '_blank';
|
||||
|
||||
const el = $ref();
|
||||
|
||||
useTooltip($$(el), (showing) => {
|
||||
os.popup(defineAsyncComponent(() => import('@/components/MkUrlPreviewPopup.vue')), {
|
||||
showing,
|
||||
url: props.url,
|
||||
source: el,
|
||||
}, {}, 'closed');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.xlcxczvw {
|
||||
word-break: break-all;
|
||||
|
||||
> .icon {
|
||||
padding-left: 2px;
|
||||
font-size: .9em;
|
||||
}
|
||||
}
|
||||
</style>
|
106
packages/frontend/src/components/MkMarquee.vue
Normal file
|
@ -0,0 +1,106 @@
|
|||
<script lang="ts">
|
||||
import { h, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
export default {
|
||||
name: 'MarqueeText',
|
||||
props: {
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
repeat: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
paused: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
reverse: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const contentEl = ref();
|
||||
|
||||
function calc() {
|
||||
const eachLength = contentEl.value.offsetWidth / props.repeat;
|
||||
const factor = 3000;
|
||||
const duration = props.duration / ((1 / eachLength) * factor);
|
||||
|
||||
contentEl.value.style.animationDuration = `${duration}s`;
|
||||
}
|
||||
|
||||
watch(() => props.duration, calc);
|
||||
|
||||
onMounted(() => {
|
||||
calc();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
});
|
||||
|
||||
return {
|
||||
contentEl,
|
||||
};
|
||||
},
|
||||
render({
|
||||
$slots, $style, $props: {
|
||||
duration, repeat, paused, reverse,
|
||||
},
|
||||
}) {
|
||||
return h('div', { class: [$style.wrap] }, [
|
||||
h('span', {
|
||||
ref: 'contentEl',
|
||||
class: [
|
||||
paused
|
||||
? $style.paused
|
||||
: undefined,
|
||||
$style.content,
|
||||
],
|
||||
}, Array(repeat).fill(
|
||||
h('span', {
|
||||
class: $style.text,
|
||||
style: {
|
||||
animationDirection: reverse
|
||||
? 'reverse'
|
||||
: undefined,
|
||||
},
|
||||
}, $slots.default()),
|
||||
)),
|
||||
]);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrap {
|
||||
overflow: clip;
|
||||
animation-play-state: running;
|
||||
|
||||
&:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
.text {
|
||||
display: inline-block;
|
||||
animation-name: marquee;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
animation-duration: inherit;
|
||||
animation-play-state: inherit;
|
||||
}
|
||||
.paused .text {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% { transform:translateX(0); }
|
||||
100% { transform:translateX(-100%); }
|
||||
}
|
||||
</style>
|
102
packages/frontend/src/components/MkMediaBanner.vue
Normal file
|
@ -0,0 +1,102 @@
|
|||
<template>
|
||||
<div class="mk-media-banner">
|
||||
<div v-if="media.isSensitive && hide" class="sensitive" @click="hide = false">
|
||||
<span class="icon"><i class="ti ti-alert-triangle"></i></span>
|
||||
<b>{{ $ts.sensitive }}</b>
|
||||
<span>{{ $ts.clickToShow }}</span>
|
||||
</div>
|
||||
<div v-else-if="media.type.startsWith('audio') && media.type !== 'audio/midi'" class="audio">
|
||||
<audio
|
||||
ref="audioEl"
|
||||
class="audio"
|
||||
:src="media.url"
|
||||
:title="media.name"
|
||||
controls
|
||||
preload="metadata"
|
||||
@volumechange="volumechange"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
v-else class="download"
|
||||
:href="media.url"
|
||||
:title="media.name"
|
||||
:download="media.name"
|
||||
>
|
||||
<span class="icon"><i class="ti ti-download"></i></span>
|
||||
<b>{{ media.name }}</b>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { ColdDeviceStorage } from '@/store';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
media: misskey.entities.DriveFile;
|
||||
}>(), {
|
||||
});
|
||||
|
||||
const audioEl = $ref<HTMLAudioElement | null>();
|
||||
let hide = $ref(true);
|
||||
|
||||
function volumechange() {
|
||||
if (audioEl) ColdDeviceStorage.set('mediaVolume', audioEl.volume);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (audioEl) audioEl.volume = ColdDeviceStorage.get('mediaVolume');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mk-media-banner {
|
||||
width: 100%;
|
||||
border-radius: 4px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
> .download,
|
||||
> .sensitive {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
|
||||
> * {
|
||||
display: block;
|
||||
}
|
||||
|
||||
> b {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-right: .2em;
|
||||
}
|
||||
|
||||
> .icon {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
> .download {
|
||||
background: var(--noteAttachedFile);
|
||||
}
|
||||
|
||||
> .sensitive {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
> .audio {
|
||||
.audio {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
130
packages/frontend/src/components/MkMediaImage.vue
Normal file
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div v-if="hide" class="qjewsnkg" @click="hide = false">
|
||||
<ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/>
|
||||
<div class="text">
|
||||
<div class="wrapper">
|
||||
<b style="display: block;"><i class="ti ti-alert-triangle"></i> {{ $ts.sensitive }}</b>
|
||||
<span style="display: block;">{{ $ts.clickToShow }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="gqnyydlz">
|
||||
<a
|
||||
:href="image.url"
|
||||
:title="image.name"
|
||||
>
|
||||
<ImgWithBlurhash :hash="image.blurhash" :src="url" :alt="image.comment" :title="image.comment" :cover="false"/>
|
||||
<div v-if="image.type === 'image/gif'" class="gif">GIF</div>
|
||||
</a>
|
||||
<button v-tooltip="$ts.hide" class="_button hide" @click="hide = true"><i class="ti ti-eye-off"></i></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { getStaticImageUrl } from '@/scripts/get-static-image-url';
|
||||
import ImgWithBlurhash from '@/components/MkImgWithBlurhash.vue';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
image: misskey.entities.DriveFile;
|
||||
raw?: boolean;
|
||||
}>();
|
||||
|
||||
let hide = $ref(true);
|
||||
|
||||
const url = (props.raw || defaultStore.state.loadRawImages)
|
||||
? props.image.url
|
||||
: defaultStore.state.disableShowingAnimatedImages
|
||||
? getStaticImageUrl(props.image.thumbnailUrl)
|
||||
: props.image.thumbnailUrl;
|
||||
|
||||
// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
|
||||
watch(() => props.image, () => {
|
||||
hide = (defaultStore.state.nsfw === 'force') ? true : props.image.isSensitive && (defaultStore.state.nsfw !== 'ignore');
|
||||
}, {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.qjewsnkg {
|
||||
position: relative;
|
||||
|
||||
> .bg {
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
|
||||
> .text {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
> .wrapper {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.gqnyydlz {
|
||||
position: relative;
|
||||
//box-shadow: 0 0 0 1px var(--divider) inset;
|
||||
background: var(--bg);
|
||||
|
||||
> .hide {
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
background-color: var(--accentedBg);
|
||||
-webkit-backdrop-filter: var(--blur, blur(15px));
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
color: var(--accent);
|
||||
font-size: 0.8em;
|
||||
padding: 6px 8px;
|
||||
text-align: center;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
|
||||
> i {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
> a {
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
> .gif {
|
||||
background-color: var(--fg);
|
||||
border-radius: 6px;
|
||||
color: var(--accentLighten);
|
||||
display: inline-block;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
left: 12px;
|
||||
opacity: .5;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
top: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
189
packages/frontend/src/components/MkMediaList.vue
Normal file
|
@ -0,0 +1,189 @@
|
|||
<template>
|
||||
<div class="hoawjimk">
|
||||
<XBanner v-for="media in mediaList.filter(media => !previewable(media))" :key="media.id" :media="media"/>
|
||||
<div v-if="mediaList.filter(media => previewable(media)).length > 0" class="gird-container">
|
||||
<div ref="gallery" :data-count="mediaList.filter(media => previewable(media)).length">
|
||||
<template v-for="media in mediaList.filter(media => previewable(media))">
|
||||
<XVideo v-if="media.type.startsWith('video')" :key="media.id" :video="media"/>
|
||||
<XImage v-else-if="media.type.startsWith('image')" :key="media.id" class="image" :data-id="media.id" :image="media" :raw="raw"/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import PhotoSwipeLightbox from 'photoswipe/lightbox';
|
||||
import PhotoSwipe from 'photoswipe';
|
||||
import 'photoswipe/style.css';
|
||||
import XBanner from '@/components/MkMediaBanner.vue';
|
||||
import XImage from '@/components/MkMediaImage.vue';
|
||||
import XVideo from '@/components/MkMediaVideo.vue';
|
||||
import * as os from '@/os';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const';
|
||||
import { defaultStore } from '@/store';
|
||||
|
||||
const props = defineProps<{
|
||||
mediaList: misskey.entities.DriveFile[];
|
||||
raw?: boolean;
|
||||
}>();
|
||||
|
||||
const gallery = ref(null);
|
||||
const pswpZIndex = os.claimZIndex('middle');
|
||||
|
||||
onMounted(() => {
|
||||
const lightbox = new PhotoSwipeLightbox({
|
||||
dataSource: props.mediaList
|
||||
.filter(media => {
|
||||
if (media.type === 'image/svg+xml') return true; // svgのwebpublicはpngなのでtrue
|
||||
return media.type.startsWith('image') && FILE_TYPE_BROWSERSAFE.includes(media.type);
|
||||
})
|
||||
.map(media => {
|
||||
const item = {
|
||||
src: media.url,
|
||||
w: media.properties.width,
|
||||
h: media.properties.height,
|
||||
alt: media.name,
|
||||
};
|
||||
if (media.properties.orientation != null && media.properties.orientation >= 5) {
|
||||
[item.w, item.h] = [item.h, item.w];
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
gallery: gallery.value,
|
||||
children: '.image',
|
||||
thumbSelector: '.image',
|
||||
loop: false,
|
||||
padding: window.innerWidth > 500 ? {
|
||||
top: 32,
|
||||
bottom: 32,
|
||||
left: 32,
|
||||
right: 32,
|
||||
} : {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
},
|
||||
imageClickAction: 'close',
|
||||
tapAction: 'toggle-controls',
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
lightbox.on('itemData', (ev) => {
|
||||
const { itemData } = ev;
|
||||
|
||||
// element is children
|
||||
const { element } = itemData;
|
||||
|
||||
const id = element.dataset.id;
|
||||
const file = props.mediaList.find(media => media.id === id);
|
||||
|
||||
itemData.src = file.url;
|
||||
itemData.w = Number(file.properties.width);
|
||||
itemData.h = Number(file.properties.height);
|
||||
if (file.properties.orientation != null && file.properties.orientation >= 5) {
|
||||
[itemData.w, itemData.h] = [itemData.h, itemData.w];
|
||||
}
|
||||
itemData.msrc = file.thumbnailUrl;
|
||||
itemData.thumbCropped = true;
|
||||
});
|
||||
|
||||
lightbox.init();
|
||||
});
|
||||
|
||||
const previewable = (file: misskey.entities.DriveFile): boolean => {
|
||||
if (file.type === 'image/svg+xml') return true; // svgのwebpublic/thumbnailはpngなのでtrue
|
||||
// FILE_TYPE_BROWSERSAFEに適合しないものはブラウザで表示するのに不適切
|
||||
return (file.type.startsWith('video') || file.type.startsWith('image')) && FILE_TYPE_BROWSERSAFE.includes(file.type);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hoawjimk {
|
||||
> .gird-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-top: 56.25% // 16:9;
|
||||
}
|
||||
|
||||
> div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: grid;
|
||||
grid-gap: 8px;
|
||||
|
||||
> * {
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&[data-count="1"] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
&[data-count="2"] {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
&[data-count="3"] {
|
||||
grid-template-columns: 1fr 0.5fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
|
||||
> *:nth-child(1) {
|
||||
grid-row: 1 / 3;
|
||||
}
|
||||
|
||||
> *:nth-child(3) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-count="4"] {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
> *:nth-child(1) {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
> *:nth-child(2) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
> *:nth-child(3) {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
> *:nth-child(4) {
|
||||
grid-column: 2 / 3;
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.pswp {
|
||||
// なぜか機能しない
|
||||
//z-index: v-bind(pswpZIndex);
|
||||
z-index: 2000000;
|
||||
}
|
||||
</style>
|