Merge branch 'develop' into minify-backend

This commit is contained in:
syuilo 2025-12-31 13:56:39 +09:00
commit e44f993b6b
68 changed files with 1462 additions and 567 deletions

View File

@ -48,6 +48,13 @@ jobs:
image: redis:7
ports:
- 56312:6379
meilisearch:
image: getmeili/meilisearch:v1.3.4
ports:
- 57712:7700
env:
MEILI_NO_ANALYTICS: true
MEILI_ENV: development
steps:
- uses: actions/checkout@v6.0.1

View File

@ -4,7 +4,10 @@
-
### Client
-
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
- Enhance: ウィジェットの設定項目のラベルの多言語対応
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
### Server
-

View File

@ -26,6 +26,8 @@
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/misskey-dev/misskey)
<a href="https://flatt.tech/oss/gmo/trampoline" target="_blank"><img src="https://flatt.tech/assets/images/badges/gmo-oss.svg" height="24px"/></a>
</div>
## Thanks

View File

@ -1406,6 +1406,7 @@ youAreAdmin: "あなたは管理者です"
frame: "フレーム"
presets: "プリセット"
zeroPadding: "ゼロ埋め"
nothingToConfigure: "設定項目はありません"
_imageEditing:
_vars:
@ -2602,6 +2603,43 @@ _widgets:
birthdayFollowings: "今日誕生日のユーザー"
chat: "ダイレクトメッセージ"
_widgetOptions:
showHeader: "ヘッダーを表示"
transparent: "背景を透明にする"
height: "高さ"
_button:
colored: "色付き"
_clock:
size: "サイズ"
thickness: "針の太さ"
thicknessThin: "細い"
thicknessMedium: "普通"
thicknessThick: "太い"
graduations: "文字盤の目盛り"
graduationDots: "ドット"
graduationArabic: "アラビア数字"
fadeGraduations: "目盛りをフェード"
sAnimation: "秒針のアニメーション"
sAnimationElastic: "リアル"
sAnimationEaseOut: "滑らか"
twentyFour: "24時間表示"
labelTime: "時刻"
labelTz: "タイムゾーン"
labelTimeAndTz: "時刻とタイムゾーン"
timezone: "タイムゾーン"
showMs: "ミリ秒を表示"
showLabel: "ラベルを表示"
_jobQueue:
sound: "音を鳴らす"
_rss:
url: "RSSフィードのURL"
refreshIntervalSec: "更新間隔(秒)"
maxEntries: "最大表示件数"
_rssTicker:
shuffle: "表示順をシャッフル"
duration: "ティッカーのスクロール速度(秒)"
reverse: "逆方向にスクロール"
_cw:
hide: "隠す"
show: "もっと見る"
@ -3418,7 +3456,6 @@ _imageEffector:
title: "エフェクト"
addEffect: "エフェクトを追加"
discardChangesConfirm: "変更を破棄して終了しますか?"
nothingToConfigure: "設定項目はありません"
failedToLoadImage: "画像の読み込みに失敗しました"
_fxs:

View File

@ -11,3 +11,11 @@ services:
environment:
POSTGRES_DB: "test-misskey"
POSTGRES_HOST_AUTH_METHOD: trust
meilisearchtest:
image: getmeili/meilisearch:v1.3.4
ports:
- "127.0.0.1:57712:7700"
environment:
- MEILI_NO_ANALYTICS=true
- MEILI_ENV=development

View File

