Merge tag '13.14.1' into merge-upstream
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
import micromatch from 'micromatch';
|
||||
import main from './main';
|
||||
import main from './main.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
interface Stats {
|
||||
readonly modules: readonly {
|
||||
@@ -13,8 +16,8 @@ interface Stats {
|
||||
}[];
|
||||
}
|
||||
|
||||
fs.readFile(
|
||||
path.resolve(__dirname, '../storybook-static/preview-stats.json')
|
||||
await fs.readFile(
|
||||
new URL('../storybook-static/preview-stats.json', import.meta.url)
|
||||
).then((buffer) => {
|
||||
const stats: Stats = JSON.parse(buffer.toString());
|
||||
const keys = new Set(stats.modules.map((stat) => stat.id));
|
||||
|
@@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
|
||||
url: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) {
|
||||
const date = new Date();
|
||||
const createdAt = new Date();
|
||||
createdAt.setDate(date.getDate() - 1)
|
||||
const expiresAt = new Date();
|
||||
|
||||
if (isExpired) {
|
||||
expiresAt.setHours(date.getHours() - 1)
|
||||
} else {
|
||||
expiresAt.setHours(date.getHours() + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
id: "9gyqzizw77",
|
||||
code: "SLF3JKF7UV2H9",
|
||||
expiresAt: hasExpiration ? expiresAt.toISOString() : null,
|
||||
createdAt: createdAt.toISOString(),
|
||||
createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'),
|
||||
usedBy: isUsed ? userDetailed('3i3r2znx1v') : null,
|
||||
usedAt: isUsed ? date.toISOString() : null,
|
||||
used: isUsed,
|
||||
}
|
||||
}
|
||||
|
@@ -96,7 +96,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function toStories(component: string): string {
|
||||
function toStories(component: string): Promise<string> {
|
||||
const msw = `${component.slice(0, -'.vue'.length)}.msw`;
|
||||
const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`;
|
||||
const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`;
|
||||
@@ -394,18 +394,21 @@ function toStories(component: string): string {
|
||||
}
|
||||
|
||||
// glob('src/{components,pages,ui,widgets}/**/*.vue')
|
||||
Promise.all([
|
||||
glob('src/components/global/*.vue'),
|
||||
glob('src/components/Mk{A,B}*.vue'),
|
||||
glob('src/components/MkDigitalClock.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
glob('src/components/MkSignupServerRules.vue'),
|
||||
glob('src/components/MkUserSetupDialog.vue'),
|
||||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
])
|
||||
.then((globs) => globs.flat())
|
||||
.then((components) => Promise.all(components.map((component) => {
|
||||
(async () => {
|
||||
const globs = await Promise.all([
|
||||
glob('src/components/global/*.vue'),
|
||||
glob('src/components/Mk{A,B}*.vue'),
|
||||
glob('src/components/MkDigitalClock.vue'),
|
||||
glob('src/components/MkGalleryPostPreview.vue'),
|
||||
glob('src/components/MkSignupServerRules.vue'),
|
||||
glob('src/components/MkUserSetupDialog.vue'),
|
||||
glob('src/components/MkUserSetupDialog.*.vue'),
|
||||
glob('src/components/MkInviteCode.vue'),
|
||||
glob('src/pages/user/home.vue'),
|
||||
]);
|
||||
const components = globs.flat();
|
||||
await Promise.all(components.map(async (component) => {
|
||||
const stories = component.replace(/\.vue$/, '.stories.ts');
|
||||
return writeFile(stories, toStories(component));
|
||||
})));
|
||||
await writeFile(stories, await toStories(component));
|
||||
}))
|
||||
})();
|
||||
|
@@ -1,7 +1,11 @@
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||
import { type Plugin, mergeConfig } from 'vite';
|
||||
import turbosnap from 'vite-plugin-turbosnap';
|
||||
|
||||
const dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
const config = {
|
||||
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||
addons: [
|
||||
@@ -9,7 +13,7 @@ const config = {
|
||||
'@storybook/addon-interactions',
|
||||
'@storybook/addon-links',
|
||||
'@storybook/addon-storysource',
|
||||
resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'),
|
||||
resolve(dirname, '../node_modules/storybook-addon-misskey-theme'),
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/vue3-vite',
|
||||
@@ -28,7 +32,8 @@ const config = {
|
||||
}
|
||||
return mergeConfig(config, {
|
||||
plugins: [
|
||||
turbosnap({
|
||||
// XXX: https://github.com/IanVS/vite-plugin-turbosnap/issues/8
|
||||
(turbosnap as any as typeof turbosnap['default'])({
|
||||
rootDir: config.root ?? process.cwd(),
|
||||
}),
|
||||
],
|
||||
|
3
packages/frontend/.storybook/package.json
Normal file
3
packages/frontend/.storybook/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
@@ -1,9 +1,8 @@
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import * as locales from '../../../locales';
|
||||
import locales from '../../../locales/index.js';
|
||||
|
||||
writeFile(
|
||||
resolve(__dirname, 'locale.ts'),
|
||||
await writeFile(
|
||||
new URL('locale.ts', import.meta.url),
|
||||
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
|
||||
'utf8',
|
||||
)
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import * as JSON5 from 'json5';
|
||||
import JSON5 from 'json5';
|
||||
|
||||
const keys = [
|
||||
'_dark',
|
||||
@@ -26,9 +25,9 @@ const keys = [
|
||||
'd-u0',
|
||||
]
|
||||
|
||||
Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => {
|
||||
await Promise.all(keys.map((key) => readFile(new URL(`../src/themes/${key}.json5`, import.meta.url), 'utf8'))).then((sources) => {
|
||||
writeFile(
|
||||
resolve(__dirname, './themes.ts'),
|
||||
new URL('./themes.ts', import.meta.url),
|
||||
`export default ${JSON.stringify(
|
||||
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
|
||||
undefined,
|
||||
|
@@ -3,10 +3,10 @@ import { FORCE_REMOUNT } from '@storybook/core-events';
|
||||
import { type Preview, setup } from '@storybook/vue3';
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||
import { userDetailed } from './fakes';
|
||||
import locale from './locale';
|
||||
import { commonHandlers, onUnhandledRequest } from './mocks';
|
||||
import themes from './themes';
|
||||
import { userDetailed } from './fakes.js';
|
||||
import locale from './locale.js';
|
||||
import { commonHandlers, onUnhandledRequest } from './mocks.js';
|
||||
import themes from './themes.js';
|
||||
import '../src/style.scss';
|
||||
|
||||
const appInitialized = Symbol();
|
||||
|
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "Node16",
|
||||
"strict": true,
|
||||
"allowUnusedLabels": false,
|
||||
"allowUnreachableCode": false,
|
||||
|
@@ -4,8 +4,9 @@
|
||||
"scripts": {
|
||||
"watch": "vite",
|
||||
"build": "vite build",
|
||||
"storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'",
|
||||
"build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build",
|
||||
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
|
||||
"build-storybook-pre": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
|
||||
"build-storybook": "pnpm build-storybook-pre && storybook build",
|
||||
"chromatic": "chromatic",
|
||||
"test": "vitest --run",
|
||||
"test-and-coverage": "vitest --run --coverage",
|
||||
@@ -19,30 +20,30 @@
|
||||
"@rollup/plugin-json": "6.0.0",
|
||||
"@rollup/plugin-replace": "5.0.2",
|
||||
"@rollup/pluginutils": "5.0.2",
|
||||
"@syuilo/aiscript": "0.13.3",
|
||||
"@tabler/icons-webfont": "2.21.0",
|
||||
"@syuilo/aiscript": "0.15.0",
|
||||
"@tabler/icons-webfont": "2.25.0",
|
||||
"@vitejs/plugin-vue": "4.2.3",
|
||||
"@vue-macros/reactivity-transform": "0.3.9",
|
||||
"@vue-macros/reactivity-transform": "0.3.15",
|
||||
"@vue/compiler-sfc": "3.3.4",
|
||||
"astring": "1.8.6",
|
||||
"autosize": "6.0.1",
|
||||
"broadcast-channel": "5.1.0",
|
||||
"browser-image-resizer": "github:misskey-dev/browser-image-resizer#v2.2.1-misskey.3",
|
||||
"buraha": "github:misskey-dev/buraha",
|
||||
"buraha": "0.0.1",
|
||||
"canvas-confetti": "1.6.0",
|
||||
"chart.js": "4.3.0",
|
||||
"chartjs-adapter-date-fns": "3.0.0",
|
||||
"chartjs-chart-matrix": "2.0.1",
|
||||
"chartjs-plugin-gradient": "0.6.1",
|
||||
"chartjs-plugin-zoom": "2.0.1",
|
||||
"chromatic": "6.18.0",
|
||||
"chromatic": "6.19.9",
|
||||
"compare-versions": "5.0.3",
|
||||
"cropperjs": "2.0.0-beta.2",
|
||||
"cropperjs": "2.0.0-beta.3",
|
||||
"date-fns": "2.30.0",
|
||||
"escape-regexp": "0.0.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"eventemitter3": "5.0.1",
|
||||
"gsap": "3.11.5",
|
||||
"gsap": "3.12.2",
|
||||
"idb-keyval": "6.2.1",
|
||||
"insert-text-at-cursor": "0.3.0",
|
||||
"is-file-animated": "1.0.2",
|
||||
@@ -50,97 +51,94 @@
|
||||
"matter-js": "0.19.0",
|
||||
"mfm-js": "0.23.3",
|
||||
"misskey-js": "workspace:*",
|
||||
"photoswipe": "5.3.7",
|
||||
"photoswipe": "5.3.8",
|
||||
"prismjs": "1.29.0",
|
||||
"punycode": "2.3.0",
|
||||
"querystring": "0.2.1",
|
||||
"rndstr": "1.0.0",
|
||||
"rollup": "3.23.0",
|
||||
"rollup": "3.26.3",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.10.0",
|
||||
"sass": "1.62.1",
|
||||
"seedrandom": "3.0.5",
|
||||
"sanitize-html": "2.11.0",
|
||||
"sass": "1.63.6",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"syuilo-password-strength": "0.0.1",
|
||||
"textarea-caret": "3.1.0",
|
||||
"three": "0.153.0",
|
||||
"three": "0.154.0",
|
||||
"throttle-debounce": "5.0.0",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tsc-alias": "1.8.6",
|
||||
"tsc-alias": "1.8.7",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twemoji-parser": "14.0.0",
|
||||
"typescript": "5.1.3",
|
||||
"typescript": "5.1.6",
|
||||
"uuid": "9.0.0",
|
||||
"vanilla-tilt": "1.8.0",
|
||||
"vite": "4.3.9",
|
||||
"vite": "4.4.4",
|
||||
"vue": "3.3.4",
|
||||
"vue-prism-editor": "2.0.0-alpha.2",
|
||||
"vuedraggable": "next"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "7.0.18",
|
||||
"@storybook/addon-essentials": "7.0.18",
|
||||
"@storybook/addon-interactions": "7.0.18",
|
||||
"@storybook/addon-links": "7.0.18",
|
||||
"@storybook/addon-storysource": "7.0.18",
|
||||
"@storybook/addons": "7.0.18",
|
||||
"@storybook/blocks": "7.0.18",
|
||||
"@storybook/core-events": "7.0.18",
|
||||
"@storybook/addon-actions": "7.0.27",
|
||||
"@storybook/addon-essentials": "7.0.27",
|
||||
"@storybook/addon-interactions": "7.0.27",
|
||||
"@storybook/addon-links": "7.0.27",
|
||||
"@storybook/addon-storysource": "7.0.27",
|
||||
"@storybook/addons": "7.0.27",
|
||||
"@storybook/blocks": "7.0.27",
|
||||
"@storybook/core-events": "7.0.27",
|
||||
"@storybook/jest": "0.1.0",
|
||||
"@storybook/manager-api": "7.0.18",
|
||||
"@storybook/preview-api": "7.0.18",
|
||||
"@storybook/react": "7.0.18",
|
||||
"@storybook/react-vite": "7.0.18",
|
||||
"@storybook/testing-library": "0.1.0",
|
||||
"@storybook/theming": "7.0.18",
|
||||
"@storybook/types": "7.0.18",
|
||||
"@storybook/vue3": "7.0.18",
|
||||
"@storybook/vue3-vite": "7.0.18",
|
||||
"@storybook/manager-api": "7.0.27",
|
||||
"@storybook/preview-api": "7.0.27",
|
||||
"@storybook/react": "7.0.27",
|
||||
"@storybook/react-vite": "7.0.27",
|
||||
"@storybook/testing-library": "0.2.0",
|
||||
"@storybook/theming": "7.0.27",
|
||||
"@storybook/types": "7.0.27",
|
||||
"@storybook/vue3": "7.0.27",
|
||||
"@storybook/vue3-vite": "7.0.27",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/vue": "7.0.0",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/estree": "1.0.1",
|
||||
"@types/gulp": "4.0.10",
|
||||
"@types/gulp": "4.0.13",
|
||||
"@types/gulp-rename": "2.0.2",
|
||||
"@types/matter-js": "0.18.5",
|
||||
"@types/micromatch": "4.0.2",
|
||||
"@types/node": "20.2.5",
|
||||
"@types/node": "20.4.2",
|
||||
"@types/punycode": "2.1.0",
|
||||
"@types/sanitize-html": "2.9.0",
|
||||
"@types/seedrandom": "3.0.5",
|
||||
"@types/testing-library__jest-dom": "^5.14.6",
|
||||
"@types/testing-library__jest-dom": "5.14.8",
|
||||
"@types/throttle-debounce": "5.0.0",
|
||||
"@types/tinycolor2": "1.4.3",
|
||||
"@types/uuid": "9.0.1",
|
||||
"@types/uuid": "9.0.2",
|
||||
"@types/websocket": "1.0.5",
|
||||
"@types/ws": "8.5.4",
|
||||
"@typescript-eslint/eslint-plugin": "5.59.8",
|
||||
"@typescript-eslint/parser": "5.59.8",
|
||||
"@vitest/coverage-c8": "0.31.4",
|
||||
"@types/ws": "8.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "5.61.0",
|
||||
"@typescript-eslint/parser": "5.61.0",
|
||||
"@vitest/coverage-v8": "0.33.0",
|
||||
"@vue/runtime-core": "3.3.4",
|
||||
"acorn": "^8.8.2",
|
||||
"chokidar-cli": "3.0.0",
|
||||
"acorn": "8.10.0",
|
||||
"cross-env": "7.0.3",
|
||||
"cypress": "12.13.0",
|
||||
"eslint": "8.41.0",
|
||||
"cypress": "12.17.1",
|
||||
"eslint": "8.45.0",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-vue": "9.14.1",
|
||||
"fast-glob": "3.2.12",
|
||||
"happy-dom": "9.20.3",
|
||||
"micromatch": "3.1.10",
|
||||
"msw": "1.2.1",
|
||||
"eslint-plugin-vue": "9.15.1",
|
||||
"fast-glob": "3.3.0",
|
||||
"happy-dom": "10.0.3",
|
||||
"micromatch": "4.0.5",
|
||||
"msw": "1.2.2",
|
||||
"msw-storybook-addon": "1.8.0",
|
||||
"prettier": "2.8.8",
|
||||
"nodemon": "3.0.1",
|
||||
"prettier": "3.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"start-server-and-test": "2.0.0",
|
||||
"storybook": "7.0.18",
|
||||
"storybook": "7.0.27",
|
||||
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||
"summaly": "github:misskey-dev/summaly",
|
||||
"vite-plugin-turbosnap": "1.0.2",
|
||||
"vitest": "0.31.4",
|
||||
"vitest": "0.33.0",
|
||||
"vitest-fetch-mock": "0.2.2",
|
||||
"vue-eslint-parser": "9.3.0",
|
||||
"vue-tsc": "1.6.5"
|
||||
"vue-eslint-parser": "9.3.1",
|
||||
"vue-tsc": "1.8.5"
|
||||
}
|
||||
}
|
||||
|
@@ -13,10 +13,11 @@ import { miLocalStorage } from '@/local-storage';
|
||||
import { claimAchievement, claimedAchievements } from '@/scripts/achievements';
|
||||
import { mainRouter } from '@/router';
|
||||
import { initializeSw } from '@/scripts/initialize-sw';
|
||||
import { deckStore } from '@/ui/deck/deck-store';
|
||||
|
||||
export async function mainBoot() {
|
||||
const { isClientUpdated } = await common(() => createApp(
|
||||
new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
|
||||
new URLSearchParams(window.location.search).has('zen') || (ui === 'deck' && deckStore.state.useSimpleUiForNonRootPages && location.pathname !== '/') ? defineAsyncComponent(() => import('@/ui/zen.vue')) :
|
||||
!$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) :
|
||||
ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) :
|
||||
ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) :
|
||||
@@ -87,7 +88,7 @@ export async function mainBoot() {
|
||||
const now = new Date();
|
||||
const m = now.getMonth() + 1;
|
||||
const d = now.getDate();
|
||||
|
||||
|
||||
if ($i.birthday) {
|
||||
const bm = parseInt($i.birthday.split('-')[1]);
|
||||
const bd = parseInt($i.birthday.split('-')[2]);
|
||||
|
@@ -4,3 +4,4 @@ import { Cache } from '@/scripts/cache';
|
||||
export const clipsCache = new Cache<misskey.entities.Clip[]>(Infinity);
|
||||
export const rolesCache = new Cache(Infinity);
|
||||
export const userListsCache = new Cache<misskey.entities.UserList[]>(Infinity);
|
||||
export const antennasCache = new Cache<misskey.entities.Antenna[]>(Infinity);
|
||||
|
@@ -39,7 +39,7 @@
|
||||
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
|
||||
</template>
|
||||
</MkFolder>
|
||||
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
|
||||
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }]" :style="{ textAlign: c.align ?? null, backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
|
||||
<template v-for="child in c.children" :key="child">
|
||||
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size" :align="c.align"/>
|
||||
</template>
|
||||
@@ -102,10 +102,6 @@ function openPostForm() {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.containerCenter {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fontSerif {
|
||||
font-family: serif;
|
||||
}
|
||||
|
@@ -356,9 +356,7 @@ onMounted(() => {
|
||||
|
||||
props.textarea.addEventListener('keydown', onKeydown);
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
document.body.addEventListener('mousedown', onMousedown);
|
||||
|
||||
nextTick(() => {
|
||||
exec();
|
||||
@@ -374,9 +372,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(() => {
|
||||
props.textarea.removeEventListener('keydown', onKeydown);
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
document.body.removeEventListener('mousedown', onMousedown);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
@@ -1,24 +1,29 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-for="user in users" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
|
||||
<div v-for="user in users.slice(0, limit)" :key="user.id" style="display:inline-block;width:32px;height:32px;margin-right:8px;">
|
||||
<MkAvatar :user="user" style="width:32px; height:32px;" indicator link preview/>
|
||||
</div>
|
||||
<div v-if="users.length > limit" style="display: inline-block;">...</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import { UserLite } from 'misskey-js/built/entities';
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
userIds: string[];
|
||||
}>();
|
||||
limit?: number;
|
||||
}>(), {
|
||||
limit: Infinity,
|
||||
});
|
||||
|
||||
const users = ref([]);
|
||||
const users = ref<UserLite[]>([]);
|
||||
|
||||
onMounted(async () => {
|
||||
users.value = await os.api('users/show', {
|
||||
userIds: props.userIds,
|
||||
});
|
||||
}) as unknown as UserLite[];
|
||||
});
|
||||
</script>
|
||||
|
@@ -61,15 +61,11 @@ onMounted(() => {
|
||||
rootEl.style.top = `${top}px`;
|
||||
rootEl.style.left = `${left}px`;
|
||||
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.addEventListener('mousedown', onMousedown);
|
||||
}
|
||||
document.body.addEventListener('mousedown', onMousedown);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const el of Array.from(document.querySelectorAll('body *'))) {
|
||||
el.removeEventListener('mousedown', onMousedown);
|
||||
}
|
||||
document.body.removeEventListener('mousedown', onMousedown);
|
||||
});
|
||||
|
||||
function onMousedown(evt: Event) {
|
||||
|
@@ -47,6 +47,7 @@ const emit = defineEmits<{
|
||||
const props = defineProps<{
|
||||
file: misskey.entities.DriveFile;
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}>();
|
||||
|
||||
const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
|
||||
@@ -58,11 +59,17 @@ 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 => {
|
||||
croppedCanvas?.toBlob(blob => {
|
||||
if (!blob) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob);
|
||||
formData.append('i', $i.token);
|
||||
if (defaultStore.state.uploadFolder) {
|
||||
formData.append('name', `cropped_${props.file.name}`);
|
||||
formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
|
||||
formData.append('comment', props.file.comment ?? 'null');
|
||||
formData.append('i', $i!.token);
|
||||
if (props.uploadFolder || props.uploadFolder === null) {
|
||||
formData.append('folderId', props.uploadFolder ?? 'null');
|
||||
} else if (defaultStore.state.uploadFolder) {
|
||||
formData.append('folderId', defaultStore.state.uploadFolder);
|
||||
}
|
||||
|
||||
@@ -82,12 +89,12 @@ const ok = async () => {
|
||||
const f = await promise;
|
||||
|
||||
emit('ok', f);
|
||||
dialogEl.close();
|
||||
dialogEl!.close();
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
dialogEl.close();
|
||||
dialogEl!.close();
|
||||
};
|
||||
|
||||
const onImageLoad = () => {
|
||||
@@ -100,7 +107,7 @@ const onImageLoad = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
cropper = new Cropper(imgEl, {
|
||||
cropper = new Cropper(imgEl!, {
|
||||
});
|
||||
|
||||
const computedStyle = getComputedStyle(document.documentElement);
|
||||
@@ -112,13 +119,13 @@ onMounted(() => {
|
||||
selection.outlined = true;
|
||||
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
cropper!.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 100);
|
||||
|
||||
// モーダルオープンアニメーションが終わったあとで再度調整
|
||||
window.setTimeout(() => {
|
||||
cropper.getCropperImage()!.$center('contain');
|
||||
cropper!.getCropperImage()!.$center('contain');
|
||||
selection.$center();
|
||||
}, 500);
|
||||
});
|
||||
|
@@ -19,14 +19,14 @@
|
||||
</div>
|
||||
<div v-if="file.isSensitive" :class="[$style.label, $style.red]">
|
||||
<img :class="$style.labelImg" src="/client-assets/label-red.svg"/>
|
||||
<p :class="$style.labelText">{{ i18n.ts.nsfw }}</p>
|
||||
<p :class="$style.labelText">{{ i18n.ts.sensitive }}</p>
|
||||
</div>
|
||||
|
||||
<MkDriveFileThumbnail :class="$style.thumbnail" :file="file" fit="contain"/>
|
||||
|
||||
<p :class="$style.name">
|
||||
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span>
|
||||
<span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span>
|
||||
<span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substring(0, file.name.lastIndexOf('.')) : file.name }}</span>
|
||||
<span v-if="file.name.lastIndexOf('.') != -1" style="opacity: 0.5;">{{ file.name.substring(file.name.lastIndexOf('.')) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,6 +44,7 @@ import { getDriveFileMenu } from '@/scripts/get-drive-file-menu';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
file: Misskey.entities.DriveFile;
|
||||
folder: Misskey.entities.DriveFolder | null;
|
||||
isSelected?: boolean;
|
||||
selectMode?: boolean;
|
||||
}>(), {
|
||||
@@ -65,12 +66,12 @@ function onClick(ev: MouseEvent) {
|
||||
if (props.selectMode) {
|
||||
emit('chosen', props.file);
|
||||
} else {
|
||||
os.popupMenu(getDriveFileMenu(props.file), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu(getDriveFileMenu(props.file), ev);
|
||||
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
|
||||
}
|
||||
|
||||
function onDragstart(ev: DragEvent) {
|
||||
|
@@ -33,6 +33,7 @@ import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
folder: Misskey.entities.DriveFolder;
|
||||
@@ -93,9 +94,9 @@ function onDragover(ev: DragEvent) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
@@ -244,7 +245,8 @@ function setAsUploadFolder() {
|
||||
}
|
||||
|
||||
function onContextmenu(ev: MouseEvent) {
|
||||
os.contextMenu([{
|
||||
let menu;
|
||||
menu = [{
|
||||
text: i18n.ts.openInWindow,
|
||||
icon: 'ti ti-app-window',
|
||||
action: () => {
|
||||
@@ -262,7 +264,17 @@ function onContextmenu(ev: MouseEvent) {
|
||||
icon: 'ti ti-trash',
|
||||
danger: true,
|
||||
action: deleteFolder,
|
||||
}], ev);
|
||||
}];
|
||||
if (defaultStore.state.devMode) {
|
||||
menu = menu.concat([null, {
|
||||
icon: 'ti ti-id',
|
||||
text: i18n.ts.copyFolderId,
|
||||
action: () => {
|
||||
copyToClipboard(props.folder.id);
|
||||
},
|
||||
}]);
|
||||
}
|
||||
os.contextMenu(menu, ev);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@@ -61,9 +61,9 @@ function onDragover(ev: DragEvent) {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
|
@@ -56,7 +56,7 @@
|
||||
/>
|
||||
<!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
|
||||
<div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
|
||||
<MkButton v-if="moreFolders" ref="moreFolders">{{ i18n.ts.loadMore }}</MkButton>
|
||||
<MkButton v-if="moreFolders" ref="moreFolders" @click="fetchMoreFolders">{{ i18n.ts.loadMore }}</MkButton>
|
||||
</div>
|
||||
<div v-show="files.length > 0" ref="filesContainer" :class="$style.files">
|
||||
<XFile
|
||||
@@ -65,6 +65,7 @@
|
||||
v-anim="i"
|
||||
:class="$style.file"
|
||||
:file="file"
|
||||
:folder="folder"
|
||||
:selectMode="select === 'file'"
|
||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||
@chosen="chooseFile"
|
||||
@@ -201,9 +202,9 @@ function onDragover(ev: DragEvent): any {
|
||||
switch (ev.dataTransfer.effectAllowed) {
|
||||
case 'all':
|
||||
case 'uninitialized':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
case 'copy':
|
||||
case 'copyLink':
|
||||
case 'copyMove':
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
break;
|
||||
case 'linkMove':
|
||||
@@ -559,6 +560,28 @@ async function fetch() {
|
||||
fetching.value = false;
|
||||
}
|
||||
|
||||
function fetchMoreFolders() {
|
||||
fetching.value = true;
|
||||
|
||||
const max = 30;
|
||||
|
||||
os.api('drive/folders', {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
untilId: folders.value.at(-1)?.id,
|
||||
limit: max + 1,
|
||||
}).then(folders => {
|
||||
if (folders.length === max + 1) {
|
||||
moreFolders.value = true;
|
||||
folders.pop();
|
||||
} else {
|
||||
moreFolders.value = false;
|
||||
}
|
||||
for (const x of folders) appendFolder(x);
|
||||
fetching.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
function fetchMoreFiles() {
|
||||
fetching.value = true;
|
||||
|
||||
@@ -568,7 +591,7 @@ function fetchMoreFiles() {
|
||||
os.api('drive/files', {
|
||||
folderId: folder.value ? folder.value.id : null,
|
||||
type: props.type,
|
||||
untilId: files.value[files.value.length - 1].id,
|
||||
untilId: files.value.at(-1)?.id,
|
||||
limit: max + 1,
|
||||
}).then(files => {
|
||||
if (files.length === max + 1) {
|
||||
|
@@ -89,7 +89,7 @@ const props = defineProps<{
|
||||
> .file {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
|
||||
|
||||
> .thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
@@ -87,7 +87,7 @@ const props = defineProps<{
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 10px;
|
||||
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
|
||||
|
@@ -22,10 +22,13 @@ import TestWebGL2 from '@/workers/test-webgl2?worker';
|
||||
import { WorkerMultiDispatch } from '@/scripts/worker-multi-dispatch';
|
||||
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
|
||||
|
||||
const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
|
||||
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
|
||||
// テスト環境で Web Worker インスタンスは作成できない
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
resolve(null);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
return;
|
||||
}
|
||||
const testWorker = new TestWebGL2();
|
||||
@@ -38,7 +41,10 @@ const workerPromise = new Promise<WorkerMultiDispatch | null>(resolve => {
|
||||
resolve(workers);
|
||||
if (_DEV_) console.log('WebGL2 in worker is supported!');
|
||||
} else {
|
||||
resolve(null);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
resolve(canvas);
|
||||
if (_DEV_) console.log('WebGL2 in worker is not supported...');
|
||||
}
|
||||
testWorker.terminate();
|
||||
@@ -70,6 +76,7 @@ const props = withDefaults(defineProps<{
|
||||
width?: number;
|
||||
cover?: boolean;
|
||||
forceBlurhash?: boolean;
|
||||
onlyAvgColor?: boolean; // 軽量化のためにBlurhashを使わずに平均色だけを描画
|
||||
}>(), {
|
||||
transition: null,
|
||||
src: null,
|
||||
@@ -79,6 +86,7 @@ const props = withDefaults(defineProps<{
|
||||
width: 64,
|
||||
cover: true,
|
||||
forceBlurhash: false,
|
||||
onlyAvgColor: false,
|
||||
});
|
||||
|
||||
const viewId = uuid();
|
||||
@@ -100,7 +108,7 @@ function waitForDecode() {
|
||||
.then(() => {
|
||||
loaded = true;
|
||||
}, error => {
|
||||
console.error('Error occured during decoding image', img.value, error);
|
||||
console.error('Error occurred during decoding image', img.value, error);
|
||||
throw Error(error);
|
||||
});
|
||||
} else {
|
||||
@@ -139,8 +147,8 @@ function drawImage(bitmap: CanvasImageSource) {
|
||||
ctx.drawImage(bitmap, 0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
async function draw() {
|
||||
if (!canvas.value || props.hash == null) return;
|
||||
function drawAvg() {
|
||||
if (!canvas.value || !props.hash) return;
|
||||
|
||||
const ctx = canvas.value.getContext('2d');
|
||||
if (!ctx) return;
|
||||
@@ -149,27 +157,30 @@ async function draw() {
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = extractAvgColorFromBlurhash(props.hash) ?? '#888';
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
const workers = await workerPromise;
|
||||
if (workers) {
|
||||
workers.postMessage(
|
||||
async function draw() {
|
||||
if (props.hash == null) return;
|
||||
|
||||
drawAvg();
|
||||
|
||||
if (props.onlyAvgColor) return;
|
||||
|
||||
const work = await canvasPromise;
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.postMessage(
|
||||
{
|
||||
id: viewId,
|
||||
hash: props.hash,
|
||||
width: canvasWidth,
|
||||
height: canvasHeight,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const work = document.createElement('canvas');
|
||||
work.width = canvasWidth;
|
||||
work.height = canvasHeight;
|
||||
render(props.hash, work);
|
||||
ctx.drawImage(work, 0, 0, canvasWidth, canvasHeight);
|
||||
drawImage(work);
|
||||
} catch (error) {
|
||||
console.error('Error occured during drawing blurhash', error);
|
||||
console.error('Error occurred during drawing blurhash', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,9 +190,9 @@ function workerOnMessage(event: MessageEvent) {
|
||||
drawImage(event.data.bitmap as ImageBitmap);
|
||||
}
|
||||
|
||||
workerPromise.then(worker => {
|
||||
if (worker) {
|
||||
worker.addListener(workerOnMessage);
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.addListener(workerOnMessage);
|
||||
}
|
||||
|
||||
draw();
|
||||
@@ -204,8 +215,10 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
workerPromise.then(worker => {
|
||||
worker?.removeListener(workerOnMessage);
|
||||
canvasPromise.then(work => {
|
||||
if (work instanceof WorkerMultiDispatch) {
|
||||
work.removeListener(workerOnMessage);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
@@ -0,0 +1,60 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { rest } from 'msw';
|
||||
import { userDetailed, inviteCode } from '../../.storybook/fakes';
|
||||
import { commonHandlers } from '../../.storybook/mocks';
|
||||
import MkInviteCode from './MkInviteCode.vue';
|
||||
|
||||
export const Default = {
|
||||
render(args) {
|
||||
return {
|
||||
components: {
|
||||
MkInviteCode,
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
args,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
props() {
|
||||
return {
|
||||
...this.args,
|
||||
};
|
||||
},
|
||||
},
|
||||
template: '<MkInviteCode v-bind="props" />',
|
||||
};
|
||||
},
|
||||
args: {
|
||||
invite: inviteCode() as any,
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
msw: {
|
||||
handlers: [
|
||||
...commonHandlers,
|
||||
rest.post('/api/users/show', (req, res, ctx) => {
|
||||
return res(ctx.json(userDetailed(req.params.userId as string)));
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [() => ({
|
||||
template: '<div style="width:100cqmin"><story/></div>',
|
||||
})],
|
||||
} satisfies StoryObj<typeof MkInviteCode>;
|
||||
|
||||
export const Used = {
|
||||
...Default,
|
||||
args: {
|
||||
invite: inviteCode(true) as any
|
||||
},
|
||||
} satisfies StoryObj<typeof MkInviteCode>;
|
||||
|
||||
export const Expired = {
|
||||
...Default,
|
||||
args: {
|
||||
invite: inviteCode(false, true, true) as any
|
||||
},
|
||||
} satisfies StoryObj<typeof MkInviteCode>;
|
124
packages/frontend/src/components/MkInviteCode.vue
Normal file
124
packages/frontend/src/components/MkInviteCode.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<MkFolder>
|
||||
<template #label>{{ invite.code }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="invite.used">{{ i18n.ts.used }}</span>
|
||||
<span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span>
|
||||
<span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span>
|
||||
</template>
|
||||
|
||||
<div class="_gaps_s" :class="$style.root">
|
||||
<div :class="$style.items">
|
||||
<div>
|
||||
<div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
|
||||
<div>{{ invite.code }}</div>
|
||||
</div>
|
||||
<div v-if="moderator">
|
||||
<div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div>
|
||||
<div v-if="invite.createdBy" :class="$style.user">
|
||||
<MkAvatar :user="invite.createdBy" :class="$style.avatar" link preview/>
|
||||
<MkUserName :user="invite.createdBy" :nowrap="false"/>
|
||||
<div v-if="moderator">({{ invite.createdBy.id }})</div>
|
||||
</div>
|
||||
<div v-else>system</div>
|
||||
</div>
|
||||
<div v-if="invite.used">
|
||||
<div :class="$style.label">{{ i18n.ts.registeredUserUsingInviteCode }}</div>
|
||||
<div v-if="invite.usedBy" :class="$style.user">
|
||||
<MkAvatar :user="invite.usedBy" :class="$style.avatar" link preview/>
|
||||
<MkUserName :user="invite.usedBy" :nowrap="false"/>
|
||||
<div v-if="moderator">({{ invite.usedBy.id }})</div>
|
||||
</div>
|
||||
<div v-else>{{ i18n.ts.unknown }} ({{ i18n.ts.waitingForMailAuth }})</div>
|
||||
</div>
|
||||
<div v-if="invite.expiresAt && !invite.used">
|
||||
<div :class="$style.label">{{ i18n.ts.expirationDate }}</div>
|
||||
<div><MkTime :time="invite.expiresAt" mode="absolute"/></div>
|
||||
</div>
|
||||
<div v-if="invite.usedAt">
|
||||
<div :class="$style.label">{{ i18n.ts.inviteCodeUsedAt }}</div>
|
||||
<div><MkTime :time="invite.usedAt" mode="absolute"/></div>
|
||||
</div>
|
||||
<div v-if="moderator">
|
||||
<div :class="$style.label">{{ i18n.ts.createdAt }}</div>
|
||||
<div><MkTime :time="invite.createdAt" mode="absolute"/></div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.buttons">
|
||||
<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</MkButton>
|
||||
<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
|
||||
</div>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
invite: misskey.entities.Invite;
|
||||
moderator?: boolean;
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
(event: 'deleted', value: string): void;
|
||||
}>();
|
||||
|
||||
const isExpired = computed(() => {
|
||||
return props.invite.expiresAt && new Date(props.invite.expiresAt) < new Date();
|
||||
});
|
||||
|
||||
function deleteCode() {
|
||||
os.apiWithDialog('invite/delete', {
|
||||
inviteId: props.invite.id,
|
||||
});
|
||||
emits('deleted', props.invite.id);
|
||||
}
|
||||
|
||||
function copyInviteCode() {
|
||||
copyToClipboard(props.invite.code);
|
||||
os.success();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
grid-gap: 12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.85em;
|
||||
padding: 0 0 8px 0;
|
||||
user-select: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
--height: 24px;
|
||||
width: var(--height);
|
||||
height: var(--height);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<component
|
||||
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substr(local.length) : url" :rel="rel" :target="target"
|
||||
:is="self ? 'MkA' : 'a'" ref="el" style="word-break: break-all;" class="_link" :[attr]="self ? url.substring(local.length) : url" :rel="rel" :target="target"
|
||||
:title="url"
|
||||
>
|
||||
<slot></slot>
|
||||
|
@@ -20,7 +20,7 @@
|
||||
<template v-if="hide">
|
||||
<div :class="$style.hiddenText">
|
||||
<div :class="$style.hiddenTextWrapper">
|
||||
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-alert-triangle"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
|
||||
<b v-if="image.isSensitive" style="display: block;"><i class="ti ti-eye-exclamation"></i> {{ i18n.ts.sensitive }}{{ defaultStore.state.enableDataSaverMode ? ` (${i18n.ts.image}${image.size ? ' ' + bytes(image.size) : ''})` : '' }}</b>
|
||||
<b v-else style="display: block;"><i class="ti ti-photo"></i> {{ defaultStore.state.enableDataSaverMode && image.size ? bytes(image.size) : i18n.ts.image }}</b>
|
||||
<span style="display: block;">{{ i18n.ts.clickToShow }}</span>
|
||||
</div>
|
||||
@@ -30,9 +30,10 @@
|
||||
<div :class="$style.indicators">
|
||||
<div v-if="['image/gif', 'image/apng'].includes(image.type)" :class="$style.indicator">GIF</div>
|
||||
<div v-if="image.comment" :class="$style.indicator">ALT</div>
|
||||
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);">NSFW</div>
|
||||
<div v-if="image.isSensitive" :class="$style.indicator" style="color: var(--warn);" :title="i18n.ts.sensitive"><i class="ti ti-eye-exclamation"></i></div>
|
||||
</div>
|
||||
<button :class="$style.menu" class="_button" @click.stop="showMenu"><i class="ti ti-dots" style="vertical-align: middle;"></i></button>
|
||||
<i class="ti ti-eye-off" :class="$style.hide" @click.stop="hide = true"></i>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -113,6 +114,21 @@ function showMenu(ev: MouseEvent) {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 6px;
|
||||
background-color: var(--fg);
|
||||
color: var(--accentLighten);
|
||||
font-size: 12px;
|
||||
opacity: .5;
|
||||
padding: 5px 8px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.hiddenTextWrapper {
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
@@ -137,8 +153,8 @@ function showMenu(ev: MouseEvent) {
|
||||
backdrop-filter: var(--blur, blur(15px));
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
text-align: center;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
|
@@ -113,8 +113,10 @@ onMounted(() => {
|
||||
right: 0,
|
||||
},
|
||||
imageClickAction: 'close',
|
||||
tapAction: 'toggle-controls',
|
||||
tapAction: 'close',
|
||||
bgOpacity: 1,
|
||||
showAnimationDuration: 100,
|
||||
hideAnimationDuration: 100,
|
||||
pswpModule: PhotoSwipe,
|
||||
});
|
||||
|
||||
|
@@ -17,8 +17,8 @@
|
||||
controls
|
||||
@contextmenu.stop
|
||||
>
|
||||
<source
|
||||
:src="video.url"
|
||||
<source
|
||||
:src="video.url"
|
||||
:type="video.type"
|
||||
>
|
||||
</video>
|
||||
|
@@ -59,8 +59,8 @@ function draw(): void {
|
||||
|
||||
polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`;
|
||||
|
||||
headX = _polylinePoints[_polylinePoints.length - 1][0];
|
||||
headY = _polylinePoints[_polylinePoints.length - 1][1];
|
||||
headX = _polylinePoints.at(-1)![0];
|
||||
headY = _polylinePoints.at(-1)![1];
|
||||
}
|
||||
|
||||
watch(() => props.src, draw, { immediate: true });
|
||||
|
@@ -165,6 +165,7 @@ import { getNoteSummary } from '@/scripts/get-note-summary';
|
||||
import { MenuItem } from '@/types/menu';
|
||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||
import { showMovedDialog } from '@/scripts/show-moved-dialog';
|
||||
import { shouldCollapsed } from '@/scripts/collapsed';
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
@@ -204,17 +205,7 @@ let appearNote = $computed(() => isRenote ? note.renote as misskey.entities.Note
|
||||
const isMyRenote = $i && ($i.id === note.userId);
|
||||
const showContent = ref(false);
|
||||
const urls = appearNote.text ? extractUrlFromMfm(mfm.parse(appearNote.text)) : null;
|
||||
const isLong = (appearNote.cw == null && appearNote.text != null && (
|
||||
(appearNote.text.includes('$[x2')) ||
|
||||
(appearNote.text.includes('$[x3')) ||
|
||||
(appearNote.text.includes('$[x4')) ||
|
||||
(appearNote.text.includes('$[scale')) ||
|
||||
(appearNote.text.includes('$[position')) ||
|
||||
(appearNote.text.split('\n').length > 9) ||
|
||||
(appearNote.text.length > 500) ||
|
||||
(appearNote.files.length >= 5) ||
|
||||
(urls && urls.length >= 4)
|
||||
));
|
||||
const isLong = shouldCollapsed(appearNote);
|
||||
const collapsed = ref(appearNote.cw == null && isLong);
|
||||
const isDeleted = ref(false);
|
||||
const muted = ref(checkWordMute(appearNote, $i, defaultStore.state.mutedWords));
|
||||
@@ -222,7 +213,7 @@ const translation = ref<any>(null);
|
||||
const translating = ref(false);
|
||||
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
|
||||
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || appearNote.userId === $i.id);
|
||||
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId)) || (appearNote.myReaction != null)));
|
||||
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
|
||||
|
||||
const keymap = {
|
||||
'r': () => reply(true),
|
||||
@@ -259,6 +250,17 @@ useTooltip(renoteButton, async (showing) => {
|
||||
}, {}, 'closed');
|
||||
});
|
||||
|
||||
type Visibility = 'public' | 'home' | 'followers' | 'specified';
|
||||
|
||||
// defaultStore.state.visibilityがstringなためstringも受け付けている
|
||||
function smallerVisibility(a: Visibility | string, b: Visibility | string): Visibility {
|
||||
if (a === 'specified' || b === 'specified') return 'specified';
|
||||
if (a === 'followers' || b === 'followers') return 'followers';
|
||||
if (a === 'home' || b === 'home') return 'home';
|
||||
// if (a === 'public' || b === 'public')
|
||||
return 'public';
|
||||
}
|
||||
|
||||
function renote(viaKeyboard = false) {
|
||||
pleaseLogin();
|
||||
showMovedDialog();
|
||||
@@ -309,7 +311,12 @@ function renote(viaKeyboard = false) {
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
const configuredVisibility = defaultStore.state.rememberNoteVisibility ? defaultStore.state.visibility : defaultStore.state.defaultNoteVisibility;
|
||||
const localOnly = defaultStore.state.rememberNoteVisibility ? defaultStore.state.localOnly : defaultStore.state.defaultNoteLocalOnly;
|
||||
|
||||
os.api('notes/create', {
|
||||
localOnly,
|
||||
visibility: smallerVisibility(appearNote.visibility, configuredVisibility),
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
os.toast(i18n.ts.renoted);
|
||||
|
@@ -293,7 +293,7 @@ function renote(viaKeyboard = false) {
|
||||
const y = rect.top + (el.offsetHeight / 2);
|
||||
os.popup(MkRippleEffect, { x, y }, {}, 'end');
|
||||
}
|
||||
|
||||
|
||||
os.api('notes/create', {
|
||||
renoteId: appearNote.id,
|
||||
}).then(() => {
|
||||
|
@@ -17,25 +17,27 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div :class="$style.root" style="container-type: inline-size;">
|
||||
<div ref="contents" :class="$style.root" style="container-type: inline-size;">
|
||||
<RouterView :key="reloadCount" :router="router"/>
|
||||
</div>
|
||||
</MkWindow>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ComputedRef, onMounted, onUnmounted, provide } from 'vue';
|
||||
import { ComputedRef, onMounted, onUnmounted, provide, shallowRef } from 'vue';
|
||||
import RouterView from '@/components/global/RouterView.vue';
|
||||
import MkWindow from '@/components/MkWindow.vue';
|
||||
import { popout as _popout } from '@/scripts/popout';
|
||||
import copyToClipboard from '@/scripts/copy-to-clipboard';
|
||||
import { url } from '@/config';
|
||||
import { mainRouter, routes } from '@/router';
|
||||
import { Router } from '@/nirax';
|
||||
import { mainRouter, routes, page } from '@/router';
|
||||
import { $i } from '@/account';
|
||||
import { Router, useScrollPositionManager } from '@/nirax';
|
||||
import { i18n } from '@/i18n';
|
||||
import { PageMetadata, provideMetadataReceiver } from '@/scripts/page-metadata';
|
||||
import { openingWindowsCount } from '@/os';
|
||||
import { claimAchievement } from '@/scripts/achievements';
|
||||
import { getScrollContainer } from '@/scripts/scroll';
|
||||
|
||||
const props = defineProps<{
|
||||
initialPath: string;
|
||||
@@ -45,8 +47,9 @@ defineEmits<{
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const router = new Router(routes, props.initialPath);
|
||||
const router = new Router(routes, props.initialPath, !!$i, page(() => import('@/pages/not-found.vue')));
|
||||
|
||||
const contents = shallowRef<HTMLElement>();
|
||||
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
|
||||
let windowEl = $shallowRef<InstanceType<typeof MkWindow>>();
|
||||
const history = $ref<{ path: string; key: any; }[]>([{
|
||||
@@ -117,7 +120,7 @@ const contextmenu = $computed(() => ([{
|
||||
|
||||
function back() {
|
||||
history.pop();
|
||||
router.replace(history[history.length - 1].path, history[history.length - 1].key);
|
||||
router.replace(history.at(-1)!.path, history.at(-1)!.key);
|
||||
}
|
||||
|
||||
function reload() {
|
||||
@@ -138,6 +141,8 @@ function popout() {
|
||||
windowEl.close();
|
||||
}
|
||||
|
||||
useScrollPositionManager(() => getScrollContainer(contents.value), router);
|
||||
|
||||
onMounted(() => {
|
||||
openingWindowsCount.value++;
|
||||
if (openingWindowsCount.value >= 3) {
|
||||
|
@@ -21,14 +21,14 @@
|
||||
|
||||
<div v-else ref="rootEl">
|
||||
<div v-show="pagination.reversed && more" key="_more_" class="_margin">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMoreAhead : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMoreAhead">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
</div>
|
||||
<slot :items="items" :fetching="fetching || moreFetching"></slot>
|
||||
<slot :items="Array.from(items.values())" :fetching="fetching || moreFetching"></slot>
|
||||
<div v-show="!pagination.reversed && more" key="_more_" class="_margin">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? fetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
|
||||
<MkButton v-if="!moreFetching" v-appear="(enableInfiniteScroll && !props.disableAutoLoad) ? appearFetchMore : null" :class="$style.more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary rounded @click="fetchMore">
|
||||
{{ i18n.ts.loadMore }}
|
||||
</MkButton>
|
||||
<MkLoading v-else class="loading"/>
|
||||
@@ -50,6 +50,7 @@ import { i18n } from '@/i18n';
|
||||
|
||||
const SECOND_FETCH_LIMIT = 30;
|
||||
const TOLERANCE = 16;
|
||||
const APPEAR_MINIMUM_INTERVAL = 600;
|
||||
|
||||
export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints> = {
|
||||
endpoint: E;
|
||||
@@ -71,6 +72,16 @@ export type Paging<E extends keyof misskey.Endpoints = keyof misskey.Endpoints>
|
||||
|
||||
pageEl?: HTMLElement;
|
||||
};
|
||||
|
||||
type MisskeyEntityMap = Map<string, MisskeyEntity>;
|
||||
|
||||
function arrayToEntries(entities: MisskeyEntity[]): [string, MisskeyEntity][] {
|
||||
return entities.map(en => [en.id, en]);
|
||||
}
|
||||
|
||||
function concatMapWithArray(map: MisskeyEntityMap, entities: MisskeyEntity[]): MisskeyEntityMap {
|
||||
return new Map([...map, ...arrayToEntries(entities)]);
|
||||
}
|
||||
</script>
|
||||
<script lang="ts" setup>
|
||||
import { infoImageUrl } from '@/instance';
|
||||
@@ -94,21 +105,38 @@ let backed = $ref(false);
|
||||
|
||||
let scrollRemove = $ref<(() => void) | null>(null);
|
||||
|
||||
const items = ref<MisskeyEntity[]>([]);
|
||||
const queue = ref<MisskeyEntity[]>([]);
|
||||
/**
|
||||
* 表示するアイテムのソース
|
||||
* 最新が0番目
|
||||
*/
|
||||
const items = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
/**
|
||||
* タブが非アクティブなどの場合に更新を貯めておく
|
||||
* 最新が0番目
|
||||
*/
|
||||
const queue = ref<MisskeyEntityMap>(new Map());
|
||||
|
||||
const offset = ref(0);
|
||||
|
||||
/**
|
||||
* 初期化中かどうか(trueならMkLoadingで全て隠す)
|
||||
*/
|
||||
const fetching = ref(true);
|
||||
|
||||
const moreFetching = ref(false);
|
||||
const more = ref(false);
|
||||
const preventAppearFetchMore = ref(false);
|
||||
const preventAppearFetchMoreTimer = ref<number | null>(null);
|
||||
const isBackTop = ref(false);
|
||||
const empty = computed(() => items.value.length === 0);
|
||||
const empty = computed(() => items.value.size === 0);
|
||||
const error = ref(false);
|
||||
const {
|
||||
enableInfiniteScroll,
|
||||
} = defaultStore.reactiveState;
|
||||
|
||||
const contentEl = $computed(() => props.pagination.pageEl ?? rootEl);
|
||||
const scrollableElement = $computed(() => getScrollContainer(contentEl));
|
||||
const scrollableElement = $computed(() => contentEl ? getScrollContainer(contentEl) : document.body);
|
||||
|
||||
const visibility = useDocumentVisibility();
|
||||
|
||||
@@ -133,9 +161,9 @@ watch([() => props.pagination.reversed, $$(scrollableElement)], () => {
|
||||
}, { immediate: true });
|
||||
|
||||
watch($$(rootEl), () => {
|
||||
scrollObserver.disconnect();
|
||||
scrollObserver?.disconnect();
|
||||
nextTick(() => {
|
||||
if (rootEl) scrollObserver.observe(rootEl);
|
||||
if (rootEl) scrollObserver?.observe(rootEl);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,12 +183,13 @@ if (props.pagination.params && isRef(props.pagination.params)) {
|
||||
}
|
||||
|
||||
watch(queue, (a, b) => {
|
||||
if (a.length === 0 && b.length === 0) return;
|
||||
emit('queue', queue.value.length);
|
||||
if (a.size === 0 && b.size === 0) return;
|
||||
emit('queue', queue.value.size);
|
||||
}, { deep: true });
|
||||
|
||||
async function init(): Promise<void> {
|
||||
queue.value = [];
|
||||
items.value = new Map();
|
||||
queue.value = new Map();
|
||||
fetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
@@ -173,11 +202,11 @@ async function init(): Promise<void> {
|
||||
}
|
||||
|
||||
if (res.length === 0 || props.pagination.noPaging) {
|
||||
items.value = res;
|
||||
concatItems(res);
|
||||
more.value = false;
|
||||
} else {
|
||||
if (props.pagination.reversed) moreFetching.value = true;
|
||||
items.value = res;
|
||||
concatItems(res);
|
||||
more.value = true;
|
||||
}
|
||||
|
||||
@@ -191,12 +220,11 @@ async function init(): Promise<void> {
|
||||
}
|
||||
|
||||
const reload = (): Promise<void> => {
|
||||
items.value = [];
|
||||
return init();
|
||||
};
|
||||
|
||||
const fetchMore = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
@@ -205,7 +233,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
untilId: items.value[items.value.length - 1].id,
|
||||
untilId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
}).then(res => {
|
||||
for (let i = 0; i < res.length; i++) {
|
||||
@@ -217,7 +245,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
const oldHeight = scrollableElement ? scrollableElement.scrollHeight : getBodyScrollHeight();
|
||||
const oldScroll = scrollableElement ? scrollableElement.scrollTop : window.scrollY;
|
||||
|
||||
items.value = items.value.concat(_res);
|
||||
items.value = concatMapWithArray(items.value, _res);
|
||||
|
||||
return nextTick(() => {
|
||||
if (scrollableElement) {
|
||||
@@ -237,7 +265,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
@@ -248,7 +276,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
moreFetching.value = false;
|
||||
});
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
moreFetching.value = false;
|
||||
}
|
||||
@@ -260,7 +288,7 @@ const fetchMore = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const fetchMoreAhead = async (): Promise<void> => {
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.length === 0) return;
|
||||
if (!more.value || fetching.value || moreFetching.value || items.value.size === 0) return;
|
||||
moreFetching.value = true;
|
||||
const params = props.pagination.params ? isRef(props.pagination.params) ? props.pagination.params.value : props.pagination.params : {};
|
||||
await os.api(props.pagination.endpoint, {
|
||||
@@ -269,14 +297,14 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||
...(props.pagination.offsetMode ? {
|
||||
offset: offset.value,
|
||||
} : {
|
||||
sinceId: items.value[items.value.length - 1].id,
|
||||
sinceId: Array.from(items.value.keys()).at(-1),
|
||||
}),
|
||||
}).then(res => {
|
||||
if (res.length === 0) {
|
||||
items.value = items.value.concat(res);
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = false;
|
||||
} else {
|
||||
items.value = items.value.concat(res);
|
||||
items.value = concatMapWithArray(items.value, res);
|
||||
more.value = true;
|
||||
}
|
||||
offset.value += res.length;
|
||||
@@ -286,7 +314,32 @@ const fetchMoreAhead = async (): Promise<void> => {
|
||||
});
|
||||
};
|
||||
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl, TOLERANCE);
|
||||
/**
|
||||
* Appear(IntersectionObserver)によってfetchMoreが呼ばれる場合、
|
||||
* APPEAR_MINIMUM_INTERVALミリ秒以内に2回fetchMoreが呼ばれるのを防ぐ
|
||||
*/
|
||||
const fetchMoreApperTimeoutFn = (): void => {
|
||||
preventAppearFetchMore.value = false;
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
};
|
||||
const fetchMoreAppearTimeout = (): void => {
|
||||
preventAppearFetchMore.value = true;
|
||||
preventAppearFetchMoreTimer.value = window.setTimeout(fetchMoreApperTimeoutFn, APPEAR_MINIMUM_INTERVAL);
|
||||
};
|
||||
|
||||
const appearFetchMore = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMore();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const appearFetchMoreAhead = async (): Promise<void> => {
|
||||
if (preventAppearFetchMore.value) return;
|
||||
await fetchMoreAhead();
|
||||
fetchMoreAppearTimeout();
|
||||
};
|
||||
|
||||
const isTop = (): boolean => isBackTop.value || (props.pagination.reversed ? isBottomVisible : isTopVisible)(contentEl!, TOLERANCE);
|
||||
|
||||
watch(visibility, () => {
|
||||
if (visibility.value === 'hidden') {
|
||||
@@ -308,10 +361,15 @@ watch(visibility, () => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 最新のものとして1つだけアイテムを追加する
|
||||
* ストリーミングから降ってきたアイテムはこれで追加する
|
||||
* @param item アイテム
|
||||
*/
|
||||
const prepend = (item: MisskeyEntity): void => {
|
||||
// 初回表示時はunshiftだけでOK
|
||||
if (!rootEl) {
|
||||
items.value.unshift(item);
|
||||
if (items.value.size === 0) {
|
||||
items.value.set(item.id, item);
|
||||
fetching.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,38 +377,55 @@ const prepend = (item: MisskeyEntity): void => {
|
||||
else prependQueue(item);
|
||||
};
|
||||
|
||||
/**
|
||||
* 新着アイテムをitemsの先頭に追加し、displayLimitを適用する
|
||||
* @param newItems 新しいアイテムの配列
|
||||
*/
|
||||
function unshiftItems(newItems: MisskeyEntity[]) {
|
||||
const length = newItems.length + items.value.length;
|
||||
items.value = [...newItems, ...items.value].slice(0, props.displayLimit);
|
||||
const length = newItems.length + items.value.size;
|
||||
items.value = new Map([...arrayToEntries(newItems), ...items.value].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 古いアイテムをitemsの末尾に追加し、displayLimitを適用する
|
||||
* @param oldItems 古いアイテムの配列
|
||||
*/
|
||||
function concatItems(oldItems: MisskeyEntity[]) {
|
||||
const length = oldItems.length + items.value.size;
|
||||
items.value = new Map([...items.value, ...arrayToEntries(oldItems)].slice(0, props.displayLimit));
|
||||
|
||||
if (length >= props.displayLimit) more.value = true;
|
||||
}
|
||||
|
||||
function executeQueue() {
|
||||
if (queue.value.length === 0) return;
|
||||
unshiftItems(queue.value);
|
||||
queue.value = [];
|
||||
unshiftItems(Array.from(queue.value.values()));
|
||||
queue.value = new Map();
|
||||
}
|
||||
|
||||
function prependQueue(newItem: MisskeyEntity) {
|
||||
queue.value.unshift(newItem);
|
||||
if (queue.value.length >= props.displayLimit) {
|
||||
queue.value.pop();
|
||||
}
|
||||
queue.value = new Map([[newItem.id, newItem], ...queue.value].slice(0, props.displayLimit) as [string, MisskeyEntity][]);
|
||||
}
|
||||
|
||||
/*
|
||||
* アイテムを末尾に追加する(使うの?)
|
||||
*/
|
||||
const appendItem = (item: MisskeyEntity): void => {
|
||||
items.value.push(item);
|
||||
items.value.set(item.id, item);
|
||||
};
|
||||
|
||||
const removeItem = (finder: (item: MisskeyEntity) => boolean) => {
|
||||
const i = items.value.findIndex(finder);
|
||||
items.value.splice(i, 1);
|
||||
const removeItem = (id: string) => {
|
||||
items.value.delete(id);
|
||||
queue.value.delete(id);
|
||||
};
|
||||
|
||||
const updateItem = (id: MisskeyEntity['id'], replacer: (old: MisskeyEntity) => MisskeyEntity): void => {
|
||||
const i = items.value.findIndex(item => item.id === id);
|
||||
items.value[i] = replacer(items.value[i]);
|
||||
const item = items.value.get(id);
|
||||
if (item) items.value.set(id, replacer(item));
|
||||
|
||||
const queueItem = queue.value.get(id);
|
||||
if (queueItem) queue.value.set(id, replacer(queueItem));
|
||||
};
|
||||
|
||||
const inited = init();
|
||||
@@ -364,7 +439,7 @@ onDeactivated(() => {
|
||||
});
|
||||
|
||||
function toBottom() {
|
||||
scrollToBottom(contentEl);
|
||||
scrollToBottom(contentEl!);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -388,7 +463,11 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(timerForSetPause);
|
||||
timerForSetPause = null;
|
||||
}
|
||||
scrollObserver.disconnect();
|
||||
if (preventAppearFetchMoreTimer.value) {
|
||||
clearTimeout(preventAppearFetchMoreTimer.value);
|
||||
preventAppearFetchMoreTimer.value = null;
|
||||
}
|
||||
scrollObserver?.disconnect();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
|
@@ -66,7 +66,7 @@
|
||||
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
|
||||
</div>
|
||||
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
|
||||
<XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
|
||||
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
|
||||
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text"/>
|
||||
<div v-if="showingOptions" style="padding: 8px 16px;">
|
||||
@@ -410,7 +410,11 @@ function updateFileName(file, name) {
|
||||
files[files.findIndex(x => x.id === file.id)].name = name;
|
||||
}
|
||||
|
||||
function upload(file: File, name?: string) {
|
||||
function replaceFile(file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void {
|
||||
files[files.findIndex(x => x.id === file.id)] = newFile;
|
||||
}
|
||||
|
||||
function upload(file: File, name?: string): void {
|
||||
uploadFile(file, defaultStore.state.uploadFolder, name).then(res => {
|
||||
files.push(res);
|
||||
});
|
||||
@@ -560,7 +564,7 @@ async function onPaste(ev: ClipboardEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
quoteId = paste.substr(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
|
||||
quoteId = paste.substring(url.length).match(/^\/notes\/(.+?)\/?$/)[1];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -903,6 +907,7 @@ defineExpose({
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
@@ -1015,10 +1020,12 @@ defineExpose({
|
||||
|
||||
.preview {
|
||||
padding: 16px 20px 0 20px;
|
||||
max-height: 150px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.targetNote {
|
||||
padding: 0 20px 16px 20px;
|
||||
padding: 10px 20px 16px 20px;
|
||||
}
|
||||
|
||||
.withQuote {
|
||||
|
@@ -5,7 +5,7 @@
|
||||
<div :class="$style.file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)">
|
||||
<MkDriveFileThumbnail :data-id="element.id" :class="$style.thumbnail" :file="element" fit="cover"/>
|
||||
<div v-if="element.isSensitive" :class="$style.sensitive">
|
||||
<i class="ti ti-alert-triangle" style="margin: auto;"></i>
|
||||
<i class="ti ti-eye-exclamation" style="margin: auto;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
@@ -30,8 +31,9 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
(ev: 'update:modelValue', value: any[]): void;
|
||||
(ev: 'detach', id: string): void;
|
||||
(ev: 'changeSensitive'): void;
|
||||
(ev: 'changeName'): void;
|
||||
(ev: 'changeSensitive', file: misskey.entities.DriveFile, isSensitive: boolean): void;
|
||||
(ev: 'changeName', file: misskey.entities.DriveFile, newName: string): void;
|
||||
(ev: 'replaceFile', file: misskey.entities.DriveFile, newFile: misskey.entities.DriveFile): void;
|
||||
}>();
|
||||
|
||||
let menuShowing = false;
|
||||
@@ -85,8 +87,15 @@ async function describe(file) {
|
||||
}, 'closed');
|
||||
}
|
||||
|
||||
function showFileMenu(file, ev: MouseEvent) {
|
||||
async function crop(file: misskey.entities.DriveFile): Promise<void> {
|
||||
const newFile = await os.cropImage(file, { aspectRatio: NaN });
|
||||
emit('replaceFile', file, newFile);
|
||||
}
|
||||
|
||||
function showFileMenu(file: misskey.entities.DriveFile, ev: MouseEvent): void {
|
||||
if (menuShowing) return;
|
||||
|
||||
const isImage = file.type.startsWith('image/');
|
||||
os.popupMenu([{
|
||||
text: i18n.ts.renameFile,
|
||||
icon: 'ti ti-forms',
|
||||
@@ -99,7 +108,11 @@ function showFileMenu(file, ev: MouseEvent) {
|
||||
text: i18n.ts.describeFile,
|
||||
icon: 'ti ti-text-caption',
|
||||
action: () => { describe(file); },
|
||||
}, {
|
||||
}, ...isImage ? [{
|
||||
text: i18n.ts.cropImage,
|
||||
icon: 'ti ti-crop',
|
||||
action: () : void => { crop(file); },
|
||||
}] : [], {
|
||||
text: i18n.ts.attachCancel,
|
||||
icon: 'ti ti-circle-x',
|
||||
action: () => { detachMedia(file.id); },
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<MkModal ref="modal" :preferType="'dialog'" @click="modal.close()" @closed="onModalClosed()">
|
||||
<MkPostForm ref="form" style="margin: 0 auto auto auto;" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/>
|
||||
<MkPostForm ref="form" :class="$style.form" v-bind="props" autofocus freezeAfterPosted @posted="onPosted" @cancel="modal.close()" @esc="modal.close()"/>
|
||||
</MkModal>
|
||||
</template>
|
||||
|
||||
@@ -44,3 +44,10 @@ function onModalClosed() {
|
||||
emit('closed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.form {
|
||||
max-height: 100%;
|
||||
margin: 0 auto auto auto;
|
||||
}
|
||||
</style>
|
||||
|
@@ -6,7 +6,7 @@
|
||||
:class="[$style.root, { [$style.reacted]: note.myReaction == reaction, [$style.canToggle]: canToggle, [$style.large]: defaultStore.state.largeNoteReactions }]"
|
||||
@click="toggleReaction()"
|
||||
>
|
||||
<MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substr(1, reaction.length - 2)]"/>
|
||||
<MkReactionIcon :class="$style.icon" :reaction="reaction" :emojiUrl="note.reactionEmojis[reaction.substring(1, reaction.length - 1)]"/>
|
||||
<span :class="$style.count">{{ count }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
@@ -90,6 +90,7 @@ onMounted(async () => {
|
||||
ticks: {
|
||||
callback: (value, index, values) => value + '%',
|
||||
},
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
|
@@ -9,7 +9,10 @@
|
||||
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
|
||||
<div style="text-align: center;">
|
||||
<div>{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
|
||||
<div style="font-weight: bold; margin-top: 0.5em;">{{ i18n.ts.beSureToReadThisAsItIsImportant }}</div>
|
||||
</div>
|
||||
|
||||
<MkFolder v-if="availableServerRules" :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts.serverRules }}</template>
|
||||
@@ -19,7 +22,7 @@
|
||||
<li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
|
||||
</ol>
|
||||
|
||||
<MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
|
||||
<MkSwitch :modelValue="agreeServerRules" style="margin-top: 16px;" @update:modelValue="updateAgreeServerRules">{{ i18n.ts.agree }}</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="availableTos" :defaultOpen="true">
|
||||
@@ -28,7 +31,7 @@
|
||||
|
||||
<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
|
||||
|
||||
<MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
|
||||
<MkSwitch :modelValue="agreeTos" style="margin-top: 16px;" @update:modelValue="updateAgreeTos">{{ i18n.ts.agree }}</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
@@ -37,7 +40,7 @@
|
||||
|
||||
<a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
|
||||
|
||||
<MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch>
|
||||
<MkSwitch :modelValue="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree @update:modelValue="updateAgreeNote">{{ i18n.ts.agree }}</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<div v-if="!agreed" style="text-align: center;">{{ i18n.ts.pleaseAgreeAllToContinue }}</div>
|
||||
@@ -52,13 +55,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { instance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkInfo from '@/components/MkInfo.vue';
|
||||
import * as os from '@/os';
|
||||
|
||||
const availableServerRules = instance.serverRules.length > 0;
|
||||
const availableTos = instance.tosUrl != null;
|
||||
@@ -75,6 +79,48 @@ const emit = defineEmits<{
|
||||
(ev: 'cancel'): void;
|
||||
(ev: 'done'): void;
|
||||
}>();
|
||||
|
||||
async function updateAgreeServerRules(v: boolean) {
|
||||
if (v) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.ts.doYouAgree,
|
||||
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.serverRules }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
agreeServerRules.value = true;
|
||||
} else {
|
||||
agreeServerRules.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgreeTos(v: boolean) {
|
||||
if (v) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.ts.doYouAgree,
|
||||
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.termsOfService }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
agreeTos.value = true;
|
||||
} else {
|
||||
agreeTos.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAgreeNote(v: boolean) {
|
||||
if (v) {
|
||||
const confirm = await os.confirm({
|
||||
type: 'question',
|
||||
title: i18n.ts.doYouAgree,
|
||||
text: i18n.t('iHaveReadXCarefullyAndAgree', { x: i18n.ts.basicNotesBeforeCreateAccount }),
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
agreeNote.value = true;
|
||||
} else {
|
||||
agreeNote.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
|
@@ -32,7 +32,8 @@
|
||||
</path>
|
||||
</svg>
|
||||
-->
|
||||
<svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg" style="position: absolute; top: -32px; left: -32px;">
|
||||
<!-- MFMで上位レイヤーに表示されるため、リンクをクリックできるようにstyleにpointer-events: none;を付与。 -->
|
||||
<svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg" style="position: absolute; top: -32px; left: -32px; pointer-events: none;">
|
||||
<path
|
||||
style="transform-origin: center; transform-box: fill-box;"
|
||||
:transform="`translate(${particle.x} ${particle.y})`"
|
||||
@@ -115,6 +116,5 @@ onUnmounted(() => {
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
@@ -15,9 +15,12 @@
|
||||
<summary>{{ i18n.ts.poll }}</summary>
|
||||
<MkPoll :note="note"/>
|
||||
</details>
|
||||
<button v-if="collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
|
||||
<button v-if="isLong && collapsed" :class="$style.fade" class="_button" @click="collapsed = false">
|
||||
<span :class="$style.fadeLabel">{{ i18n.ts.showMore }}</span>
|
||||
</button>
|
||||
<button v-else-if="isLong && !collapsed" :class="$style.showLess" class="_button" @click="collapsed = true">
|
||||
<span :class="$style.showLessLabel">{{ i18n.ts.showLess }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -28,16 +31,15 @@ import MkMediaList from '@/components/MkMediaList.vue';
|
||||
import MkPoll from '@/components/MkPoll.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { shouldCollapsed } from '@/scripts/collapsed';
|
||||
|
||||
const props = defineProps<{
|
||||
note: misskey.entities.Note;
|
||||
}>();
|
||||
|
||||
const collapsed = $ref(
|
||||
props.note.cw == null && props.note.text != null && (
|
||||
(props.note.text.split('\n').length > 9) ||
|
||||
(props.note.text.length > 500)
|
||||
));
|
||||
const isLong = shouldCollapsed(props.note);
|
||||
|
||||
const collapsed = $ref(isLong);
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
@@ -86,4 +88,20 @@ const collapsed = $ref(
|
||||
font-style: oblique;
|
||||
color: var(--renote);
|
||||
}
|
||||
|
||||
.showLess {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
position: sticky;
|
||||
bottom: calc(var(--stickyBottom, 0px) + 14px);
|
||||
}
|
||||
|
||||
.showLessLabel {
|
||||
display: inline-block;
|
||||
background: var(--popup);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 2px 6px rgb(0 0 0 / 20%);
|
||||
}
|
||||
</style>
|
||||
|
@@ -46,7 +46,7 @@ defineProps<{
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
||||
> .items {
|
||||
> .item {
|
||||
display: flex;
|
||||
|
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<div v-else>
|
||||
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
|
||||
<component :is="self ? 'MkA' : 'a'" :class="[$style.link, { [$style.compact]: compact }]" :[attr]="self ? url.substring(local.length) : url" rel="nofollow noopener" :target="target" :title="url">
|
||||
<div v-if="thumbnail" :class="$style.thumbnail" :style="`background-image: url('${thumbnail}')`">
|
||||
</div>
|
||||
<article :class="$style.body">
|
||||
@@ -52,19 +52,21 @@
|
||||
</footer>
|
||||
</article>
|
||||
</component>
|
||||
<div v-if="tweetId" :class="$style.action">
|
||||
<MkButton :small="true" inline @click="tweetExpanded = true">
|
||||
<i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<div v-if="!playerEnabled && player.url" :class="$style.action">
|
||||
<MkButton :small="true" inline @click="playerEnabled = true">
|
||||
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
|
||||
</MkButton>
|
||||
<MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()">
|
||||
<i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<template v-if="showActions">
|
||||
<div v-if="tweetId" :class="$style.action">
|
||||
<MkButton :small="true" inline @click="tweetExpanded = true">
|
||||
<i class="ti ti-brand-twitter"></i> {{ i18n.ts.expandTweet }}
|
||||
</MkButton>
|
||||
</div>
|
||||
<div v-if="!playerEnabled && player.url" :class="$style.action">
|
||||
<MkButton :small="true" inline @click="playerEnabled = true">
|
||||
<i class="ti ti-player-play"></i> {{ i18n.ts.enablePlayer }}
|
||||
</MkButton>
|
||||
<MkButton v-if="!isMobile" :small="true" inline @click="openPlayer()">
|
||||
<i class="ti ti-picture-in-picture"></i> {{ i18n.ts.openInWindow }}
|
||||
</MkButton>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -85,9 +87,11 @@ const props = withDefaults(defineProps<{
|
||||
url: string;
|
||||
detail?: boolean;
|
||||
compact?: boolean;
|
||||
showActions?: boolean;
|
||||
}>(), {
|
||||
detail: false,
|
||||
compact: false,
|
||||
showActions: true,
|
||||
});
|
||||
|
||||
const MOBILE_THRESHOLD = 500;
|
||||
|
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div :class="$style.root" :style="{ zIndex, top: top + 'px', left: left + 'px' }">
|
||||
<Transition :name="defaultStore.state.animation ? '_transition_zoom' : ''" @afterLeave="emit('closed')">
|
||||
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url"/>
|
||||
<MkUrlPreview v-if="showing" class="_popup _shadow" :url="url" :showActions="false"/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
@@ -15,13 +15,13 @@
|
||||
</div>
|
||||
<div :class="$style.status">
|
||||
<div :class="$style.statusItem">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ user.notesCount }}</span>
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.notes }}</p><span :class="$style.statusItemValue">{{ number(user.notesCount) }}</span>
|
||||
</div>
|
||||
<div :class="$style.statusItem">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ user.followingCount }}</span>
|
||||
<div v-if="isFfVisibleForMe(user)" :class="$style.statusItem">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.following }}</p><span :class="$style.statusItemValue">{{ number(user.followingCount) }}</span>
|
||||
</div>
|
||||
<div :class="$style.statusItem">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ user.followersCount }}</span>
|
||||
<div v-if="isFfVisibleForMe(user)" :class="$style.statusItem">
|
||||
<p :class="$style.statusItemLabel">{{ i18n.ts.followers }}</p><span :class="$style.statusItemValue">{{ number(user.followersCount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<MkFollowButton v-if="$i && user.id != $i.id" :class="$style.follow" :user="user" mini/>
|
||||
@@ -31,9 +31,11 @@
|
||||
<script lang="ts" setup>
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import number from '@/filters/number';
|
||||
import { userPage } from '@/filters/user';
|
||||
import { i18n } from '@/i18n';
|
||||
import { $i } from '@/account';
|
||||
import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe';
|
||||
|
||||
defineProps<{
|
||||
user: misskey.entities.UserDetailed;
|
||||
|
@@ -30,11 +30,11 @@
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.notes }}</div>
|
||||
<div>{{ number(user.notesCount) }}</div>
|
||||
</div>
|
||||
<div :class="$style.statusItem">
|
||||
<div v-if="isFfVisibleForMe(user)" :class="$style.statusItem">
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.following }}</div>
|
||||
<div>{{ number(user.followingCount) }}</div>
|
||||
</div>
|
||||
<div :class="$style.statusItem">
|
||||
<div v-if="isFfVisibleForMe(user)" :class="$style.statusItem">
|
||||
<div :class="$style.statusItemLabel">{{ i18n.ts.followers }}</div>
|
||||
<div>{{ number(user.followersCount) }}</div>
|
||||
</div>
|
||||
@@ -61,6 +61,7 @@ import number from '@/filters/number';
|
||||
import { i18n } from '@/i18n';
|
||||
import { defaultStore } from '@/store';
|
||||
import { $i } from '@/account';
|
||||
import { isFfVisibleForMe } from '@/scripts/isFfVisibleForMe';
|
||||
|
||||
const props = defineProps<{
|
||||
showing: boolean;
|
||||
@@ -88,7 +89,7 @@ onMounted(() => {
|
||||
user = props.q;
|
||||
} else {
|
||||
const query = props.q.startsWith('@') ?
|
||||
Acct.parse(props.q.substr(1)) :
|
||||
Acct.parse(props.q.substring(1)) :
|
||||
{ userId: props.q };
|
||||
|
||||
os.api('users/show', query).then(res => {
|
||||
@@ -195,7 +196,7 @@ onMounted(() => {
|
||||
.mfm {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
@@ -26,7 +26,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
@@ -23,7 +23,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
@@ -23,7 +23,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
@@ -90,7 +90,7 @@ async function follow() {
|
||||
.mfm {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
@@ -26,7 +26,7 @@ export const Default = {
|
||||
};
|
||||
},
|
||||
args: {
|
||||
|
||||
|
||||
},
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
|
@@ -29,11 +29,11 @@ export const Default = {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
await userEvent.click(a, { button: 2 });
|
||||
await userEvent.pointer({ keys: '[MouseRight]', target: a });
|
||||
await tick();
|
||||
const menu = canvas.getByRole('menu');
|
||||
await expect(menu).toBeInTheDocument();
|
||||
await userEvent.click(a, { button: 0 });
|
||||
await userEvent.click(a);
|
||||
a.blur();
|
||||
await tick();
|
||||
await expect(menu).not.toBeInTheDocument();
|
||||
|
@@ -1,9 +1,12 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { expect } from '@storybook/jest';
|
||||
import { userEvent, within } from '@storybook/testing-library';
|
||||
import { userEvent, waitFor, within } from '@storybook/testing-library';
|
||||
import { StoryObj } from '@storybook/vue3';
|
||||
import { i18n } from '@/i18n';
|
||||
import MkAd from './MkAd.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
|
||||
let lock: Promise<undefined> | undefined;
|
||||
|
||||
const common = {
|
||||
render(args) {
|
||||
return {
|
||||
@@ -25,39 +28,57 @@ const common = {
|
||||
template: '<MkAd v-bind="props" />',
|
||||
};
|
||||
},
|
||||
/* FIXME: disabled because it still didn’t pass after applying #11267
|
||||
async play({ canvasElement, args }) {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
const img = within(a).getByRole('img');
|
||||
await expect(img).toBeInTheDocument();
|
||||
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(1);
|
||||
const i = buttons[0];
|
||||
await expect(i).toBeInTheDocument();
|
||||
await userEvent.click(i);
|
||||
await expect(a).not.toBeInTheDocument();
|
||||
await expect(i).not.toBeInTheDocument();
|
||||
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
|
||||
const reduce = args.__hasReduce ? buttons[0] : null;
|
||||
const back = buttons[args.__hasReduce ? 1 : 0];
|
||||
if (reduce) {
|
||||
await expect(reduce).toBeInTheDocument();
|
||||
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
|
||||
if (lock) {
|
||||
console.warn('This test is unexpectedly running twice in parallel, fix it!');
|
||||
console.warn('See also: https://github.com/misskey-dev/misskey/issues/11267');
|
||||
await lock;
|
||||
}
|
||||
await expect(back).toBeInTheDocument();
|
||||
await expect(back).toHaveTextContent(i18n.ts._ad.back);
|
||||
await userEvent.click(back);
|
||||
if (reduce) {
|
||||
await expect(reduce).not.toBeInTheDocument();
|
||||
|
||||
let resolve: (value?: any) => void;
|
||||
lock = new Promise(r => resolve = r);
|
||||
|
||||
try {
|
||||
const canvas = within(canvasElement);
|
||||
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||
const img = within(a).getByRole('img');
|
||||
await expect(img).toBeInTheDocument();
|
||||
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(1);
|
||||
const i = buttons[0];
|
||||
await expect(i).toBeInTheDocument();
|
||||
await userEvent.click(i);
|
||||
await waitFor(() => expect(canvasElement).toHaveTextContent(i18n.ts._ad.back));
|
||||
await expect(a).not.toBeInTheDocument();
|
||||
await expect(i).not.toBeInTheDocument();
|
||||
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
|
||||
const reduce = args.__hasReduce ? buttons[0] : null;
|
||||
const back = buttons[args.__hasReduce ? 1 : 0];
|
||||
if (reduce) {
|
||||
await expect(reduce).toBeInTheDocument();
|
||||
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
|
||||
}
|
||||
await expect(back).toBeInTheDocument();
|
||||
await expect(back).toHaveTextContent(i18n.ts._ad.back);
|
||||
await userEvent.click(back);
|
||||
await waitFor(() => expect(canvas.queryByRole('img')).toBeTruthy());
|
||||
if (reduce) {
|
||||
await expect(reduce).not.toBeInTheDocument();
|
||||
}
|
||||
await expect(back).not.toBeInTheDocument();
|
||||
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(aAgain).toBeInTheDocument();
|
||||
const imgAgain = within(aAgain).getByRole('img');
|
||||
await expect(imgAgain).toBeInTheDocument();
|
||||
} finally {
|
||||
resolve!();
|
||||
lock = undefined;
|
||||
}
|
||||
await expect(back).not.toBeInTheDocument();
|
||||
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
|
||||
await expect(aAgain).toBeInTheDocument();
|
||||
const imgAgain = within(aAgain).getByRole('img');
|
||||
await expect(imgAgain).toBeInTheDocument();
|
||||
},
|
||||
*/
|
||||
args: {
|
||||
prefer: [],
|
||||
specify: {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<component :is="link ? MkA : 'span'" v-user-preview="preview ? user.id : undefined" v-bind="bound" class="_noSelect" :class="[$style.root, { [$style.animation]: animation, [$style.cat]: user.isCat, [$style.square]: squareAvatars }]" :style="{ color }" :title="acct(user)" @click="onClick">
|
||||
<img :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true"/>
|
||||
<MkImgWithBlurhash :class="$style.inner" :src="url" :hash="user?.avatarBlurhash" :cover="true" :onlyAvgColor="true"/>
|
||||
<MkUserOnlineIndicator v-if="indicator" :class="$style.indicator" :user="user"/>
|
||||
<div v-if="user.isCat" :class="[$style.ears]">
|
||||
<div :class="$style.earLeft">
|
||||
@@ -24,6 +24,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkImgWithBlurhash from '../MkImgWithBlurhash.vue';
|
||||
import MkA from './MkA.vue';
|
||||
import { getStaticImageUrl } from '@/scripts/media-proxy';
|
||||
import { extractAvgColorFromBlurhash } from '@/scripts/extract-avg-color-from-blurhash';
|
||||
|
@@ -18,7 +18,7 @@ const props = defineProps<{
|
||||
useOriginalSize?: boolean;
|
||||
}>();
|
||||
|
||||
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substr(1, props.name.length - 2) : props.name).replace('@.', ''));
|
||||
const customEmojiName = computed(() => (props.name[0] === ':' ? props.name.substring(1, props.name.length - 1) : props.name).replace('@.', ''));
|
||||
const isLocal = computed(() => !props.host && (customEmojiName.value.endsWith('@.') || !customEmojiName.value.includes('@')));
|
||||
|
||||
const rawUrl = computed(() => {
|
||||
|
@@ -199,7 +199,7 @@ export default function(props: {
|
||||
}
|
||||
const x = Math.min(parseFloat(token.props.args.x ?? '1'), 5);
|
||||
const y = Math.min(parseFloat(token.props.args.y ?? '1'), 5);
|
||||
style = `transform: scale(${x}, ${y});`;
|
||||
style = `transform: scale(${x}, ${y});`;
|
||||
scale = scale * Math.max(x, y);
|
||||
break;
|
||||
}
|
||||
@@ -256,7 +256,7 @@ export default function(props: {
|
||||
case 'mention': {
|
||||
return [h(MkMention, {
|
||||
key: Math.random(),
|
||||
host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) || host,
|
||||
host: (token.props.host == null && props.author && props.author.host != null ? props.author.host : token.props.host) ?? host,
|
||||
username: token.props.username,
|
||||
})];
|
||||
}
|
||||
|
@@ -9,7 +9,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import isChromatic from 'chromatic/isChromatic';
|
||||
import { onUnmounted } from 'vue';
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
|
||||
@@ -29,11 +29,12 @@ const invalid = Number.isNaN(_time);
|
||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||
|
||||
let now = $ref((props.origin ?? new Date()).getTime());
|
||||
const ago = $computed(() => (now - _time) / 1000/*ms*/);
|
||||
|
||||
const relative = $computed<string>(() => {
|
||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||
if (invalid) return i18n.ts._ago.invalid;
|
||||
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
return (
|
||||
ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) :
|
||||
ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) :
|
||||
@@ -47,19 +48,25 @@ const relative = $computed<string>(() => {
|
||||
});
|
||||
|
||||
let tickId: number;
|
||||
let currentInterval: number;
|
||||
|
||||
function tick() {
|
||||
now = props.origin ?? (new Date()).getTime();
|
||||
const ago = (now - _time) / 1000/*ms*/;
|
||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
now = (new Date()).getTime();
|
||||
const nextInterval = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||
|
||||
tickId = window.setTimeout(tick, next);
|
||||
if (currentInterval !== nextInterval) {
|
||||
if (tickId) window.clearInterval(tickId);
|
||||
currentInterval = nextInterval;
|
||||
tickId = window.setInterval(tick, nextInterval);
|
||||
}
|
||||
}
|
||||
|
||||
if (props.mode === 'relative' || props.mode === 'detail') {
|
||||
tick();
|
||||
if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) {
|
||||
onMounted(() => {
|
||||
tick();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.clearTimeout(tickId);
|
||||
if (tickId) window.clearInterval(tickId);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
@@ -11,13 +11,13 @@ export default function(props: { src: string; tag?: string; textTag?: string; },
|
||||
parsed.push(str);
|
||||
break;
|
||||
} else {
|
||||
if (nextBracketOpen > 0) parsed.push(str.substr(0, nextBracketOpen));
|
||||
if (nextBracketOpen > 0) parsed.push(str.substring(0, nextBracketOpen));
|
||||
parsed.push({
|
||||
arg: str.substring(nextBracketOpen + 1, nextBracketClose),
|
||||
});
|
||||
}
|
||||
|
||||
str = str.substr(nextBracketClose + 1);
|
||||
str = str.substring(nextBracketClose + 1);
|
||||
}
|
||||
|
||||
return h(props.tag ?? 'span', parsed.map(x => typeof x === 'string' ? (props.textTag ? h(props.textTag, x) : x) : slots[x.arg]()));
|
||||
|
@@ -57,6 +57,9 @@ export const ROLE_POLICIES = [
|
||||
'ltlAvailable',
|
||||
'canPublicNote',
|
||||
'canInvite',
|
||||
'inviteLimit',
|
||||
'inviteLimitCycle',
|
||||
'inviteExpirationTime',
|
||||
'canManageCustomEmojis',
|
||||
'canSearchNotes',
|
||||
'canHideAds',
|
||||
|
@@ -10,7 +10,7 @@ export default {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
|
||||
const myBg = window.getComputedStyle(src).backgroundColor;
|
||||
|
@@ -10,7 +10,7 @@ export default {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
|
||||
const myBg = window.getComputedStyle(src).backgroundColor;
|
||||
|
@@ -10,7 +10,7 @@ export default {
|
||||
return el.parentElement ? getBgColor(el.parentElement) : 'transparent';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const parentBg = getBgColor(src.parentElement);
|
||||
|
||||
const myBg = getComputedStyle(document.documentElement).getPropertyValue('--panel');
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||
|
||||
export default (d: Date | number | undefined) => dateTimeFormat.format(d);
|
||||
export default (d: Date | number | undefined) => dateTimeFormat.format(d);
|
||||
export const dateString = (d: string) => dateTimeFormat.format(new Date(d));
|
||||
|
@@ -14,7 +14,7 @@ type Keys =
|
||||
'wallpaper' |
|
||||
'theme' |
|
||||
'colorScheme' |
|
||||
'useSystemFont' |
|
||||
'useSystemFont' |
|
||||
'fontSize' |
|
||||
'ui' |
|
||||
'ui_temp' |
|
||||
|
@@ -1,8 +1,7 @@
|
||||
// NIRAX --- A lightweight router
|
||||
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { Component, shallowRef, ShallowRef } from 'vue';
|
||||
import { pleaseLogin } from '@/scripts/please-login';
|
||||
import { Component, onMounted, shallowRef, ShallowRef } from 'vue';
|
||||
import { safeURIDecode } from '@/scripts/safe-uri-decode';
|
||||
|
||||
type RouteDef = {
|
||||
@@ -23,7 +22,7 @@ type ParsedPath = (string | {
|
||||
optional?: boolean;
|
||||
})[];
|
||||
|
||||
export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; };
|
||||
export type Resolved = { route: RouteDef; props: Map<string, string | boolean>; child?: Resolved; };
|
||||
|
||||
function parsePath(path: string): ParsedPath {
|
||||
const res = [] as ParsedPath;
|
||||
@@ -75,15 +74,19 @@ export class Router extends EventEmitter<{
|
||||
public currentRef: ShallowRef<Resolved> = shallowRef();
|
||||
public currentRoute: ShallowRef<RouteDef> = shallowRef();
|
||||
private currentPath: string;
|
||||
private isLoggedIn: boolean;
|
||||
private notFoundPageComponent: Component;
|
||||
private currentKey = Date.now().toString();
|
||||
|
||||
public navHook: ((path: string, flag?: any) => boolean) | null = null;
|
||||
|
||||
constructor(routes: Router['routes'], currentPath: Router['currentPath']) {
|
||||
constructor(routes: Router['routes'], currentPath: Router['currentPath'], isLoggedIn: boolean, notFoundPageComponent: Component) {
|
||||
super();
|
||||
|
||||
this.routes = routes;
|
||||
this.currentPath = currentPath;
|
||||
this.isLoggedIn = isLoggedIn;
|
||||
this.notFoundPageComponent = notFoundPageComponent;
|
||||
this.navigate(currentPath, null, false);
|
||||
}
|
||||
|
||||
@@ -159,11 +162,11 @@ export class Router extends EventEmitter<{
|
||||
if (route.hash != null && hash != null) {
|
||||
props.set(route.hash, safeURIDecode(hash));
|
||||
}
|
||||
|
||||
|
||||
if (route.query != null && queryString != null) {
|
||||
const queryObject = [...new URLSearchParams(queryString).entries()]
|
||||
.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
|
||||
|
||||
|
||||
for (const q in route.query) {
|
||||
const as = route.query[q];
|
||||
if (queryObject[q]) {
|
||||
@@ -171,7 +174,7 @@ export class Router extends EventEmitter<{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
route,
|
||||
props,
|
||||
@@ -212,8 +215,9 @@ export class Router extends EventEmitter<{
|
||||
throw new Error('no route found for: ' + path);
|
||||
}
|
||||
|
||||
if (res.route.loginRequired) {
|
||||
pleaseLogin('/');
|
||||
if (res.route.loginRequired && !this.isLoggedIn) {
|
||||
res.route.component = this.notFoundPageComponent;
|
||||
res.props.set('showLoginPopup', true);
|
||||
}
|
||||
|
||||
const isSamePath = beforePath === path;
|
||||
@@ -263,13 +267,33 @@ export class Router extends EventEmitter<{
|
||||
});
|
||||
}
|
||||
|
||||
public replace(path: string, key?: string | null, emitEvent = true) {
|
||||
public replace(path: string, key?: string | null) {
|
||||
this.navigate(path, key);
|
||||
if (emitEvent) {
|
||||
this.emit('replace', {
|
||||
path,
|
||||
key: this.currentKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useScrollPositionManager(getScrollContainer: () => HTMLElement, router: Router) {
|
||||
const scrollPosStore = new Map<string, number>();
|
||||
|
||||
onMounted(() => {
|
||||
const scrollContainer = getScrollContainer();
|
||||
|
||||
scrollContainer.addEventListener('scroll', () => {
|
||||
scrollPosStore.set(router.getCurrentKey(), scrollContainer.scrollTop);
|
||||
}, { passive: true });
|
||||
|
||||
router.addListener('change', ctx => {
|
||||
const scrollPos = scrollPosStore.get(ctx.key) ?? 0;
|
||||
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
|
||||
if (scrollPos !== 0) {
|
||||
window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール
|
||||
scrollContainer.scroll({ top: scrollPos, behavior: 'instant' });
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
router.addListener('same', () => {
|
||||
scrollContainer.scroll({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -460,11 +460,13 @@ export async function pickEmoji(src: HTMLElement | null, opts) {
|
||||
|
||||
export async function cropImage(image: Misskey.entities.DriveFile, options: {
|
||||
aspectRatio: number;
|
||||
uploadFolder?: string | null;
|
||||
}): Promise<Misskey.entities.DriveFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
popup(defineAsyncComponent(() => import('@/components/MkCropperDialog.vue')), {
|
||||
file: image,
|
||||
aspectRatio: options.aspectRatio,
|
||||
uploadFolder: options.uploadFolder,
|
||||
}, {
|
||||
ok: x => {
|
||||
resolve(x);
|
||||
|
@@ -88,10 +88,13 @@
|
||||
<template #label>Special thanks</template>
|
||||
<div class="_gaps" style="text-align: center;">
|
||||
<div>
|
||||
<a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a>
|
||||
<a style="display: inline-block;" class="masknetwork" title="Mask Network" href="https://mask.io/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/masknetwork.png" alt="Mask Network"></a>
|
||||
</div>
|
||||
<div>
|
||||
<a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="200" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
|
||||
<a style="display: inline-block;" class="skeb" title="Skeb" href="https://skeb.jp/" target="_blank"><img width="180" src="https://misskey-hub.net/sponsors/skeb.svg" alt="Skeb"></a>
|
||||
</div>
|
||||
<div>
|
||||
<a style="display: inline-block;" class="dcadvirth" title="DC Advirth" href="https://www.dotchain.ltd/advirth" target="_blank"><img width="100" src="https://misskey-hub.net/sponsors/dcadvirth.png" alt="DC Advirth"></a>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
@@ -155,6 +158,30 @@ const patronsWithIcon = [{
|
||||
}, {
|
||||
name: 'spinlock',
|
||||
icon: 'https://misskey-hub.net/patrons/6a1cebc819d540a78bf20e9e3115baa8.jpg',
|
||||
}, {
|
||||
name: 'じゅくま',
|
||||
icon: 'https://misskey-hub.net/patrons/3e56bdac69dd42f7a06e0f12cf2fc895.jpg',
|
||||
}, {
|
||||
name: '清遊あみ',
|
||||
icon: 'https://misskey-hub.net/patrons/de25195b88e940a388388bea2e7637d8.jpg',
|
||||
}, {
|
||||
name: 'Nagi8410',
|
||||
icon: 'https://misskey-hub.net/patrons/31b102ab4fc540ed806b0461575d38be.jpg',
|
||||
}, {
|
||||
name: '山岡士郎',
|
||||
icon: 'https://misskey-hub.net/patrons/84b9056341684266bb1eda3e680d094d.jpg',
|
||||
}, {
|
||||
name: 'よもやまたろう',
|
||||
icon: 'https://misskey-hub.net/patrons/4273c9cce50d445f8f7d0f16113d6d7f.jpg',
|
||||
}, {
|
||||
name: '花咲ももか',
|
||||
icon: 'https://misskey-hub.net/patrons/8c9b2b9128cb4fee99f04bb4f86f2efa.jpg',
|
||||
}, {
|
||||
name: 'カガミ',
|
||||
icon: 'https://misskey-hub.net/patrons/226ea3a4617749548580ec2d9a263e24.jpg',
|
||||
}, {
|
||||
name: 'フランギ・シュウ',
|
||||
icon: 'https://misskey-hub.net/patrons/3016d37e35f3430b90420176c912d304.jpg',
|
||||
}];
|
||||
|
||||
const patrons = [
|
||||
@@ -250,6 +277,9 @@ const patrons = [
|
||||
'binvinyl',
|
||||
'渡志郎',
|
||||
'ぷーざ',
|
||||
'越貝鯛丸',
|
||||
'Nick / pprmint.',
|
||||
'kino3277',
|
||||
];
|
||||
|
||||
let thereIsTreasure = $ref($i && !claimedAchievements.includes('foundTreasure'));
|
||||
|
@@ -20,7 +20,7 @@
|
||||
<XEmoji v-for="emoji in searchEmojis" :key="emoji.name" :emoji="emoji"/>
|
||||
</div>
|
||||
</MkFoldableSection>
|
||||
|
||||
|
||||
<MkFoldableSection v-for="category in customEmojiCategories" v-once :key="category">
|
||||
<template #header>{{ category || i18n.ts.other }}</template>
|
||||
<div :class="$style.emojis">
|
||||
@@ -56,7 +56,7 @@ function search() {
|
||||
const queryarry = q.match(/\:([a-z0-9_]*)\:/g);
|
||||
|
||||
if (queryarry) {
|
||||
searchEmojis = customEmojis.value.filter(emoji =>
|
||||
searchEmojis = customEmojis.value.filter(emoji =>
|
||||
queryarry.includes(`:${emoji.name}:`),
|
||||
);
|
||||
} else {
|
||||
|
@@ -32,7 +32,7 @@
|
||||
<MkUserCardMini :user="file.user"/>
|
||||
</MkA>
|
||||
<div>
|
||||
<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch>
|
||||
<MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">{{ i18n.ts.sensitive }}</MkSwitch>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
@@ -75,7 +75,7 @@ const pagination = {
|
||||
};
|
||||
|
||||
function resolved(reportId) {
|
||||
reports.removeItem(item => item.id === reportId);
|
||||
reports.removeItem(reportId);
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
@@ -36,6 +36,16 @@
|
||||
<template #label>{{ i18n.ts.expiration }}</template>
|
||||
</MkInput>
|
||||
</FormSplit>
|
||||
<MkFolder>
|
||||
<template #label>{{ i18n.ts.advancedSettings }}</template>
|
||||
<span>
|
||||
{{ i18n.ts._ad.timezoneinfo }}
|
||||
<div v-for="(day, index) in daysOfWeek" :key="index">
|
||||
<input :id="`ad${ad.id}-${index}`" type="checkbox" :checked="(ad.dayOfWeek & (1 << index)) !== 0" @change="toggleDayOfWeek(ad, index)">
|
||||
<label :for="`ad${ad.id}-${index}`">{{ day }}</label>
|
||||
</div>
|
||||
</span>
|
||||
</MkFolder>
|
||||
<MkTextarea v-model="ad.memo">
|
||||
<template #label>{{ i18n.ts.memo }}</template>
|
||||
</MkTextarea>
|
||||
@@ -59,6 +69,7 @@ import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import FormSplit from '@/components/form/split.vue';
|
||||
import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
@@ -69,6 +80,7 @@ let ads: any[] = $ref([]);
|
||||
// ISO形式はTZがUTCになってしまうので、TZ分ずらして時間を初期化
|
||||
const localTime = new Date();
|
||||
const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
|
||||
const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday];
|
||||
|
||||
os.api('admin/ad/list').then(adsResponse => {
|
||||
ads = adsResponse.map(r => {
|
||||
@@ -84,6 +96,11 @@ os.api('admin/ad/list').then(adsResponse => {
|
||||
});
|
||||
});
|
||||
|
||||
// 選択された曜日(index)のビットフラグを操作する
|
||||
function toggleDayOfWeek(ad, index) {
|
||||
ad.dayOfWeek ^= 1 << index;
|
||||
}
|
||||
|
||||
function add() {
|
||||
ads.unshift({
|
||||
id: null,
|
||||
@@ -95,6 +112,7 @@ function add() {
|
||||
imageUrl: null,
|
||||
expiresAt: null,
|
||||
startsAt: null,
|
||||
dayOfWeek: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,6 +123,7 @@ function remove(ad) {
|
||||
}).then(({ canceled }) => {
|
||||
if (canceled) return;
|
||||
ads = ads.filter(x => x !== ad);
|
||||
if (ad.id == null) return;
|
||||
os.apiWithDialog('admin/ad/delete', {
|
||||
id: ad.id,
|
||||
});
|
||||
|
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
|
||||
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
|
||||
<div v-if="!narrow || currentPage?.route.name == null" class="nav">
|
||||
<MkSpacer :contentMax="700" :marginMin="16">
|
||||
<div class="lxpfedzu">
|
||||
<div class="banner">
|
||||
@@ -80,7 +80,7 @@ const menuDef = $computed(() => [{
|
||||
}, ...(instance.disableRegistration ? [{
|
||||
type: 'button',
|
||||
icon: 'ti ti-user-plus',
|
||||
text: i18n.ts.invite,
|
||||
text: i18n.ts.createInviteCode,
|
||||
action: invite,
|
||||
}] : [])],
|
||||
}, {
|
||||
@@ -95,6 +95,11 @@ const menuDef = $computed(() => [{
|
||||
text: i18n.ts.users,
|
||||
to: '/admin/users',
|
||||
active: currentPage?.route.name === 'users',
|
||||
}, {
|
||||
icon: 'ti ti-user-plus',
|
||||
text: i18n.ts.invite,
|
||||
to: '/admin/invites',
|
||||
active: currentPage?.route.name === 'invites',
|
||||
}, {
|
||||
icon: 'ti ti-badges',
|
||||
text: i18n.ts.roles,
|
||||
@@ -240,10 +245,10 @@ provideMetadataReceiver((info) => {
|
||||
});
|
||||
|
||||
const invite = () => {
|
||||
os.api('invite').then(x => {
|
||||
os.api('admin/invite/create').then(x => {
|
||||
os.alert({
|
||||
type: 'info',
|
||||
text: x.code,
|
||||
text: x?.[0].code,
|
||||
});
|
||||
}).catch(err => {
|
||||
os.alert({
|
||||
|
126
packages/frontend/src/pages/admin/invites.vue
Normal file
126
packages/frontend/src/pages/admin/invites.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="800">
|
||||
<div class="_gaps_m">
|
||||
<MkFolder :expanded="false">
|
||||
<template #icon><i class="ti ti-plus"></i></template>
|
||||
<template #label>{{ i18n.ts.createInviteCode }}</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
<MkSwitch v-model="noExpirationDate">
|
||||
<template #label>{{ i18n.ts.noExpirationDate }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local">
|
||||
<template #label>{{ i18n.ts.expirationDate }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="createCount" type="number">
|
||||
<template #label>{{ i18n.ts.createCount }}</template>
|
||||
</MkInput>
|
||||
<MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<div :class="$style.inputs">
|
||||
<MkSelect v-model="type" :class="$style.input">
|
||||
<template #label>{{ i18n.ts.state }}</template>
|
||||
<option value="all">{{ i18n.ts.all }}</option>
|
||||
<option value="unused">{{ i18n.ts.unused }}</option>
|
||||
<option value="used">{{ i18n.ts.used }}</option>
|
||||
<option value="expired">{{ i18n.ts.expired }}</option>
|
||||
</MkSelect>
|
||||
<MkSelect v-model="sort" :class="$style.input">
|
||||
<template #label>{{ i18n.ts.sort }}</template>
|
||||
<option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option>
|
||||
<option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option>
|
||||
<option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option>
|
||||
<option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
|
||||
</MkSelect>
|
||||
</div>
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
<MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import XHeader from './_header_.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkSelect from '@/components/MkSelect.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import MkInviteCode from '@/components/MkInviteCode.vue';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
||||
let type = ref('all');
|
||||
let sort = ref('+createdAt');
|
||||
|
||||
const pagination: Paging = {
|
||||
endpoint: 'admin/invite/list' as const,
|
||||
limit: 10,
|
||||
params: computed(() => ({
|
||||
type: type.value,
|
||||
sort: sort.value,
|
||||
})),
|
||||
offsetMode: true,
|
||||
};
|
||||
|
||||
const expiresAt = ref('');
|
||||
const noExpirationDate = ref(true);
|
||||
const createCount = ref(1);
|
||||
|
||||
async function createWithOptions() {
|
||||
const options = {
|
||||
expiresAt: noExpirationDate.value ? null : expiresAt.value,
|
||||
count: createCount.value,
|
||||
};
|
||||
|
||||
const tickets = await os.api('admin/invite/create', options);
|
||||
os.alert({
|
||||
type: 'success',
|
||||
title: i18n.ts.inviteCodeCreated,
|
||||
text: tickets?.map(x => x.code).join('\n'),
|
||||
});
|
||||
|
||||
tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket));
|
||||
}
|
||||
|
||||
function deleted(id: string) {
|
||||
if (pagingComponent.value) {
|
||||
pagingComponent.value.items.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.invite,
|
||||
icon: 'ti ti-user-plus',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.inputs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
@@ -24,7 +24,7 @@
|
||||
<template #label>{{ i18n.ts.preservedUsernames }}</template>
|
||||
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
|
||||
</MkTextarea>
|
||||
|
||||
|
||||
<MkTextarea v-model="sensitiveWords">
|
||||
<template #label>{{ i18n.ts.sensitiveWords }}</template>
|
||||
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
|
||||
|
@@ -3,14 +3,34 @@
|
||||
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
|
||||
<FormSuspense :p="init">
|
||||
<div class="_gaps_s">
|
||||
<MkSwitch v-model="enableChartsForRemoteUser">
|
||||
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
|
||||
</MkSwitch>
|
||||
<div class="_gaps">
|
||||
<div class="_panel" style="padding: 16px;">
|
||||
<MkSwitch v-model="enableServerMachineStats">
|
||||
<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
|
||||
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
|
||||
<MkSwitch v-model="enableChartsForFederatedInstances">
|
||||
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
|
||||
</MkSwitch>
|
||||
<div class="_panel" style="padding: 16px;">
|
||||
<MkSwitch v-model="enableIdenticonGeneration">
|
||||
<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
|
||||
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
|
||||
<div class="_panel" style="padding: 16px;">
|
||||
<MkSwitch v-model="enableChartsForRemoteUser">
|
||||
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
|
||||
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
|
||||
<div class="_panel" style="padding: 16px;">
|
||||
<MkSwitch v-model="enableChartsForFederatedInstances">
|
||||
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
|
||||
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
|
||||
</MkSwitch>
|
||||
</div>
|
||||
</div>
|
||||
</FormSuspense>
|
||||
</MkSpacer>
|
||||
@@ -27,17 +47,23 @@ import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import MkSwitch from '@/components/MkSwitch.vue';
|
||||
|
||||
let enableServerMachineStats: boolean = $ref(false);
|
||||
let enableIdenticonGeneration: boolean = $ref(false);
|
||||
let enableChartsForRemoteUser: boolean = $ref(false);
|
||||
let enableChartsForFederatedInstances: boolean = $ref(false);
|
||||
|
||||
async function init() {
|
||||
const meta = await os.api('admin/meta');
|
||||
enableServerMachineStats = meta.enableServerMachineStats;
|
||||
enableIdenticonGeneration = meta.enableIdenticonGeneration;
|
||||
enableChartsForRemoteUser = meta.enableChartsForRemoteUser;
|
||||
enableChartsForFederatedInstances = meta.enableChartsForFederatedInstances;
|
||||
}
|
||||
|
||||
function save() {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
enableServerMachineStats,
|
||||
enableIdenticonGeneration,
|
||||
enableChartsForRemoteUser,
|
||||
enableChartsForFederatedInstances,
|
||||
}).then(() => {
|
||||
|
@@ -259,7 +259,7 @@ onMounted(async () => {
|
||||
},
|
||||
plugins: [chartVLine(vLineColor)],
|
||||
});
|
||||
|
||||
|
||||
fetching = false;
|
||||
});
|
||||
</script>
|
||||
|
@@ -58,7 +58,7 @@ let federationSubActiveDiff = $ref<number | null>(null);
|
||||
let fetching = $ref(true);
|
||||
|
||||
const { handler: externalTooltipHandler } = useChartTooltip();
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
const chart = await os.apiGet('charts/federation', { limit: 2, span: 'day' });
|
||||
federationPubActive = chart.pubActive[0];
|
||||
|
@@ -85,7 +85,7 @@ onMounted(() => {
|
||||
connection.on('stats', onStats);
|
||||
connection.on('statsLog', onStatsLog);
|
||||
connection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
id: Math.random().toString().substring(2, 10),
|
||||
length: 100,
|
||||
});
|
||||
});
|
||||
@@ -122,4 +122,4 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
@@ -30,7 +30,7 @@
|
||||
<template #header>Federation</template>
|
||||
<XFederation/>
|
||||
</MkFoldableSection>
|
||||
|
||||
|
||||
<MkFoldableSection class="item">
|
||||
<template #header>Instances</template>
|
||||
<XInstances/>
|
||||
@@ -156,7 +156,7 @@ onMounted(async () => {
|
||||
|
||||
nextTick(() => {
|
||||
queueStatsConnection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
id: Math.random().toString().substring(2, 10),
|
||||
length: 100,
|
||||
});
|
||||
});
|
||||
|
@@ -106,7 +106,7 @@ onMounted(() => {
|
||||
connection.on('stats', onStats);
|
||||
connection.on('statsLog', onStatsLog);
|
||||
connection.send('requestLog', {
|
||||
id: Math.random().toString().substr(2, 8),
|
||||
id: Math.random().toString().substring(2, 10),
|
||||
length: 200,
|
||||
});
|
||||
});
|
||||
|
@@ -1,5 +1,9 @@
|
||||
<template>
|
||||
<div class="_gaps">
|
||||
<MkInput v-if="readonly" :modelValue="role.id" :readonly="true">
|
||||
<template #label>ID</template>
|
||||
</MkInput>
|
||||
|
||||
<MkInput v-model="role.name" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.name }}</template>
|
||||
</MkInput>
|
||||
@@ -171,6 +175,65 @@
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.inviteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.inviteLimit.value }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimit)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.inviteLimit.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.inviteLimit.value" :disabled="role.policies.inviteLimit.useDefault" type="number" :readonly="readonly">
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.inviteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
|
||||
<template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.inviteLimitCycle.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.inviteLimitCycle.value + i18n.ts._time.minute }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimitCycle)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.inviteLimitCycle.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.inviteLimitCycle.value" :disabled="role.policies.inviteLimitCycle.useDefault" type="number" :readonly="readonly">
|
||||
<template #suffix>{{ i18n.ts._time.minute }}</template>
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.inviteLimitCycle.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
|
||||
<template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
|
||||
<template #suffix>
|
||||
<span v-if="role.policies.inviteExpirationTime.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||
<span v-else>{{ role.policies.inviteExpirationTime.value + i18n.ts._time.minute }}</span>
|
||||
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteExpirationTime)"></i></span>
|
||||
</template>
|
||||
<div class="_gaps">
|
||||
<MkSwitch v-model="role.policies.inviteExpirationTime.useDefault" :readonly="readonly">
|
||||
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||
</MkSwitch>
|
||||
<MkInput v-model="role.policies.inviteExpirationTime.value" :disabled="role.policies.inviteExpirationTime.useDefault" type="number" :readonly="readonly">
|
||||
<template #suffix>{{ i18n.ts._time.minute }}</template>
|
||||
</MkInput>
|
||||
<MkRange v-model="role.policies.inviteExpirationTime.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
|
||||
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
|
||||
<template #suffix>
|
||||
@@ -210,7 +273,7 @@
|
||||
</MkRange>
|
||||
</div>
|
||||
</MkFolder>
|
||||
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
|
||||
<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
|
||||
<template #suffix>
|
||||
|
@@ -40,7 +40,7 @@
|
||||
</div>
|
||||
<div v-if="expandedItems.includes(item.id)" :class="$style.userItemSub">
|
||||
<div>Assigned: <MkTime :time="item.createdAt" mode="detail"/></div>
|
||||
<div v-if="item.expiresAt">Period: {{ item.expiresAt.toLocaleString() }}</div>
|
||||
<div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
|
||||
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -51,6 +51,29 @@
|
||||
</MkSwitch>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
|
||||
<template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
|
||||
<template #suffix>{{ policies.inviteLimit }}</template>
|
||||
<MkInput v-model="policies.inviteLimit" type="number">
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
|
||||
<template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
|
||||
<template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template>
|
||||
<MkInput v-model="policies.inviteLimitCycle" type="number">
|
||||
<template #suffix>{{ i18n.ts._time.minute }}</template>
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
|
||||
<template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
|
||||
<template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template>
|
||||
<MkInput v-model="policies.inviteExpirationTime" type="number">
|
||||
<template #suffix>{{ i18n.ts._time.minute }}</template>
|
||||
</MkInput>
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
|
||||
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
|
||||
<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||
|
@@ -37,6 +37,13 @@
|
||||
<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
|
||||
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
|
||||
</MkSwitch>
|
||||
|
||||
<template v-if="cacheRemoteFiles">
|
||||
<MkSwitch v-model="cacheRemoteSensitiveFiles">
|
||||
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}</template>
|
||||
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
|
||||
</MkSwitch>
|
||||
</template>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
@@ -104,7 +111,6 @@ import { fetchInstance } from '@/instance';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkColorInput from '@/components/MkColorInput.vue';
|
||||
|
||||
let name: string | null = $ref(null);
|
||||
let description: string | null = $ref(null);
|
||||
@@ -112,13 +118,14 @@ let maintainerName: string | null = $ref(null);
|
||||
let maintainerEmail: string | null = $ref(null);
|
||||
let pinnedUsers: string = $ref('');
|
||||
let cacheRemoteFiles: boolean = $ref(false);
|
||||
let cacheRemoteSensitiveFiles: boolean = $ref(false);
|
||||
let enableServiceWorker: boolean = $ref(false);
|
||||
let swPublicKey: any = $ref(null);
|
||||
let swPrivateKey: any = $ref(null);
|
||||
let deeplAuthKey: string = $ref('');
|
||||
let deeplIsPro: boolean = $ref(false);
|
||||
|
||||
async function init() {
|
||||
async function init(): Promise<void> {
|
||||
const meta = await os.api('admin/meta');
|
||||
name = meta.name;
|
||||
description = meta.description;
|
||||
@@ -126,6 +133,7 @@ async function init() {
|
||||
maintainerEmail = meta.maintainerEmail;
|
||||
pinnedUsers = meta.pinnedUsers.join('\n');
|
||||
cacheRemoteFiles = meta.cacheRemoteFiles;
|
||||
cacheRemoteSensitiveFiles = meta.cacheRemoteSensitiveFiles;
|
||||
enableServiceWorker = meta.enableServiceWorker;
|
||||
swPublicKey = meta.swPublickey;
|
||||
swPrivateKey = meta.swPrivateKey;
|
||||
@@ -133,7 +141,7 @@ async function init() {
|
||||
deeplIsPro = meta.deeplIsPro;
|
||||
}
|
||||
|
||||
function save() {
|
||||
function save(): void {
|
||||
os.apiWithDialog('admin/update-meta', {
|
||||
name,
|
||||
description,
|
||||
@@ -141,6 +149,7 @@ function save() {
|
||||
maintainerEmail,
|
||||
pinnedUsers: pinnedUsers.split('\n'),
|
||||
cacheRemoteFiles,
|
||||
cacheRemoteSensitiveFiles,
|
||||
enableServiceWorker,
|
||||
swPublicKey,
|
||||
swPrivateKey,
|
||||
|
@@ -25,11 +25,11 @@
|
||||
|
||||
<MkFolder :defaultOpen="true">
|
||||
<template #label>{{ i18n.ts.pinnedNotes }}</template>
|
||||
|
||||
|
||||
<div class="_gaps">
|
||||
<MkButton primary rounded @click="addPinnedNote()"><i class="ti ti-plus"></i></MkButton>
|
||||
|
||||
<Sortable
|
||||
<Sortable
|
||||
v-model="pinnedNotes"
|
||||
itemKey="id"
|
||||
:handle="'.' + $style.pinnedNoteHandle"
|
||||
@@ -160,7 +160,7 @@ async function archive() {
|
||||
});
|
||||
|
||||
if (canceled) return;
|
||||
|
||||
|
||||
os.api('channels/update', {
|
||||
channelId: props.channelId,
|
||||
isArchived: true,
|
||||
|
@@ -87,7 +87,7 @@ const props = defineProps<{
|
||||
channelId: string;
|
||||
}>();
|
||||
|
||||
let tab = $ref('timeline');
|
||||
let tab = $ref('overview');
|
||||
let channel = $ref(null);
|
||||
let favorited = $ref(false);
|
||||
let searchQuery = $ref('');
|
||||
@@ -107,6 +107,9 @@ watch(() => props.channelId, async () => {
|
||||
channelId: props.channelId,
|
||||
});
|
||||
favorited = channel.isFavorited;
|
||||
if (favorited || channel.isFollowing) {
|
||||
tab = 'timeline';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
function edit() {
|
||||
|
@@ -55,7 +55,7 @@ watch(() => props.clipId, async () => {
|
||||
favorited = clip.isFavorited;
|
||||
}, {
|
||||
immediate: true,
|
||||
});
|
||||
});
|
||||
|
||||
provide('currentClip', $$(clip));
|
||||
|
||||
|
@@ -18,7 +18,7 @@
|
||||
<MkButton inline @click="setTagBulk">Set tag</MkButton>
|
||||
<MkButton inline @click="addTagBulk">Add tag</MkButton>
|
||||
<MkButton inline @click="removeTagBulk">Remove tag</MkButton>
|
||||
<MkButton inline @click="setLisenceBulk">Set Lisence</MkButton>
|
||||
<MkButton inline @click="setLicenseBulk">Set License</MkButton>
|
||||
<MkButton inline danger @click="delBulk">Delete</MkButton>
|
||||
</div>
|
||||
<MkPagination ref="emojisPaginationComponent" :pagination="pagination">
|
||||
@@ -144,7 +144,7 @@ const edit = (emoji) => {
|
||||
...result.updated,
|
||||
}));
|
||||
} else if (result.deleted) {
|
||||
emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id);
|
||||
emojisPaginationComponent.value.removeItem(emoji.id);
|
||||
}
|
||||
},
|
||||
}, 'closed');
|
||||
@@ -221,7 +221,7 @@ const setCategoryBulk = async () => {
|
||||
emojisPaginationComponent.value.reload();
|
||||
};
|
||||
|
||||
const setLisenceBulk = async () => {
|
||||
const setLicenseBulk = async () => {
|
||||
const { canceled, result } = await os.inputText({
|
||||
title: 'License',
|
||||
});
|
||||
@@ -311,13 +311,13 @@ definePageMetadata(computed(() => ({
|
||||
.empty {
|
||||
margin: var(--margin);
|
||||
}
|
||||
|
||||
|
||||
.ldhfsamy {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
grid-gap: 12px;
|
||||
margin: var(--margin) 0;
|
||||
|
||||
|
||||
> .emoji {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<MkButton rounded style="margin: 0 auto;" @click="changeImage">{{ i18n.ts.selectFile }}</MkButton>
|
||||
<MkInput v-model="name">
|
||||
<MkInput v-model="name" pattern="[a-z0-9_]">
|
||||
<template #label>{{ i18n.ts.name }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="category" :datalist="customEmojiCategories">
|
||||
@@ -70,6 +70,7 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, watch } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
@@ -95,7 +96,7 @@ let isSensitive = $ref(props.emoji ? props.emoji.isSensitive : false);
|
||||
let localOnly = $ref(props.emoji ? props.emoji.localOnly : false);
|
||||
let roleIdsThatCanBeUsedThisEmojiAsReaction = $ref(props.emoji ? props.emoji.roleIdsThatCanBeUsedThisEmojiAsReaction : []);
|
||||
let rolesThatCanBeUsedThisEmojiAsReaction = $ref([]);
|
||||
let file = $ref();
|
||||
let file = $ref<misskey.entities.DriveFile>();
|
||||
|
||||
watch($$(roleIdsThatCanBeUsedThisEmojiAsReaction), async () => {
|
||||
rolesThatCanBeUsedThisEmojiAsReaction = (await Promise.all(roleIdsThatCanBeUsedThisEmojiAsReaction.map((id) => os.api('admin/roles/show', { roleId: id }).catch(() => null)))).filter(x => x != null);
|
||||
@@ -110,6 +111,10 @@ const emit = defineEmits<{
|
||||
|
||||
async function changeImage(ev) {
|
||||
file = await selectFile(ev.currentTarget ?? ev.target, null);
|
||||
const candidate = file.name.replace(/\.(.+)$/, '');
|
||||
if (candidate.match(/^[a-z0-9_]+$/)) {
|
||||
name = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
async function addRole() {
|
||||
|
@@ -33,7 +33,7 @@ import MkTextarea from '@/components/MkTextarea.vue';
|
||||
import MkInput from '@/components/MkInput.vue';
|
||||
import { useRouter } from '@/router';
|
||||
|
||||
const PRESET_DEFAULT = `/// @ 0.13.3
|
||||
const PRESET_DEFAULT = `/// @ 0.15.0
|
||||
|
||||
var name = ""
|
||||
|
||||
@@ -51,7 +51,7 @@ Ui:render([
|
||||
])
|
||||
`;
|
||||
|
||||
const PRESET_OMIKUJI = `/// @ 0.13.3
|
||||
const PRESET_OMIKUJI = `/// @ 0.15.0
|
||||
// ユーザーごとに日替わりのおみくじのプリセット
|
||||
|
||||
// 選択肢
|
||||
@@ -94,7 +94,7 @@ Ui:render([
|
||||
])
|
||||
`;
|
||||
|
||||
const PRESET_SHUFFLE = `/// @ 0.13.3
|
||||
const PRESET_SHUFFLE = `/// @ 0.15.0
|
||||
// 巻き戻し可能な文字シャッフルのプリセット
|
||||
|
||||
let string = "ペペロンチーノ"
|
||||
@@ -173,7 +173,7 @@ var cursor = 0
|
||||
do()
|
||||
`;
|
||||
|
||||
const PRESET_QUIZ = `/// @ 0.13.3
|
||||
const PRESET_QUIZ = `/// @ 0.15.0
|
||||
let title = '地理クイズ'
|
||||
|
||||
let qas = [{
|
||||
@@ -286,7 +286,7 @@ qaEls.push(Ui:C:container({
|
||||
Ui:render(qaEls)
|
||||
`;
|
||||
|
||||
const PRESET_TIMELINE = `/// @ 0.13.3
|
||||
const PRESET_TIMELINE = `/// @ 0.15.0
|
||||
// APIリクエストを行いローカルタイムラインを表示するプリセット
|
||||
|
||||
@fetch() {
|
||||
|
@@ -20,7 +20,7 @@ async function follow(user): Promise<void> {
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
os.apiWithDialog('following/create', {
|
||||
userId: user.id,
|
||||
});
|
||||
|
@@ -236,6 +236,7 @@ definePageMetadata(computed(() => post ? {
|
||||
border-top: solid 0.5px var(--divider);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
> .avatar {
|
||||
width: 52px;
|
||||
|
@@ -49,7 +49,7 @@
|
||||
<template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template>
|
||||
</MkKeyValue>
|
||||
</FormSection>
|
||||
|
||||
|
||||
<FormSection>
|
||||
<MkKeyValue oneline style="margin: 1em 0;">
|
||||
<template #key>Following (Pub)</template>
|
||||
|
114
packages/frontend/src/pages/invite.vue
Normal file
114
packages/frontend/src/pages/invite.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header>
|
||||
<MkPageHeader/>
|
||||
</template>
|
||||
<MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
|
||||
<div :class="$style.root">
|
||||
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
|
||||
<div :class="$style.text">
|
||||
<i class="ti ti-alert-triangle"></i>
|
||||
{{ i18n.ts.nothing }}
|
||||
</div>
|
||||
</div>
|
||||
</MKSpacer>
|
||||
<MkSpacer v-else :contentMax="800">
|
||||
<div class="_gaps_m" style="text-align: center;">
|
||||
<div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div>
|
||||
<MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton>
|
||||
<div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div>
|
||||
|
||||
<MkPagination ref="pagingComponent" :pagination="pagination">
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
<MkInviteCode v-for="item in (items as Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import type { Invite } from 'misskey-js/built/entities';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkPagination, { Paging } from '@/components/MkPagination.vue';
|
||||
import MkInviteCode from '@/components/MkInviteCode.vue';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { serverErrorImageUrl, instance } from '@/instance';
|
||||
import { $i } from '@/account';
|
||||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
const currentInviteLimit = ref<null | number>(null);
|
||||
const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
|
||||
const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
|
||||
|
||||
const pagination: Paging = {
|
||||
endpoint: 'invite/list' as const,
|
||||
limit: 10,
|
||||
};
|
||||
|
||||
const resetCycle = computed<null | string>(() => {
|
||||
if (!inviteLimitCycle) return null;
|
||||
|
||||
const minutes = inviteLimitCycle;
|
||||
if (minutes < 60) return minutes + i18n.ts._time.minute;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + i18n.ts._time.hour;
|
||||
return Math.floor(hours / 24) + i18n.ts._time.day;
|
||||
});
|
||||
|
||||
async function create() {
|
||||
const ticket = await os.api('invite/create');
|
||||
os.alert({
|
||||
type: 'success',
|
||||
title: i18n.ts.inviteCodeCreated,
|
||||
text: ticket.code,
|
||||
});
|
||||
|
||||
pagingComponent.value?.prepend(ticket);
|
||||
update();
|
||||
}
|
||||
|
||||
function deleted(id: string) {
|
||||
if (pagingComponent.value) {
|
||||
pagingComponent.value.items.delete(id);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
async function update() {
|
||||
currentInviteLimit.value = (await os.api('invite/limit')).remaining;
|
||||
}
|
||||
|
||||
update();
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.invite,
|
||||
icon: 'ti ti-user-plus',
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.img {
|
||||
vertical-align: bottom;
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
@@ -112,7 +112,7 @@ definePageMetadata(computed(() => list ? {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ import XAntenna from './editor.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { useRouter } from '@/router';
|
||||
import { antennasCache } from '@/cache';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -26,13 +27,10 @@ let draft = $ref({
|
||||
});
|
||||
|
||||
function onAntennaCreated() {
|
||||
antennasCache.delete();
|
||||
router.push('/my/antennas');
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.manageAntennas,
|
||||
icon: 'ti ti-antenna',
|
||||
|
@@ -10,6 +10,7 @@ import * as os from '@/os';
|
||||
import { i18n } from '@/i18n';
|
||||
import { useRouter } from '@/router';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { antennasCache } from '@/cache';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -20,6 +21,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
function onAntennaUpdated() {
|
||||
antennasCache.delete();
|
||||
router.push('/my/antennas');
|
||||
}
|
||||
|
||||
@@ -27,10 +29,6 @@ os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) =
|
||||
antenna = antennaResponse;
|
||||
});
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
definePageMetadata({
|
||||
title: i18n.ts.manageAntennas,
|
||||
icon: 'ti ti-antenna',
|
||||
|
@@ -2,15 +2,20 @@
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :contentMax="700">
|
||||
<div class="ieepwinx">
|
||||
<MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
<div>
|
||||
<div v-if="antennas.length === 0" class="empty">
|
||||
<div class="_fullinfo">
|
||||
<img :src="infoImageUrl" class="_ghost"/>
|
||||
<div>{{ i18n.ts.nothing }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<MkPagination v-slot="{items}" ref="list" :pagination="pagination">
|
||||
<MkA v-for="antenna in items" :key="antenna.id" class="ljoevbzj" :to="`/my/antennas/${antenna.id}`">
|
||||
<div class="name">{{ antenna.name }}</div>
|
||||
</MkA>
|
||||
</MkPagination>
|
||||
<MkButton :link="true" to="/my/antennas/create" primary :class="$style.add"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
|
||||
|
||||
<div v-if="antennas.length > 0" class="_gaps">
|
||||
<MkA v-for="antenna in antennas" :key="antenna.id" :class="$style.antenna" :to="`/my/antennas/${antenna.id}`">
|
||||
<div class="name">{{ antenna.name }}</div>
|
||||
</MkA>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
@@ -18,19 +23,31 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { antennasCache } from '@/cache';
|
||||
import { api } from '@/os';
|
||||
import { onActivated } from 'vue';
|
||||
import { infoImageUrl } from '@/instance';
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'antennas/list' as const,
|
||||
noPaging: true,
|
||||
limit: 10,
|
||||
};
|
||||
const antennas = $computed(() => antennasCache.value.value ?? []);
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
function fetch() {
|
||||
antennasCache.fetch(() => api('antennas/list'));
|
||||
}
|
||||
|
||||
fetch();
|
||||
|
||||
const headerActions = $computed(() => [{
|
||||
asFullButton: true,
|
||||
icon: 'ti ti-refresh',
|
||||
text: i18n.ts.reload,
|
||||
handler: () => {
|
||||
antennasCache.delete();
|
||||
fetch();
|
||||
},
|
||||
}]);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
@@ -38,30 +55,30 @@ definePageMetadata({
|
||||
title: i18n.ts.manageAntennas,
|
||||
icon: 'ti ti-antenna',
|
||||
});
|
||||
|
||||
onActivated(() => {
|
||||
antennasCache.fetch(() => api('antennas/list'));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ieepwinx {
|
||||
<style lang="scss" module>
|
||||
.add {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
|
||||
> .add {
|
||||
margin: 0 auto 16px auto;
|
||||
}
|
||||
.antenna {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
border: solid 1px var(--divider);
|
||||
border-radius: 6px;
|
||||
|
||||
.ljoevbzj {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
border: solid 1px var(--divider);
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
border: solid 1px var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
> .name {
|
||||
font-weight: bold;
|
||||
}
|
||||
&:hover {
|
||||
border: solid 1px var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user