@ -0,0 +1,483 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterAll, afterEach, beforeAll, describe, expect, test } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import type { Index, MeiliSearch } from 'meilisearch';
import { type Config, loadConfig } from '@/config.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { SearchService } from '@/core/SearchService.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import {
type BlockingsRepository,
type ChannelsRepository,
type FollowingsRepository,
type MutingsRepository,
type NotesRepository,
type UserProfilesRepository,
type UsersRepository,
type MiChannel,
type MiNote,
type MiUser,
} from '@/models/_.js';
describe('SearchService', () => {
type TestContext = {
app: TestingModule;
service: SearchService;
cacheService: CacheService;
idService: IdService;
mutingsRepository: MutingsRepository;
blockingsRepository: BlockingsRepository;
usersRepository: UsersRepository;
userProfilesRepository: UserProfilesRepository;
notesRepository: NotesRepository;
channelsRepository: ChannelsRepository;
followingsRepository: FollowingsRepository;
indexer?: (note: MiNote) => Promise<void>;
};
const meilisearchSettings = {
searchableAttributes: [
'text',
'cw',
],
sortableAttributes: [
'createdAt',
],
filterableAttributes: [
'createdAt',
'userId',
'userHost',
'channelId',
'tags',
],
typoTolerance: {
enabled: false,
},
pagination: {
maxTotalHits: 10000,
},
};
async function buildContext(configOverride?: Config): Promise<TestContext> {
const builder = Test.createTestingModule({
imports: [
GlobalModule,
CoreModule,
],
});
if (configOverride) {
builder.overrideProvider(DI.config).useValue(configOverride);
}
const app = await builder.compile();
app.enableShutdownHooks();
return {
app,
service: app.get(SearchService),
cacheService: app.get(CacheService),
idService: app.get(IdService),
mutingsRepository: app.get(DI.mutingsRepository),
blockingsRepository: app.get(DI.blockingsRepository),
usersRepository: app.get(DI.usersRepository),
userProfilesRepository: app.get(DI.userProfilesRepository),
notesRepository: app.get(DI.notesRepository),
channelsRepository: app.get(DI.channelsRepository),
followingsRepository: app.get(DI.followingsRepository),
};
}
async function cleanupContext(ctx: TestContext) {
await ctx.notesRepository.createQueryBuilder().delete().execute();
await ctx.mutingsRepository.createQueryBuilder().delete().execute();
await ctx.blockingsRepository.createQueryBuilder().delete().execute();
await ctx.followingsRepository.createQueryBuilder().delete().execute();
await ctx.channelsRepository.createQueryBuilder().delete().execute();
await ctx.userProfilesRepository.createQueryBuilder().delete().execute();
await ctx.usersRepository.createQueryBuilder().delete().execute();
}
async function createUser(ctx: TestContext, data: Partial<MiUser> = {}) {
const id = ctx.idService.gen();
const username = data.username ?? `user_${id}`;
const usernameLower = data.usernameLower ?? username.toLowerCase();
const user = await ctx.usersRepository
.insert({
id,
username,
usernameLower,
...data,
})
.then(x => ctx.usersRepository.findOneByOrFail(x.identifiers[0]));
await ctx.userProfilesRepository.insert({
userId: id,
});
return user;
}
async function createChannel(ctx: TestContext, user: MiUser, data: Partial<MiChannel> = {}) {
const id = ctx.idService.gen();
const channel = await ctx.channelsRepository
.insert({
id,
userId: user.id,
name: data.name ?? `channel_${id}`,
...data,
})
.then(x => ctx.channelsRepository.findOneByOrFail(x.identifiers[0]));
return channel;
}
async function createNote(ctx: TestContext, user: MiUser, data: Partial<MiNote> = {}, time?: number) {
const id = time == null ? ctx.idService.gen() : ctx.idService.gen(time);
const note = await ctx.notesRepository
.insert({
id,
text: 'hello',
userId: user.id,
userHost: user.host,
visibility: 'public',
tags: [],
...data,
})
.then(x => ctx.notesRepository.findOneByOrFail(x.identifiers[0]));
if (ctx.indexer) {
await ctx.indexer(note);
}
return note;
}
async function createFollowing(ctx: TestContext, follower: MiUser, followee: MiUser) {
await ctx.followingsRepository.insert({
id: ctx.idService.gen(),
followerId: follower.id,
followeeId: followee.id,
followerHost: follower.host,
followeeHost: followee.host,
});
}
function clearUserCaches(ctx: TestContext, userId: MiUser['id']) {
ctx.cacheService.userMutingsCache.delete(userId);
ctx.cacheService.userBlockedCache.delete(userId);
ctx.cacheService.userBlockingCache.delete(userId);
}
async function createMuting(ctx: TestContext, muter: MiUser, mutee: MiUser) {
await ctx.mutingsRepository.insert({
id: ctx.idService.gen(),
muterId: muter.id,
muteeId: mutee.id,
});
clearUserCaches(ctx, muter.id);
}
async function createBlocking(ctx: TestContext, blocker: MiUser, blockee: MiUser) {
await ctx.blockingsRepository.insert({
id: ctx.idService.gen(),
blockerId: blocker.id,
blockeeId: blockee.id,
});
clearUserCaches(ctx, blocker.id);
clearUserCaches(ctx, blockee.id);
}
function defineSearchNoteTests(
getCtx: () => TestContext,
{
supportsFollowersVisibility,
sinceIdOrder,
}: {
supportsFollowersVisibility: boolean;
sinceIdOrder: 'asc' | 'desc';
},
) {
describe('searchNote', () => {
test('filters notes by visibility (followers only visible to followers)', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const publicNote = await createNote(ctx, author, { text: 'hello public', visibility: 'public' });
const followersNote = await createNote(ctx, author, { text: 'hello followers', visibility: 'followers' });
const beforeFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(beforeFollow.map(note => note.id)).toEqual([publicNote.id]);
await createFollowing(ctx, me, author);
const afterFollow = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
const expectedIds = supportsFollowersVisibility
? [followersNote.id, publicNote.id]
: [publicNote.id];
expect(afterFollow.map(note => note.id).sort()).toEqual(expectedIds.sort());
});
test('filters out suspended users via base note filtering', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const active = await createUser(ctx, { username: 'active', usernameLower: 'active', host: null });
const suspended = await createUser(ctx, { username: 'suspended', usernameLower: 'suspended', host: null, isSuspended: true });
const activeNote = await createNote(ctx, active, { text: 'hello active', visibility: 'public' });
await createNote(ctx, suspended, { text: 'hello suspended', visibility: 'public' });
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id)).toEqual([activeNote.id]);
});
test('filters by userId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const alice = await createUser(ctx, { username: 'alice', usernameLower: 'alice', host: null });
const bob = await createUser(ctx, { username: 'bob', usernameLower: 'bob', host: null });
const aliceNote = await createNote(ctx, alice, { text: 'hello alice', visibility: 'public' });
await createNote(ctx, bob, { text: 'hello bob', visibility: 'public' });
const result = await ctx.service.searchNote('hello', me, { userId: alice.id }, { limit: 10 });
expect(result.map(note => note.id)).toEqual([aliceNote.id]);
});
test('filters by channelId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const channelA = await createChannel(ctx, author, { name: 'channel-a' });
const channelB = await createChannel(ctx, author, { name: 'channel-b' });
const channelNote = await createNote(ctx, author, { text: 'hello channel', channelId: channelA.id, visibility: 'public' });
await createNote(ctx, author, { text: 'hello other', channelId: channelB.id, visibility: 'public' });
const result = await ctx.service.searchNote('hello', me, { channelId: channelA.id }, { limit: 10 });
expect(result.map(note => note.id)).toEqual([channelNote.id]);
});
test('filters by host', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const local = await createUser(ctx, { username: 'local', usernameLower: 'local', host: null });
const remote = await createUser(ctx, { username: 'remote', usernameLower: 'remote', host: 'example.com' });
const localNote = await createNote(ctx, local, { text: 'hello local', visibility: 'public' });
const remoteNote = await createNote(ctx, remote, { text: 'hello remote', visibility: 'public', userHost: 'example.com' });
const localResult = await ctx.service.searchNote('hello', me, { host: '.' }, { limit: 10 });
expect(localResult.map(note => note.id)).toEqual([localNote.id]);
const remoteResult = await ctx.service.searchNote('hello', me, { host: 'example.com' }, { limit: 10 });
expect(remoteResult.map(note => note.id)).toEqual([remoteNote.id]);
});
describe('muting and blocking', () => {
test('filters out muted users', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const muted = await createUser(ctx, { username: 'muted', usernameLower: 'muted', host: null });
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
await createNote(ctx, muted, { text: 'hello muted', visibility: 'public' });
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
await createMuting(ctx, me, muted);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id)).toEqual([otherNote.id]);
});
test('filters out users who block me', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const blocker = await createUser(ctx, { username: 'blocker', usernameLower: 'blocker', host: null });
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
await createNote(ctx, blocker, { text: 'hello blocker', visibility: 'public' });
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
await createBlocking(ctx, blocker, me);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id)).toEqual([otherNote.id]);
});
test('filters no out users I block', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const blocked = await createUser(ctx, { username: 'blocked', usernameLower: 'blocked', host: null });
const other = await createUser(ctx, { username: 'other', usernameLower: 'other', host: null });
const blockedNote = await createNote(ctx, blocked, { text: 'hello blocked', visibility: 'public' });
const otherNote = await createNote(ctx, other, { text: 'hello other', visibility: 'public' });
await createBlocking(ctx, me, blocked);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10 });
expect(result.map(note => note.id).sort()).toEqual([otherNote.id, blockedNote.id].sort());
});
});
describe('pagination', () => {
test('paginates with sinceId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const t1 = Date.now() - 3000;
const t2 = Date.now() - 2000;
const t3 = Date.now() - 1000;
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id });
const expected = sinceIdOrder === 'asc'
? [note2.id, note3.id]
: [note3.id, note2.id];
expect(result.map(note => note.id)).toEqual(expected);
});
test('paginates with untilId', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const t1 = Date.now() - 3000;
const t2 = Date.now() - 2000;
const t3 = Date.now() - 1000;
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, untilId: note3.id });
expect(result.map(note => note.id)).toEqual([note2.id, note1.id]);
});
test('paginates with sinceId and untilId together', async () => {
const ctx = getCtx();
const me = await createUser(ctx, { username: 'me', usernameLower: 'me', host: null });
const author = await createUser(ctx, { username: 'author', usernameLower: 'author', host: null });
const t1 = Date.now() - 4000;
const t2 = Date.now() - 3000;
const t3 = Date.now() - 2000;
const t4 = Date.now() - 1000;
const note1 = await createNote(ctx, author, { text: 'hello' }, t1);
const note2 = await createNote(ctx, author, { text: 'hello' }, t2);
const note3 = await createNote(ctx, author, { text: 'hello' }, t3);
const note4 = await createNote(ctx, author, { text: 'hello' }, t4);
const result = await ctx.service.searchNote('hello', me, {}, { limit: 10, sinceId: note1.id, untilId: note4.id });
expect(result.map(note => note.id)).toEqual([note3.id, note2.id]);
});
});
});
}
describe('sqlLike', () => {
let ctx: TestContext;
beforeAll(async () => {
ctx = await buildContext();
});
afterAll(async () => {
await ctx.app.close();
});
afterEach(async () => {
await cleanupContext(ctx);
});
defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: true, sinceIdOrder: 'asc' });
});
describe('meilisearch', () => {
let ctx: TestContext;
let meilisearch: MeiliSearch;
let meilisearchIndex: Index;
let meiliConfig: Config;
beforeAll(async () => {
const baseConfig = loadConfig();
meiliConfig = {
...baseConfig,
fulltextSearch: {
provider: 'meilisearch',
},
meilisearch: {
host: '127.0.0.1',
port: '57712',
apiKey: '',
index: 'test-search-service',
scope: 'global',
ssl: false,
},
};
ctx = await buildContext(meiliConfig);
meilisearch = ctx.app.get(DI.meilisearch) as MeiliSearch;
meilisearchIndex = meilisearch.index(`${meiliConfig.meilisearch!.index}---notes`);
const settingsTask = await meilisearchIndex.updateSettings(meilisearchSettings);
await meilisearch.tasks.waitForTask(settingsTask.taskUid);
const clearTask = await meilisearchIndex.deleteAllDocuments();
await meilisearch.tasks.waitForTask(clearTask.taskUid);
ctx.indexer = async (note: MiNote) => {
if (note.text == null && note.cw == null) return;
if (!['home', 'public'].includes(note.visibility)) return;
if (meiliConfig.meilisearch?.scope === 'local' && note.userHost != null) return;
const task = await meilisearchIndex.addDocuments([{
id: note.id,
createdAt: ctx.idService.parse(note.id).date.getTime(),
userId: note.userId,
userHost: note.userHost,
channelId: note.channelId,
cw: note.cw,
text: note.text,
tags: note.tags,
}], {
primaryKey: 'id',
});
await meilisearch.tasks.waitForTask(task.taskUid);
};
});
afterAll(async () => {
await ctx.app.close();
});
afterEach(async () => {
await cleanupContext(ctx);
const clearTask = await meilisearchIndex.deleteAllDocuments();
await meilisearch.tasks.waitForTask(clearTask.taskUid);
});
defineSearchNoteTests(() => ctx, { supportsFollowersVisibility: false, sinceIdOrder: 'desc' });
});
});

View File

@ -69,7 +69,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="(f, i) in foldersPaginator.items.value"
:key="f.id"
v-anim="i"
:class="$style.folder"
:folder="f"
:selectMode="select === 'folder'"
:isSelected="selectedFolders.some(x => x.id === f.id)"
@ -102,7 +101,6 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<XFile
v-for="file in item.items" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@ -125,7 +123,6 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<XFile
v-for="file in filesPaginator.items.value" :key="file.id"
:class="$style.file"
:file="file"
:folder="folder"
:isSelected="selectedFiles.some(x => x.id === file.id)"
@ -135,7 +132,14 @@ SPDX-License-Identifier: AGPL-3.0-only
/>
</TransitionGroup>
<MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
<MkButton
v-show="filesPaginator.canFetchOlder.value"
v-appear="shouldEnableInfiniteScroll ? filesPaginator.fetchOlder : null"
:class="$style.loadMore"
primary
rounded
@click="filesPaginator.fetchOlder()"
>{{ i18n.ts.loadMore }}</MkButton>
<div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
<div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div>
@ -182,10 +186,12 @@ const props = withDefaults(defineProps<{
type?: string;
multiple?: boolean;
select?: 'file' | 'folder' | null;
forceDisableInfiniteScroll?: boolean;
}>(), {
initialFolder: null,
multiple: false,
select: null,
forceDisableInfiniteScroll: false,
});
const emit = defineEmits<{
@ -194,6 +200,10 @@ const emit = defineEmits<{
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
}>();
const shouldEnableInfiniteScroll = computed(() => {
return prefer.r.enableInfiniteScroll.value && !props.forceDisableInfiniteScroll;
});
const folder = ref<Misskey.entities.DriveFolder | null>(null);
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);

View File

@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:enterFromClass="$style.transition_x_enterFrom"
:leaveToClass="$style.transition_x_leaveTo"
>
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
<template #preview>
<div :class="$style.embedCodeGenPreviewWrapper">
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
@ -43,8 +42,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</div>
</div>
</div>
<div :class="$style.embedCodeGenSettings" class="_gaps">
</template>
<template #controls>
<div class="_spacer _gaps">
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
<template #suffix>px</template>
@ -63,7 +63,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
<div class="_gaps_s">
@ -89,18 +90,17 @@ import { url } from '@@/js/config.js';
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkButton from '@/components/MkButton.vue';
import MkCode from '@/components/MkCode.vue';
import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
import { prefer } from '@/preferences.js';
const emit = defineEmits<{
(ev: 'ok'): void;
@ -302,29 +302,6 @@ onUnmounted(() => {
height: 100%;
}
.embedCodeGenInputRoot {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.embedCodeGenPreviewRoot {
position: relative;
cursor: not-allowed;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.embedCodeGenPreviewWrapper {
display: flex;
flex-direction: column;
@ -372,11 +349,6 @@ onUnmounted(() => {
color-scheme: light dark;
}
.embedCodeGenSettings {
padding: 24px;
overflow-y: scroll;
}
.embedCodeGenResultRoot {
box-sizing: border-box;
padding: 24px;
@ -417,11 +389,4 @@ onUnmounted(() => {
.embedCodeGenResultButtons {
margin: 0 auto;
}
@container (max-width: 800px) {
.embedCodeGenInputRoot {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div>
<MkPagination v-slot="{ items }" :paginator="paginator">
<div :class="[$style.fileList, { [$style.grid]: viewMode === 'grid', [$style.list]: viewMode === 'list', '_gaps_s': viewMode === 'list' }]">
<div
:class="{
[$style.grid]: viewMode === 'grid',
[$style.list]: viewMode === 'list',
'_gaps_s': viewMode === 'list',
}"
>
<MkA
v-for="file in items"
:key="file.id"

View File

@ -0,0 +1,84 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="v, k in form">
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1" :manualSave="v.manualSave">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm" :manualSave="v.manualSave">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm" :manualSave="v.manualSave">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
</MkSelect>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
<XFile
v-else-if="v.type === 'drive-file'"
:fileId="v.defaultFileId"
:validate="async f => !v.validate || await v.validate(f)"
@update="f => values[k] = f"
/>
</template>
</div>
<MkResult v-else type="empty" :text="i18n.ts.nothingToConfigure"/>
</template>
<script lang="ts" setup>
import XFile from '@/components/MkForm.file.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkRange from '@/components/MkRange.vue';
import MkButton from '@/components/MkButton.vue';
import MkRadios from '@/components/MkRadios.vue';
import { i18n } from '@/i18n.js';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
const props = defineProps<{
form: Form;
}>();
// TODO:
const values = defineModel<Record<string, any>>({ required: true });
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
return def.enum.map((v) => {
if (typeof v === 'string') {
return { value: v, label: v };
} else {
return { value: v.value, label: v.label };
}
});
}
function getRadioKey(e: RadioFormItem['options'][number]) {
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
}
</script>

View File

@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
<div v-if="Object.keys(form).filter(item => !form[item].hidden).length > 0" class="_gaps_m">
<template v-for="(v, k) in Object.fromEntries(Object.entries(form))">
<template v-if="typeof v.hidden == 'function' ? v.hidden(values) : v.hidden"></template>
<MkInput v-else-if="v.type === 'number'" v-model="values[k]" type="number" :step="v.step || 1">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkInput v-else-if="v.type === 'string' && !v.multiline" v-model="values[k]" type="text" :mfmAutocomplete="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkInput>
<MkTextarea v-else-if="v.type === 'string' && v.multiline" v-model="values[k]" :mfmAutocomplete="v.treatAsMfm" :mfmPreview="v.treatAsMfm">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkTextarea>
<MkSwitch v-else-if="v.type === 'boolean'" v-model="values[k]">
<span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
</MkSelect>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.options" :key="getRadioKey(option)" :value="option.value">{{ option.label }}</option>
</MkRadios>
<MkRange v-else-if="v.type === 'range'" v-model="values[k]" :min="v.min" :max="v.max" :step="v.step" :textConverter="v.textConverter">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<template v-if="v.description" #caption>{{ v.description }}</template>
</MkRange>
<MkButton v-else-if="v.type === 'button'" @click="v.action($event, values)">
<span v-text="v.content || k"></span>
</MkButton>
<XFile
v-else-if="v.type === 'drive-file'"
:fileId="v.defaultFileId"
:validate="async f => !v.validate || await v.validate(f)"
@update="f => values[k] = f"
/>
</template>
</div>
<MkResult v-else type="empty"/>
<MkForm v-model="values" :form="form"/>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { reactive, useTemplateRef } from 'vue';
import MkInput from './MkInput.vue';
import MkTextarea from './MkTextarea.vue';
import MkSwitch from './MkSwitch.vue';
import MkSelect from './MkSelect.vue';
import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue';
import XFile from './MkFormDialog.file.vue';
import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
import { ref, useTemplateRef } from 'vue';
import type { Form } from '@/utility/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import MkForm from '@/components/MkForm.vue';
const props = defineProps<{
title: string;
@ -96,19 +46,22 @@ const emit = defineEmits<{
}>();
const dialog = useTemplateRef('dialog');
const values = reactive({});
const values = ref((() => {
const obj: Record<string, any> = {};
for (const item in props.form) {
if ('default' in props.form[item]) {
values[item] = props.form[item].default ?? null;
obj[item] = props.form[item].default ?? null;
} else {
values[item] = null;
obj[item] = null;
}
}
return obj;
})());
function ok() {
emit('done', {
result: values,
result: values.value,
});
dialog.value?.close();
}
@ -119,18 +72,4 @@ function cancel() {
});
dialog.value?.close();
}
function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
return def.enum.map((v) => {
if (typeof v === 'string') {
return { value: v, label: v };
} else {
return { value: v.value, label: v.label };
}
});
}
function getRadioKey(e: RadioFormItem['options'][number]) {
return typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
}
</script>

View File

@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<MkPreviewWithControls>
<template #preview>
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@ -30,8 +29,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
</div>
</div>
</div>
<div :class="$style.controls">
</template>
<template #controls>
<div class="_spacer _gaps">
<XLayer
v-for="(layer, i) in layers"
@ -44,9 +44,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
@ -56,15 +55,12 @@ import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.
import { i18n } from '@/i18n.js';
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import XLayer from '@/components/MkImageEffectorDialog.Layer.vue';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { FXS } from '@/utility/image-effector/fxs.js';
import { genId } from '@/utility/id.js';
import { prefer } from '@/preferences.js';
const props = defineProps<{
image: File;
@ -367,33 +363,6 @@ function onImagePointerdown(ev: PointerEvent) {
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
@ -442,16 +411,6 @@ function onImagePointerdown(ev: PointerEvent) {
}
}
.previewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewCanvas {
position: absolute;
top: 0;
@ -467,15 +426,4 @@ function onImagePointerdown(ev: PointerEvent) {
object-fit: contain;
touch-action: none;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput>
</div>
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
{{ i18n.ts._imageEffector.nothingToConfigure }}
{{ i18n.ts.nothingToConfigure }}
</div>
</div>
</template>

View File

@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<MkPreviewWithControls>
<template #preview>
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@ -28,8 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
</template>
<template #controls>
<div class="_spacer _gaps">
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
@ -147,9 +147,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
</MkInfo>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
@ -161,8 +160,8 @@ import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-r
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from './MkPreviewWithControls.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkRange from '@/components/MkRange.vue';
@ -173,8 +172,6 @@ import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { prefer } from '@/preferences.js';
const $i = ensureSignin();
@ -412,33 +409,6 @@ function getRgb(hex: string | number): [number, number, number] | null {
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
@ -495,15 +465,4 @@ function getRgb(hex: string | number): [number, number, number] | null {
box-sizing: border-box;
object-fit: contain;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span><MkEllipsis/></span>
</span>
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1" :class="[$style.componentItem]">
<div v-else-if="item.type === 'component'" role="menuitem" tabindex="-1">
<component :is="item.component" v-bind="item.props"/>
</div>

View File

@ -118,7 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.draftActions" class="_buttons">
<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
<MkButton
:class="$style.itemButton"
small
@click="cancelSchedule(draft)"
>
@ -126,7 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton>
<!-- TODO
<MkButton
:class="$style.itemButton"
small
@click="reSchedule(draft)"
>
@ -136,7 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<MkButton
v-else
:class="$style.itemButton"
small
@click="restoreDraft(draft)"
>
@ -147,7 +144,6 @@ SPDX-License-Identifier: AGPL-3.0-only
danger
small
:iconOnly="true"
:class="$style.itemButton"
style="margin-left: auto;"
@click="deleteDraft(draft)"
>

View File

@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<header :class="$style.header">
<div :class="$style.headerLeft">
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" :class="$style.account" class="_button" @click="openAccountMenu">
<button ref="accountMenuEl" v-click-anime v-tooltip="i18n.ts.account" class="_button" @click="openAccountMenu">
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
</button>
</div>
@ -1469,9 +1469,6 @@ defineExpose({
padding: 8px;
}
.account {
}
.avatar {
display: block;
width: 28px;

View File

@ -0,0 +1,93 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<div :class="$style.previewContent">
<slot name="preview"></slot>
</div>
<div v-if="previewLoading" :class="$style.previewLoading">
<MkLoading/>
</div>
</div>
<div :class="$style.controls">
<slot name="controls"></slot>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
previewLoading?: boolean;
}>(), {
previewLoading: false,
});
defineSlots<{
preview: () => any;
controls: () => any;
}>();
</script>
<style lang="scss" module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.previewContent {
position: relative;
width: 100%;
height: 100%;
overflow: clip;
}
.previewLoading {
position: absolute;
inset: 0;
background-color: color(from var(--MI_THEME-panel) srgb r g b / 0.7);
display: flex;
justify-content: center;
align-items: center;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root" class="_gaps_m">
<div class="_gaps_m">
<MkInput v-model="q_name" data-cy-server-name>
<template #label>{{ i18n.ts.instanceName }}</template>
</MkInput>
@ -370,8 +370,3 @@ function applySettings() {
});
}
</script>
<style lang="scss" module>
.root {
}
</style>

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="$style.root" @click="(ev) => emit('click', ev)">
<span v-if="iconClass" :class="[$style.icon, iconClass]"></span>
<span :class="$style.content">{{ content }}</span>
<span>{{ content }}</span>
<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
</MkButton>

View File

@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
>
<template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template>
<div :class="$style.root">
<div :class="$style.container">
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
<MkPreviewWithControls>
<template #preview>
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
<div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
@ -28,8 +27,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
</div>
</div>
</div>
<div :class="$style.controls">
</template>
<template #controls>
<div class="_spacer _gaps">
<div class="_gaps_s">
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
@ -57,9 +57,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
</div>
</div>
</div>
</div>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
@ -69,6 +68,7 @@ import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/Water
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
@ -411,33 +411,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
</script>
<style module>
.root {
container-type: inline-size;
height: 100%;
}
.container {
height: 100%;
display: grid;
grid-template-columns: 1fr 400px;
}
.preview {
position: relative;
background-color: var(--MI_THEME-bg);
background-image: linear-gradient(135deg, transparent 30%, var(--MI_THEME-panel) 30%, var(--MI_THEME-panel) 50%, transparent 50%, transparent 80%, var(--MI_THEME-panel) 80%, var(--MI_THEME-panel) 100%);
background-size: 20px 20px;
}
.animatedBg {
animation: bg 1.2s linear infinite;
}
@keyframes bg {
0% { background-position: 0 0; }
100% { background-position: -20px -20px; }
}
.previewContainer {
display: flex;
flex-direction: column;
@ -474,16 +447,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
}
}
.previewSpinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewCanvas {
position: absolute;
top: 0;
@ -494,15 +457,4 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
box-sizing: border-box;
object-fit: contain;
}
.controls {
overflow-y: scroll;
}
@container (max-width: 800px) {
.container {
grid-template-columns: 1fr;
grid-template-rows: 1fr 1fr;
}
}
</style>

View File

@ -0,0 +1,165 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="1000"
:height="600"
:scroll="false"
:withOkButton="true"
@close="cancel()"
@ok="save()"
@closed="emit('closed')"
>
<template #header><i class="ti ti-icons"></i> {{ widgetName }}</template>
<MkPreviewWithControls>
<template #preview>
<div :class="$style.previewWrapper">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div ref="resizerRootEl" :class="$style.previewResizerRoot" inert>
<div
ref="resizerEl"
:class="$style.previewResizer"
:style="{ transform: widgetStyle }"
>
<component
:is="`widget-${widgetName}`"
:widget="{ name: widgetName, id: '__PREVIEW__', data: settings }"
></component>
</div>
</div>
</div>
</template>
<template #controls>
<div class="_spacer">
<MkForm v-model="settings" :form="form"/>
</div>
</template>
</MkPreviewWithControls>
</MkModalWindow>
</template>
<script setup lang="ts">
import { reactive, useTemplateRef, ref, computed, watch, onBeforeUnmount, onMounted } from 'vue';
import MkPreviewWithControls from './MkPreviewWithControls.vue';
import type { Form } from '@/utility/form.js';
import { deepClone } from '@/utility/clone.js';
import { i18n } from '@/i18n.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import MkForm from '@/components/MkForm.vue';
const props = defineProps<{
widgetName: string;
form: Form;
currentSettings: Record<string, any>;
}>();
const emit = defineEmits<{
(ev: 'saved', settings: Record<string, any>): void;
(ev: 'canceled'): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
const settings = ref<Record<string, any>>(deepClone(props.currentSettings));
function save() {
emit('saved', deepClone(settings.value));
dialog.value?.close();
}
function cancel() {
emit('canceled');
dialog.value?.close();
}
//#region
const resizerRootEl = useTemplateRef('resizerRootEl');
const resizerEl = useTemplateRef('resizerEl');
const widgetHeight = ref(0);
const widgetScale = ref(1);
const widgetStyle = computed(() => {
return `translate(-50%, -50%) scale(${widgetScale.value})`;
});
const ro1 = new ResizeObserver(() => {
widgetHeight.value = resizerEl.value!.clientHeight;
calcScale();
});
const ro2 = new ResizeObserver(() => {
calcScale();
});
function calcScale() {
if (!resizerRootEl.value) return;
const previewWidth = resizerRootEl.value.clientWidth - 40; // 20px
const previewHeight = resizerRootEl.value.clientHeight - 40; // 20px
const widgetWidth = 280;
const scale = Math.min(previewWidth / widgetWidth, previewHeight / widgetHeight.value, 1); // 1
widgetScale.value = scale;
}
onMounted(() => {
if (resizerEl.value) {
ro1.observe(resizerEl.value);
}
if (resizerRootEl.value) {
ro2.observe(resizerRootEl.value);
}
calcScale();
});
onBeforeUnmount(() => {
ro1.disconnect();
ro2.disconnect();
});
//#endregion
</script>
<style module>
.previewContainer {
display: flex;
flex-direction: column;
height: 100%;
user-select: none;
-webkit-user-drag: none;
}
.previewTitle {
position: absolute;
z-index: 100;
top: 8px;
left: 8px;
padding: 6px 10px;
border-radius: 6px;
font-size: 85%;
}
.previewWrapper {
display: flex;
flex-direction: column;
height: 100%;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.previewResizerRoot {
position: relative;
flex: 1 0;
}
.previewResizer {
position: absolute;
container-type: inline-size;
top: 50%;
left: 50%;
width: 280px;
}
</style>

View File

@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div v-if="show" ref="el" :class="[$style.root]">
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" :class="$style.buttonsLeft" @click="openAccountMenu">
<div v-if="!thin_ && narrow && props.displayMyAvatar && $i" class="_button" @click="openAccountMenu">
<MkAvatar :class="$style.avatar" :user="$i"/>
</div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="[$style.buttons, $style.buttonsLeft]"></div>
<div v-else-if="!thin_ && narrow && !hideTitle" :class="$style.buttons"></div>
<template v-if="pageMetadata">
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
</template>
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="[$style.buttons, $style.buttonsRight]">
<div v-if="(!thin_ && narrow && !hideTitle) || (actions && actions.length > 0)" :class="$style.buttons">
<template v-for="action in actions">
<button v-tooltip.noDelay="action.text" class="_button" :class="[$style.button, { [$style.highlighted]: action.highlighted }]" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button>
</template>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
<div :class="[$style.root, { [$style.warn]: type === 'notFound', [$style.error]: type === 'error' }]" class="_gaps">
<div :class="$style.root" class="_gaps">
<img v-if="type === 'empty' && instance.infoImageUrl" :src="instance.infoImageUrl" draggable="false" :class="$style.img"/>
<MkSystemIcon v-else-if="type === 'empty'" type="info" :class="$style.icon"/>
<img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/>

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
<div ref="rootEl" :class="reversed ? '_pageScrollableReversed' : '_pageScrollable'">
<MkStickyContainer>
<template #header>
<MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/>

View File

@ -16,7 +16,7 @@ export const appearDirective = {
const fn = binding.value;
if (fn == null) return;
const check = throttle<IntersectionObserverCallback>(1000, (entries) => {
const check = throttle<IntersectionObserverCallback>(500, (entries) => {
if (entries.some(entry => entry.isIntersecting)) {
fn();
}

View File

@ -39,7 +39,7 @@ export const directives = {
} as Record<string, Directive>;
declare module 'vue' {
export interface ComponentCustomProperties {
export interface GlobalDirectives {
vUserPreview: typeof userPreviewDirective;
vGetSize: typeof getSizeDirective;
vRipple: typeof rippleDirective;

View File

@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader v-model:tab="headerTab" :tabs="headerTabs">
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
<XGridRemoteComponent v-else-if="headerTab === 'remote'" :class="$style.remote"/>
<XRegisterComponent v-else-if="headerTab === 'register'" :class="$style.register"/>
<XGridRemoteComponent v-else-if="headerTab === 'remote'"/>
<XRegisterComponent v-else-if="headerTab === 'register'"/>
</PageWithHeader>
</template>

View File

@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<MkTabs
v-model:tab="jobState"
:class="$style.jobsTabs" :tabs="[{
:tabs="[{
key: 'all',
title: 'All',
icon: 'ti ti-code-asterisk',
@ -359,8 +359,4 @@ definePage(() => ({
font-size: 85%;
margin: 6px 0;
}
.jobsTabs {
}
</style>

View File

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:enableEmojiMenu="true"
:enableEmojiMenuReaction="true"
/>
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
</MkFukidashi>
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
<div :class="$style.footer">

View File

@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
<div :class="$style.view">
<video ref="videoEl" :class="$style.video" autoplay muted playsinline></video>
<div ref="overlayEl" :class="$style.overlay"></div>
<div ref="overlayEl"></div>
<div :class="$style.controls">
<MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton>

View File

@ -71,7 +71,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserCardMini
:user="user"
:withChart="false"
:class="$style.userSelectedCard"
/>
</div>
<div>

View File

@ -60,6 +60,7 @@ import bytes from '@/filters/bytes.js';
import { definePage } from '@/page.js';
import MkSelect from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { useGlobalEvent } from '@/events.js';
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
import { Paginator } from '@/utility/paginator.js';
@ -123,6 +124,12 @@ function onContextMenu(ev: MouseEvent, file): void {
os.contextMenu(getDriveFileMenu(file), ev);
}
useGlobalEvent('driveFilesDeleted', (files) => {
for (const f of files) {
paginator.removeItem(f.id);
}
});
definePage(() => ({
title: i18n.ts.drivecleaner,
icon: 'ti ti-trash',

View File

@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<MkButton rounded full @click="emit('showMore')">{{ i18n.ts.showMore }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
<p v-if="!fetching && notes.length == 0" :class="$style.empty">{{ i18n.ts.nothing }}</p>
<p v-if="!fetching && notes.length == 0">{{ i18n.ts.nothing }}</p>
</div>
</MkContainer>
</template>

View File

@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div :class="$style.root">
<div>
<MkStickyContainer>
<template #header>
<MkTab

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div :class="[$style.root, acrylic ? $style.acrylic : null]">
<div :class="$style.body">
<div :class="$style.left">
<div>
<button v-click-anime :class="[$style.item, $style.instance]" class="_button" @click="openInstanceMenu">
<img :class="$style.instanceIcon" :src="instance.iconUrl ?? '/favicon.ico'" draggable="false"/>
</button>

View File

@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<div v-if="!forceIconOnly && prefer.r.showNavbarSubButtons.value" :class="$style.subButtons">
<div :class="[$style.subButton, $style.menuEditButton]">
<div :class="$style.subButton">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>
@ -90,7 +90,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="!props.asDrawer">
<div :class="$style.subButtonGapFill"></div>
<div :class="$style.subButtonGapFillDivider"></div>
<div :class="[$style.subButton, $style.toggleButton]">
<div :class="$style.subButton">
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
<path d="M47.488,7.995C47.79,10.11 47.943,12.266 47.943,14.429C47.997,26.989 47.997,84 47.997,84C47.997,84 44.018,118.246 23.997,133.5C-0.374,152.07 -0.003,192 -0.003,192L-0.003,-96C-0.003,-96 0.151,-56.216 23.997,-37.5C40.861,-24.265 46.043,-1.243 47.488,7.995Z" style="fill:var(--MI_THEME-navBg);"/>

View File

@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
v-for="id in ids"
:ref="id"
:key="id"
:class="[$style.column, { '_shadow': withWallpaper }]"
:class="{ '_shadow': withWallpaper }"
:column="columns.find(c => c.id === id)!"
:isStacked="ids.length > 1"
@headerWheel="onWheel"

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.root">
<div>
<div :class="$style.contents">
<!--
デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない)
@ -57,9 +57,6 @@ function goToDeck() {
</script>
<style lang="scss" module>
.root {
}
.contents {
display: flex;
flex-direction: column;

View File

@ -25,6 +25,7 @@ export interface StringFormItem extends FormItemBase {
required?: boolean;
multiline?: boolean;
treatAsMfm?: boolean;
manualSave?: boolean;
}
export interface NumberFormItem extends FormItemBase {
@ -33,6 +34,7 @@ export interface NumberFormItem extends FormItemBase {
description?: string;
required?: boolean;
step?: number;
manualSave?: boolean;
}
export interface BooleanFormItem extends FormItemBase {
@ -145,3 +147,11 @@ type GetItemType<Item extends FormItem> =
export type GetFormResultType<F extends Form> = {
[P in keyof F]: GetItemType<F[P]>;
};
export function getDefaultFormValues<F extends FormWithDefault>(form: F): GetFormResultType<F> {
const result = {} as GetFormResultType<F>;
for (const key of Object.keys(form) as (keyof F)[]) {
result[key] = form[key].default as GetItemType<F[typeof key]>;
}
return result;
}

View File

@ -38,10 +38,12 @@ const name = 'activity';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
view: {

View File

@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
@ -20,6 +21,7 @@ const name = 'ai';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;

View File

@ -37,10 +37,12 @@ const name = 'aiscript';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
script: {
type: 'string',
label: i18n.ts.script,
multiline: true,
default: '(1 + 1)',
hidden: true,

View File

@ -24,6 +24,7 @@ import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
import * as os from '@/os.js';
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
import MkAsUi from '@/components/MkAsUi.vue';
import MkContainer from '@/components/MkContainer.vue';
import { registerAsUiLib } from '@/aiscript/ui.js';
@ -33,11 +34,13 @@ const name = 'aiscriptApp';
const widgetPropsDef = {
script: {
type: 'string',
label: i18n.ts.script,
multiline: true,
default: '',
},
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;

View File

@ -33,11 +33,12 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
const name = i18n.ts._widgets.birthdayFollowings;
const name = 'birthdayFollowings';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;

View File

@ -20,20 +20,24 @@ import * as os from '@/os.js';
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
import { $i } from '@/i.js';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
const name = 'button';
const widgetPropsDef = {
label: {
type: 'string',
label: i18n.ts.label,
default: 'BUTTON',
},
colored: {
type: 'boolean',
label: i18n.ts._widgetOptions._button.colored,
default: true,
},
script: {
type: 'string',
label: i18n.ts.script,
multiline: true,
default: 'Mk:dialog("hello" "world")',
},

View File

@ -50,6 +50,7 @@ const name = 'calendar';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;

View File

@ -29,6 +29,7 @@ const name = 'chat';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;

View File

@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import { i18n } from '@/i18n.js';
import MkContainer from '@/components/MkContainer.vue';
import MkClickerGame from '@/components/MkClickerGame.vue';
@ -23,6 +24,7 @@ const name = 'clicker';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: false,
},
} satisfies FormWithDefault;

View File

@ -44,10 +44,12 @@ const name = 'clock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
size: {
type: 'radio',
label: i18n.ts._widgetOptions._clock.size,
default: 'medium',
options: [{
value: 'small' as const,
@ -62,79 +64,89 @@ const widgetPropsDef = {
},
thickness: {
type: 'radio',
label: i18n.ts._widgetOptions._clock.thickness,
default: 0.2,
options: [{
value: 0.1 as const,
label: 'thin',
label: i18n.ts._widgetOptions._clock.thicknessThin,
}, {
value: 0.2 as const,
label: 'medium',
label: i18n.ts._widgetOptions._clock.thicknessMedium,
}, {
value: 0.3 as const,
label: 'thick',
label: i18n.ts._widgetOptions._clock.thicknessThick,
}],
},
graduations: {
type: 'radio',
label: i18n.ts._widgetOptions._clock.graduations,
default: 'numbers',
options: [{
value: 'none' as const,
label: 'None',
label: i18n.ts.none,
}, {
value: 'dots' as const,
label: 'Dots',
label: i18n.ts._widgetOptions._clock.graduationDots,
}, {
value: 'numbers' as const,
label: 'Numbers',
}],
label: i18n.ts._widgetOptions._clock.graduationArabic,
}, /*, {
value: 'roman' as const,
label: i18n.ts._widgetOptions._clock.graduationRoman,
}*/],
},
fadeGraduations: {
type: 'boolean',
label: i18n.ts._widgetOptions._clock.fadeGraduations,
default: true,
},
sAnimation: {
type: 'radio',
label: i18n.ts._widgetOptions._clock.sAnimation,
default: 'elastic',
options: [{
value: 'none' as const,
label: 'None',
label: i18n.ts.none,
}, {
value: 'elastic' as const,
label: 'Elastic',
label: i18n.ts._widgetOptions._clock.sAnimationElastic,
}, {
value: 'easeOut' as const,
label: 'Ease out',
label: i18n.ts._widgetOptions._clock.sAnimationEaseOut,
}],
},
twentyFour: {
type: 'boolean',
label: i18n.ts._widgetOptions._clock.twentyFour,
default: false,
},
label: {
type: 'radio',
label: i18n.ts.label,
default: 'none',
options: [{
value: 'none' as const,
label: 'None',
label: i18n.ts.none,
}, {
value: 'time' as const,
label: 'Time',
label: i18n.ts._widgetOptions._clock.labelTime,
}, {
value: 'tz' as const,
label: 'TZ',
label: i18n.ts._widgetOptions._clock.labelTz,
}, {
value: 'timeAndTz' as const,
label: 'Time + TZ',
label: i18n.ts._widgetOptions._clock.labelTimeAndTz,
}],
},
timezone: {
type: 'enum',
label: i18n.ts._widgetOptions._clock.timezone,
default: null,
enum: [...timezones.map((tz) => ({
label: tz.name,
value: tz.name.toLowerCase(),
})), {
label: '(auto)',
label: i18n.ts.auto,
value: null,
}],
},

View File

@ -19,6 +19,7 @@ import { useWidgetPropsManager } from './widget.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import { timezones } from '@/utility/timezones.js';
import { i18n } from '@/i18n.js';
import MkDigitalClock from '@/components/MkDigitalClock.vue';
const name = 'digitalClock';
@ -26,29 +27,34 @@ const name = 'digitalClock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
fontSize: {
type: 'number',
label: i18n.ts.fontSize,
default: 1.5,
step: 0.1,
},
showMs: {
type: 'boolean',
label: i18n.ts._widgetOptions._clock.showMs,
default: true,
},
showLabel: {
type: 'boolean',
label: i18n.ts._widgetOptions._clock.showLabel,
default: true,
},
timezone: {
type: 'enum',
label: i18n.ts._widgetOptions._clock.timezone,
default: null,
enum: [...timezones.map((tz) => ({
label: tz.name,
value: tz.name.toLowerCase(),
})), {
label: '(auto)',
label: i18n.ts.auto,
value: null,
}],
},

View File

@ -43,6 +43,7 @@ const name = 'federation';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;

View File

@ -29,12 +29,14 @@ import MkTagCloud from '@/components/MkTagCloud.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
import { i18n } from '@/i18n.js';
const name = 'instanceCloud';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;

View File

@ -61,16 +61,19 @@ import * as sound from '@/utility/sound.js';
import { deepClone } from '@/utility/clone.js';
import { prefer } from '@/preferences.js';
import { genId } from '@/utility/id.js';
import { i18n } from '@/i18n.js';
const name = 'jobQueue';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
sound: {
type: 'boolean',
label: i18n.ts._widgetOptions._jobQueue.sound,
default: false,
},
} satisfies FormWithDefault;

View File

@ -29,10 +29,12 @@ const name = 'memo';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
height: {
type: 'number',
label: i18n.ts.height,
default: 100,
},
} satisfies FormWithDefault;

View File

@ -31,10 +31,12 @@ const name = 'notifications';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
height: {
type: 'number',
label: i18n.ts.height,
default: 300,
},
excludeTypes: {

View File

@ -28,6 +28,7 @@ const name = 'onlineUsers';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: true,
},
} satisfies FormWithDefault;

View File

@ -39,10 +39,12 @@ const name = 'photos';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;

View File

@ -25,28 +25,33 @@ import * as Misskey from 'misskey-js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { i18n } from '@/i18n.js';
const name = 'rss';
const widgetPropsDef = {
url: {
type: 'string',
label: i18n.ts._widgetOptions._rss.url,
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
manualSave: true,
},
refreshIntervalSec: {
type: 'number',
label: i18n.ts._widgetOptions._rss.refreshIntervalSec,
default: 60,
},
maxEntries: {
type: 'number',
label: i18n.ts._widgetOptions._rss.maxEntries,
default: 15,
},
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;
@ -68,7 +73,7 @@ const fetching = ref(true);
const fetchEndpoint = computed(() => {
const url = new URL('/api/fetch-rss', base);
url.searchParams.set('url', widgetProps.url);
return url;
return url.toString();
});
const intervalClear = ref<(() => void) | undefined>();
@ -83,7 +88,7 @@ const tick = () => {
});
};
watch(() => fetchEndpoint, tick);
watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();

View File

@ -35,6 +35,7 @@ import MkMarqueeText from '@/components/MkMarqueeText.vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import MkContainer from '@/components/MkContainer.vue';
import { shuffle } from '@/utility/shuffle.js';
import { i18n } from '@/i18n.js';
import { url as base } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
@ -43,22 +44,28 @@ const name = 'rssTicker';
const widgetPropsDef = {
url: {
type: 'string',
label: i18n.ts._widgetOptions._rss.url,
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
manualSave: true,
},
shuffle: {
type: 'boolean',
label: i18n.ts._widgetOptions._rssTicker.shuffle,
default: true,
},
refreshIntervalSec: {
type: 'number',
label: i18n.ts._widgetOptions._rss.refreshIntervalSec,
default: 60,
},
maxEntries: {
type: 'number',
label: i18n.ts._widgetOptions._rss.maxEntries,
default: 15,
},
duration: {
type: 'range',
label: i18n.ts._widgetOptions._rssTicker.duration,
default: 70,
step: 1,
min: 5,
@ -66,14 +73,17 @@ const widgetPropsDef = {
},
reverse: {
type: 'boolean',
label: i18n.ts._widgetOptions._rssTicker.reverse,
default: false,
},
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: false,
},
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
} satisfies FormWithDefault;
@ -119,7 +129,7 @@ const tick = () => {
});
};
watch(() => fetchEndpoint, tick);
watch(fetchEndpoint, tick);
watch(() => widgetProps.refreshIntervalSec, () => {
if (intervalClear.value) {
intervalClear.value();

View File

@ -33,6 +33,7 @@ const name = 'slideshow';
const widgetPropsDef = {
height: {
type: 'number',
label: i18n.ts._widgetOptions.height,
default: 300,
},
folderId: {

View File

@ -41,6 +41,7 @@ const name = 'hashtags';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
} satisfies FormWithDefault;

View File

@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue';
import { useWidgetPropsManager } from './widget.js';
import { i18n } from '@/i18n.js';
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
@ -26,19 +27,23 @@ const name = 'unixClock';
const widgetPropsDef = {
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
fontSize: {
type: 'number',
label: i18n.ts.fontSize,
default: 1.5,
step: 0.1,
},
showMs: {
type: 'boolean',
label: i18n.ts._widgetOptions._clock.showMs,
default: true,
},
showLabel: {
type: 'boolean',
label: i18n.ts._widgetOptions._clock.showLabel,
default: true,
},
} satisfies FormWithDefault;

View File

@ -41,6 +41,7 @@ const name = 'userList';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
listId: {

View File

@ -40,10 +40,12 @@ const name = 'serverMetric';
const widgetPropsDef = {
showHeader: {
type: 'boolean',
label: i18n.ts._widgetOptions.showHeader,
default: true,
},
transparent: {
type: 'boolean',
label: i18n.ts._widgetOptions.transparent,
default: false,
},
view: {

View File

@ -3,12 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, watch } from 'vue';
import type { Reactive } from 'vue';
import { defineAsyncComponent, reactive, watch } from 'vue';
import { throttle } from 'throttle-debounce';
import { getDefaultFormValues } from '@/utility/form.js';
import type { Reactive } from 'vue';
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js';
import { i18n } from '@/i18n';
export type Widget<P extends Record<string, unknown>> = {
id: string;
@ -39,19 +41,23 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
save: () => void;
configure: () => void;
} => {
const widgetProps = reactive<GetFormResultType<F>>((props.widget ? deepClone(props.widget.data) : {}) as GetFormResultType<F>);
const mergeProps = () => {
for (const prop of Object.keys(propsDef)) {
if (typeof widgetProps[prop] === 'undefined') {
widgetProps[prop] = propsDef[prop].default;
const widgetProps = reactive((() => {
const np = getDefaultFormValues(propsDef);
if (props.widget?.data != null) {
for (const key of Object.keys(props.widget.data) as (keyof F)[]) {
np[key] = props.widget.data[key] as GetFormResultType<F>[typeof key];
}
}
};
return np;
})());
watch(widgetProps, () => {
mergeProps();
}, { deep: true, immediate: true });
watch(() => props.widget?.data, (to) => {
if (to != null) {
for (const key of Object.keys(propsDef)) {
widgetProps[key] = to[key];
}
}
}, { deep: true });
const save = throttle(3000, () => {
emit('updateProps', widgetProps as GetFormResultType<F>);
@ -62,11 +68,36 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
for (const item of Object.keys(form)) {
form[item].default = widgetProps[item];
}
const { canceled, result } = await os.form(name, form);
if (canceled) return;
for (const key of Object.keys(result)) {
widgetProps[key] = result[key];
const res = await new Promise<{
canceled: false;
result: GetFormResultType<F>;
} | {
canceled: true;
}>((resolve) => {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkWidgetSettingsDialog.vue')), {
widgetName: i18n.ts._widgets[name] ?? name,
form: form,
currentSettings: widgetProps,
}, {
saved: (newProps: GetFormResultType<F>) => {
resolve({ canceled: false, result: newProps });
},
canceled: () => {
resolve({ canceled: true });
},
closed: () => {
dispose();
},
});
});
if (res.canceled) {
return;
}
for (const key of Object.keys(res.result)) {
widgetProps[key] = res.result[key];
}
save();

View File

@ -5639,6 +5639,10 @@ export interface Locale extends ILocale {
*
*/
"zeroPadding": string;
/**
*
*/
"nothingToConfigure": string;
"_imageEditing": {
"_vars": {
/**
@ -9889,6 +9893,138 @@ export interface Locale extends ILocale {
*/
"chat": string;
};
"_widgetOptions": {
/**
*
*/
"showHeader": string;
/**
*
*/
"transparent": string;
/**
*
*/
"height": string;
"_button": {
/**
*
*/
"colored": string;
};
"_clock": {
/**
*
*/
"size": string;
/**
*
*/
"thickness": string;
/**
*
*/
"thicknessThin": string;
/**
*
*/
"thicknessMedium": string;
/**
*
*/
"thicknessThick": string;
/**
*
*/
"graduations": string;
/**
*
*/
"graduationDots": string;
/**
*
*/
"graduationArabic": string;
/**
*
*/
"fadeGraduations": string;
/**
*
*/
"sAnimation": string;
/**
*
*/
"sAnimationElastic": string;
/**
*
*/
"sAnimationEaseOut": string;
/**
* 24
*/
"twentyFour": string;
/**
*
*/
"labelTime": string;
/**
*
*/
"labelTz": string;
/**
*
*/
"labelTimeAndTz": string;
/**
*
*/
"timezone": string;
/**
*
*/
"showMs": string;
/**
*
*/
"showLabel": string;
};
"_jobQueue": {
/**
*
*/
"sound": string;
};
"_rss": {
/**
* RSSフィードのURL
*/
"url": string;
/**
* ()
*/
"refreshIntervalSec": string;
/**
*
*/
"maxEntries": string;
};
"_rssTicker": {
/**
*
*/
"shuffle": string;
/**
* ()
*/
"duration": string;
/**
*
*/
"reverse": string;
};
};
"_cw": {
/**
*
@ -12763,10 +12899,6 @@ export interface Locale extends ILocale {
*
*/
"discardChangesConfirm": string;
/**
*
*/
"nothingToConfigure": string;
/**
*
*/