Merge branch 'develop' into minify-backend
This commit is contained in:
commit
e44f993b6b
|
|
@ -48,6 +48,13 @@ jobs:
|
||||||
image: redis:7
|
image: redis:7
|
||||||
ports:
|
ports:
|
||||||
- 56312:6379
|
- 56312:6379
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.3.4
|
||||||
|
ports:
|
||||||
|
- 57712:7700
|
||||||
|
env:
|
||||||
|
MEILI_NO_ANALYTICS: true
|
||||||
|
MEILI_ENV: development
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6.0.1
|
- uses: actions/checkout@v6.0.1
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@
|
||||||
-
|
-
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
-
|
- Enhance: ドライブのファイル一覧で自動でもっと見るを利用可能に
|
||||||
|
- Enhance: ウィジェットの表示設定をプレビューを見ながら行えるように
|
||||||
|
- Enhance: ウィジェットの設定項目のラベルの多言語対応
|
||||||
|
- Fix: ドライブクリーナーでファイルを削除しても画面に反映されない問題を修正 #16061
|
||||||
|
|
||||||
### Server
|
### Server
|
||||||
-
|
-
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@
|
||||||
|
|
||||||
[](https://deepwiki.com/misskey-dev/misskey)
|
[](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>
|
</div>
|
||||||
|
|
||||||
## Thanks
|
## Thanks
|
||||||
|
|
|
||||||
|
|
@ -1406,6 +1406,7 @@ youAreAdmin: "あなたは管理者です"
|
||||||
frame: "フレーム"
|
frame: "フレーム"
|
||||||
presets: "プリセット"
|
presets: "プリセット"
|
||||||
zeroPadding: "ゼロ埋め"
|
zeroPadding: "ゼロ埋め"
|
||||||
|
nothingToConfigure: "設定項目はありません"
|
||||||
|
|
||||||
_imageEditing:
|
_imageEditing:
|
||||||
_vars:
|
_vars:
|
||||||
|
|
@ -2602,6 +2603,43 @@ _widgets:
|
||||||
birthdayFollowings: "今日誕生日のユーザー"
|
birthdayFollowings: "今日誕生日のユーザー"
|
||||||
chat: "ダイレクトメッセージ"
|
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:
|
_cw:
|
||||||
hide: "隠す"
|
hide: "隠す"
|
||||||
show: "もっと見る"
|
show: "もっと見る"
|
||||||
|
|
@ -3418,7 +3456,6 @@ _imageEffector:
|
||||||
title: "エフェクト"
|
title: "エフェクト"
|
||||||
addEffect: "エフェクトを追加"
|
addEffect: "エフェクトを追加"
|
||||||
discardChangesConfirm: "変更を破棄して終了しますか?"
|
discardChangesConfirm: "変更を破棄して終了しますか?"
|
||||||
nothingToConfigure: "設定項目はありません"
|
|
||||||
failedToLoadImage: "画像の読み込みに失敗しました"
|
failedToLoadImage: "画像の読み込みに失敗しました"
|
||||||
|
|
||||||
_fxs:
|
_fxs:
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,11 @@ services:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: "test-misskey"
|
POSTGRES_DB: "test-misskey"
|
||||||
POSTGRES_HOST_AUTH_METHOD: trust
|
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
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -69,7 +69,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
v-for="(f, i) in foldersPaginator.items.value"
|
v-for="(f, i) in foldersPaginator.items.value"
|
||||||
:key="f.id"
|
:key="f.id"
|
||||||
v-anim="i"
|
v-anim="i"
|
||||||
:class="$style.folder"
|
|
||||||
:folder="f"
|
:folder="f"
|
||||||
:selectMode="select === 'folder'"
|
:selectMode="select === 'folder'"
|
||||||
:isSelected="selectedFolders.some(x => x.id === f.id)"
|
:isSelected="selectedFolders.some(x => x.id === f.id)"
|
||||||
|
|
@ -102,7 +101,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<XFile
|
<XFile
|
||||||
v-for="file in item.items" :key="file.id"
|
v-for="file in item.items" :key="file.id"
|
||||||
:class="$style.file"
|
|
||||||
:file="file"
|
:file="file"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||||
|
|
@ -125,7 +123,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<XFile
|
<XFile
|
||||||
v-for="file in filesPaginator.items.value" :key="file.id"
|
v-for="file in filesPaginator.items.value" :key="file.id"
|
||||||
:class="$style.file"
|
|
||||||
:file="file"
|
:file="file"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
:isSelected="selectedFiles.some(x => x.id === file.id)"
|
||||||
|
|
@ -135,7 +132,14 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
/>
|
/>
|
||||||
</TransitionGroup>
|
</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="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
|
||||||
<div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div>
|
<div v-if="draghover">{{ i18n.ts.dropHereToUpload }}</div>
|
||||||
|
|
@ -182,10 +186,12 @@ const props = withDefaults(defineProps<{
|
||||||
type?: string;
|
type?: string;
|
||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
select?: 'file' | 'folder' | null;
|
select?: 'file' | 'folder' | null;
|
||||||
|
forceDisableInfiniteScroll?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
initialFolder: null,
|
initialFolder: null,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
select: null,
|
select: null,
|
||||||
|
forceDisableInfiniteScroll: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|
@ -194,6 +200,10 @@ const emit = defineEmits<{
|
||||||
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
|
(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 folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:enterFromClass="$style.transition_x_enterFrom"
|
:enterFromClass="$style.transition_x_enterFrom"
|
||||||
:leaveToClass="$style.transition_x_leaveTo"
|
:leaveToClass="$style.transition_x_leaveTo"
|
||||||
>
|
>
|
||||||
<div v-if="phase === 'input'" key="input" :class="$style.embedCodeGenInputRoot">
|
<MkPreviewWithControls v-if="phase === 'input'" key="input" :previewLoading="iframeLoading">
|
||||||
<div :class="[$style.embedCodeGenPreviewRoot, prefer.s.animation ? $style.animatedBg : null]">
|
<template #preview>
|
||||||
<MkLoading v-if="iframeLoading" :class="$style.embedCodeGenPreviewSpinner"/>
|
|
||||||
<div :class="$style.embedCodeGenPreviewWrapper">
|
<div :class="$style.embedCodeGenPreviewWrapper">
|
||||||
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
<div class="_acrylic" :class="$style.embedCodeGenPreviewTitle">{{ i18n.ts.preview }}</div>
|
||||||
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
|
<div ref="resizerRootEl" :class="$style.embedCodeGenPreviewResizerRoot" inert>
|
||||||
|
|
@ -43,8 +42,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div :class="$style.embedCodeGenSettings" class="_gaps">
|
<template #controls>
|
||||||
|
<div class="_spacer _gaps">
|
||||||
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
|
<MkInput v-if="isEmbedWithScrollbar" v-model="maxHeight" type="number" :min="0">
|
||||||
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
|
<template #label>{{ i18n.ts._embedCodeGen.maxHeight }}</template>
|
||||||
<template #suffix>px</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>
|
<MkButton :disabled="iframeLoading" primary @click="generate">{{ i18n.ts._embedCodeGen.generateCode }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
</MkPreviewWithControls>
|
||||||
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
|
<div v-else-if="phase === 'result'" key="result" :class="$style.embedCodeGenResultRoot">
|
||||||
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
|
<div :class="$style.embedCodeGenResultWrapper" class="_gaps">
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
|
|
@ -89,18 +90,17 @@ import { url } from '@@/js/config.js';
|
||||||
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
import { embedRouteWithScrollbar } from '@@/js/embed-page.js';
|
||||||
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
import type { EmbeddableEntity, EmbedParams } from '@@/js/embed-page.js';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkCode from '@/components/MkCode.vue';
|
import MkCode from '@/components/MkCode.vue';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import * as os from '@/os.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||||
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
|
||||||
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
|
||||||
import { prefer } from '@/preferences.js';
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(ev: 'ok'): void;
|
(ev: 'ok'): void;
|
||||||
|
|
@ -302,29 +302,6 @@ onUnmounted(() => {
|
||||||
height: 100%;
|
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 {
|
.embedCodeGenPreviewWrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -372,11 +349,6 @@ onUnmounted(() => {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.embedCodeGenSettings {
|
|
||||||
padding: 24px;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embedCodeGenResultRoot {
|
.embedCodeGenResultRoot {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
@ -417,11 +389,4 @@ onUnmounted(() => {
|
||||||
.embedCodeGenResultButtons {
|
.embedCodeGenResultButtons {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@container (max-width: 800px) {
|
|
||||||
.embedCodeGenInputRoot {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<MkPagination v-slot="{ items }" :paginator="paginator">
|
<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
|
<MkA
|
||||||
v-for="file in items"
|
v-for="file in items"
|
||||||
:key="file.id"
|
:key="file.id"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -20,66 +20,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="_spacer" style="--MI_SPACER-min: 20px; --MI_SPACER-max: 32px;">
|
<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">
|
<MkForm v-model="values" :form="form"/>
|
||||||
<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"/>
|
|
||||||
</div>
|
</div>
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { reactive, useTemplateRef } from 'vue';
|
import { ref, useTemplateRef } from 'vue';
|
||||||
import MkInput from './MkInput.vue';
|
import type { Form } from '@/utility/form.js';
|
||||||
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 MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import MkForm from '@/components/MkForm.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -96,19 +46,22 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const dialog = useTemplateRef('dialog');
|
const dialog = useTemplateRef('dialog');
|
||||||
const values = reactive({});
|
|
||||||
|
|
||||||
|
const values = ref((() => {
|
||||||
|
const obj: Record<string, any> = {};
|
||||||
for (const item in props.form) {
|
for (const item in props.form) {
|
||||||
if ('default' in props.form[item]) {
|
if ('default' in props.form[item]) {
|
||||||
values[item] = props.form[item].default ?? null;
|
obj[item] = props.form[item].default ?? null;
|
||||||
} else {
|
} else {
|
||||||
values[item] = null;
|
obj[item] = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return obj;
|
||||||
|
})());
|
||||||
|
|
||||||
function ok() {
|
function ok() {
|
||||||
emit('done', {
|
emit('done', {
|
||||||
result: values,
|
result: values.value,
|
||||||
});
|
});
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
|
@ -119,18 +72,4 @@ function cancel() {
|
||||||
});
|
});
|
||||||
dialog.value?.close();
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template>
|
<template #header><i class="ti ti-sparkles"></i> {{ i18n.ts._imageEffector.title }}</template>
|
||||||
|
|
||||||
<div :class="$style.root">
|
<MkPreviewWithControls>
|
||||||
<div :class="$style.container">
|
<template #preview>
|
||||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
|
||||||
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
<canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown.prevent.stop="onImagePointerdown"></canvas>
|
||||||
<div :class="$style.previewContainer">
|
<div :class="$style.previewContainer">
|
||||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
<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>
|
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div :class="$style.controls">
|
|
||||||
|
<template #controls>
|
||||||
<div class="_spacer _gaps">
|
<div class="_spacer _gaps">
|
||||||
<XLayer
|
<XLayer
|
||||||
v-for="(layer, i) in layers"
|
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>
|
<MkButton rounded primary style="margin: 0 auto;" @click="addEffect"><i class="ti ti-plus"></i> {{ i18n.ts._imageEffector.addEffect }}</MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</MkPreviewWithControls>
|
||||||
</div>
|
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -56,15 +55,12 @@ import type { ImageEffectorLayer } from '@/utility/image-effector/ImageEffector.
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
import { ImageEffector } from '@/utility/image-effector/ImageEffector.js';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
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 MkButton from '@/components/MkButton.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
|
||||||
import XLayer from '@/components/MkImageEffectorDialog.Layer.vue';
|
import XLayer from '@/components/MkImageEffectorDialog.Layer.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { deepClone } from '@/utility/clone.js';
|
|
||||||
import { FXS } from '@/utility/image-effector/fxs.js';
|
import { FXS } from '@/utility/image-effector/fxs.js';
|
||||||
import { genId } from '@/utility/id.js';
|
import { genId } from '@/utility/id.js';
|
||||||
import { prefer } from '@/preferences.js';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
image: File;
|
image: File;
|
||||||
|
|
@ -367,33 +363,6 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module>
|
<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 {
|
.previewContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.previewCanvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -467,15 +426,4 @@ function onImagePointerdown(ev: PointerEvent) {
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 800px) {
|
|
||||||
.container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkInput>
|
</MkInput>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
|
<div v-if="Object.keys(paramDefs).length === 0" :class="$style.nothingToConfigure">
|
||||||
{{ i18n.ts._imageEffector.nothingToConfigure }}
|
{{ i18n.ts.nothingToConfigure }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<template #header><i class="ti ti-device-ipad-horizontal"></i> {{ i18n.ts._imageFrameEditor.title }}</template>
|
||||||
|
|
||||||
<div :class="$style.root">
|
<MkPreviewWithControls>
|
||||||
<div :class="$style.container">
|
<template #preview>
|
||||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
|
||||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||||
<div :class="$style.previewContainer">
|
<div :class="$style.previewContainer">
|
||||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
<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>
|
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div :class="$style.controls">
|
|
||||||
|
<template #controls>
|
||||||
<div class="_spacer _gaps">
|
<div class="_spacer _gaps">
|
||||||
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
<MkRange v-model="params.borderThickness" :min="0" :max="0.2" :step="0.01" :continuousUpdate="true">
|
||||||
<template #label>{{ i18n.ts._imageFrameEditor.borderThickness }}</template>
|
<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>
|
<div><code class="_selectableAtomic">{gps_long}</code> - {{ i18n.ts._imageEditing._vars.gps_long }}</div>
|
||||||
</MkInfo>
|
</MkInfo>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</MkPreviewWithControls>
|
||||||
</div>
|
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -161,8 +160,8 @@ import type { ImageFrameParams, ImageFramePreset } from '@/utility/image-frame-r
|
||||||
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
import { ImageFrameRenderer } from '@/utility/image-frame-renderer/ImageFrameRenderer.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkPreviewWithControls from './MkPreviewWithControls.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
import MkRange from '@/components/MkRange.vue';
|
import MkRange from '@/components/MkRange.vue';
|
||||||
|
|
@ -173,8 +172,6 @@ import * as os from '@/os.js';
|
||||||
import { deepClone } from '@/utility/clone.js';
|
import { deepClone } from '@/utility/clone.js';
|
||||||
import { ensureSignin } from '@/i.js';
|
import { ensureSignin } from '@/i.js';
|
||||||
import { genId } from '@/utility/id.js';
|
import { genId } from '@/utility/id.js';
|
||||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
|
||||||
import { prefer } from '@/preferences.js';
|
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
|
@ -412,33 +409,6 @@ function getRgb(hex: string | number): [number, number, number] | null {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module>
|
<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 {
|
.previewContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -495,15 +465,4 @@ function getRgb(hex: string | number): [number, number, number] | null {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 800px) {
|
|
||||||
.container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<span><MkEllipsis/></span>
|
<span><MkEllipsis/></span>
|
||||||
</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"/>
|
<component :is="item.component" v-bind="item.props"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<div :class="$style.draftActions" class="_buttons">
|
<div :class="$style.draftActions" class="_buttons">
|
||||||
<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
|
<template v-if="draft.scheduledAt != null && draft.isActuallyScheduled">
|
||||||
<MkButton
|
<MkButton
|
||||||
:class="$style.itemButton"
|
|
||||||
small
|
small
|
||||||
@click="cancelSchedule(draft)"
|
@click="cancelSchedule(draft)"
|
||||||
>
|
>
|
||||||
|
|
@ -126,7 +125,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</MkButton>
|
</MkButton>
|
||||||
<!-- TODO
|
<!-- TODO
|
||||||
<MkButton
|
<MkButton
|
||||||
:class="$style.itemButton"
|
|
||||||
small
|
small
|
||||||
@click="reSchedule(draft)"
|
@click="reSchedule(draft)"
|
||||||
>
|
>
|
||||||
|
|
@ -136,7 +134,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
<MkButton
|
<MkButton
|
||||||
v-else
|
v-else
|
||||||
:class="$style.itemButton"
|
|
||||||
small
|
small
|
||||||
@click="restoreDraft(draft)"
|
@click="restoreDraft(draft)"
|
||||||
>
|
>
|
||||||
|
|
@ -147,7 +144,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
danger
|
danger
|
||||||
small
|
small
|
||||||
:iconOnly="true"
|
:iconOnly="true"
|
||||||
:class="$style.itemButton"
|
|
||||||
style="margin-left: auto;"
|
style="margin-left: auto;"
|
||||||
@click="deleteDraft(draft)"
|
@click="deleteDraft(draft)"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<header :class="$style.header">
|
<header :class="$style.header">
|
||||||
<div :class="$style.headerLeft">
|
<div :class="$style.headerLeft">
|
||||||
<button v-if="!fixed" :class="$style.cancel" class="_button" @click="cancel"><i class="ti ti-x"></i></button>
|
<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%;"/>
|
<img :class="$style.avatar" :src="(postAccount ?? $i).avatarUrl" style="border-radius: 100%;"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1469,9 +1469,6 @@ defineExpose({
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account {
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
display: block;
|
display: block;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.root" class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<MkInput v-model="q_name" data-cy-server-name>
|
<MkInput v-model="q_name" data-cy-server-name>
|
||||||
<template #label>{{ i18n.ts.instanceName }}</template>
|
<template #label>{{ i18n.ts.instanceName }}</template>
|
||||||
</MkInput>
|
</MkInput>
|
||||||
|
|
@ -370,8 +370,3 @@ function applySettings() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
|
||||||
.root {
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.root" @click="(ev) => emit('click', ev)">
|
<div :class="$style.root" @click="(ev) => emit('click', ev)">
|
||||||
<span v-if="iconClass" :class="[$style.icon, iconClass]"></span>
|
<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)">
|
<MkButton v-if="exButtonIconClass" :class="$style.exButton" @click="(ev) => emit('exButtonClick', ev)">
|
||||||
<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
|
<span :class="[$style.exButtonIcon, exButtonIconClass]"></span>
|
||||||
</MkButton>
|
</MkButton>
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
>
|
>
|
||||||
<template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template>
|
<template #header><i class="ti ti-copyright"></i> {{ i18n.ts._watermarkEditor.title }}</template>
|
||||||
|
|
||||||
<div :class="$style.root">
|
<MkPreviewWithControls>
|
||||||
<div :class="$style.container">
|
<template #preview>
|
||||||
<div :class="[$style.preview, prefer.s.animation ? $style.animatedBg : null]">
|
|
||||||
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas>
|
||||||
<div :class="$style.previewContainer">
|
<div :class="$style.previewContainer">
|
||||||
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
|
<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>
|
<button class="_button" :class="[$style.previewControlsButton]" @click="choiceImage"><i class="ti ti-upload"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
<div :class="$style.controls">
|
|
||||||
|
<template #controls>
|
||||||
<div class="_spacer _gaps">
|
<div class="_spacer _gaps">
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<MkFolder v-for="(layer, i) in layers" :key="layer.id" :defaultOpen="false" :canPage="false">
|
<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>
|
<MkButton rounded primary style="margin: 0 auto;" @click="addLayer"><i class="ti ti-plus"></i></MkButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</MkPreviewWithControls>
|
||||||
</div>
|
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -69,6 +68,7 @@ import type { WatermarkLayers, WatermarkPreset } from '@/utility/watermark/Water
|
||||||
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
import { WatermarkRenderer } from '@/utility/watermark/WatermarkRenderer.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import MkPreviewWithControls from '@/components/MkPreviewWithControls.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkFolder from '@/components/MkFolder.vue';
|
import MkFolder from '@/components/MkFolder.vue';
|
||||||
|
|
@ -411,33 +411,6 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style module>
|
<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 {
|
.previewContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.previewCanvas {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|
@ -494,15 +457,4 @@ function removeLayer(layer: WatermarkPreset['layers'][number]) {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
@container (max-width: 800px) {
|
|
||||||
.container {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<div v-if="show" ref="el" :class="[$style.root]">
|
<div v-if="show" ref="el" :class="[$style.root]">
|
||||||
<div :class="[$style.upper, { [$style.slim]: narrow, [$style.thin]: thin_ }]">
|
<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"/>
|
<MkAvatar :class="$style.avatar" :user="$i"/>
|
||||||
</div>
|
</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">
|
<template v-if="pageMetadata">
|
||||||
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
<div v-if="!hideTitle" :class="$style.titleContainer" @click="top">
|
||||||
|
|
@ -28,7 +28,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
|
<XTabs v-if="!narrow || hideTitle" :class="$style.tabs" :tab="tab" :tabs="tabs" :rootEl="el" @update:tab="key => emit('update:tab', key)" @tabClick="onTabClick"/>
|
||||||
</template>
|
</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">
|
<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>
|
<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>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition :name="prefer.s.animation ? '_transition_zoom' : ''" appear>
|
<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"/>
|
<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"/>
|
<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"/>
|
<img v-if="type === 'notFound' && instance.notFoundImageUrl" :src="instance.notFoundImageUrl" draggable="false" :class="$style.img"/>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
|
<div ref="rootEl" :class="reversed ? '_pageScrollableReversed' : '_pageScrollable'">
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header>
|
<template #header>
|
||||||
<MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/>
|
<MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export const appearDirective = {
|
||||||
const fn = binding.value;
|
const fn = binding.value;
|
||||||
if (fn == null) return;
|
if (fn == null) return;
|
||||||
|
|
||||||
const check = throttle<IntersectionObserverCallback>(1000, (entries) => {
|
const check = throttle<IntersectionObserverCallback>(500, (entries) => {
|
||||||
if (entries.some(entry => entry.isIntersecting)) {
|
if (entries.some(entry => entry.isIntersecting)) {
|
||||||
fn();
|
fn();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export const directives = {
|
||||||
} as Record<string, Directive>;
|
} as Record<string, Directive>;
|
||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface ComponentCustomProperties {
|
export interface GlobalDirectives {
|
||||||
vUserPreview: typeof userPreviewDirective;
|
vUserPreview: typeof userPreviewDirective;
|
||||||
vGetSize: typeof getSizeDirective;
|
vGetSize: typeof getSizeDirective;
|
||||||
vRipple: typeof rippleDirective;
|
vRipple: typeof rippleDirective;
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<PageWithHeader v-model:tab="headerTab" :tabs="headerTabs">
|
<PageWithHeader v-model:tab="headerTab" :tabs="headerTabs">
|
||||||
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
|
<XGridLocalComponent v-if="headerTab === 'local'" :class="$style.local"/>
|
||||||
<XGridRemoteComponent v-else-if="headerTab === 'remote'" :class="$style.remote"/>
|
<XGridRemoteComponent v-else-if="headerTab === 'remote'"/>
|
||||||
<XRegisterComponent v-else-if="headerTab === 'register'" :class="$style.register"/>
|
<XRegisterComponent v-else-if="headerTab === 'register'"/>
|
||||||
</PageWithHeader>
|
</PageWithHeader>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #header>
|
<template #header>
|
||||||
<MkTabs
|
<MkTabs
|
||||||
v-model:tab="jobState"
|
v-model:tab="jobState"
|
||||||
:class="$style.jobsTabs" :tabs="[{
|
:tabs="[{
|
||||||
key: 'all',
|
key: 'all',
|
||||||
title: 'All',
|
title: 'All',
|
||||||
icon: 'ti ti-code-asterisk',
|
icon: 'ti ti-code-asterisk',
|
||||||
|
|
@ -359,8 +359,4 @@ definePage(() => ({
|
||||||
font-size: 85%;
|
font-size: 85%;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jobsTabs {
|
|
||||||
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
:enableEmojiMenu="true"
|
:enableEmojiMenu="true"
|
||||||
:enableEmojiMenuReaction="true"
|
:enableEmojiMenuReaction="true"
|
||||||
/>
|
/>
|
||||||
<MkMediaList v-if="message.file" :mediaList="[message.file]" :class="$style.file"/>
|
<MkMediaList v-if="message.file" :mediaList="[message.file]"/>
|
||||||
</MkFukidashi>
|
</MkFukidashi>
|
||||||
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
|
<MkUrlPreview v-for="url in urls" :key="url" :url="url" style="margin: 8px 0;"/>
|
||||||
<div :class="$style.footer">
|
<div :class="$style.footer">
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template #header>
|
<template #header>
|
||||||
<div :class="$style.view">
|
<div :class="$style.view">
|
||||||
<video ref="videoEl" :class="$style.video" autoplay muted playsinline></video>
|
<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">
|
<div :class="$style.controls">
|
||||||
<MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton>
|
<MkButton v-tooltip="i18n.ts._qr.scanFile" iconOnly @click="upload"><i class="ti ti-photo-plus"></i></MkButton>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkUserCardMini
|
<MkUserCardMini
|
||||||
:user="user"
|
:user="user"
|
||||||
:withChart="false"
|
:withChart="false"
|
||||||
:class="$style.userSelectedCard"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import bytes from '@/filters/bytes.js';
|
||||||
import { definePage } from '@/page.js';
|
import { definePage } from '@/page.js';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import { useMkSelect } from '@/composables/use-mkselect.js';
|
import { useMkSelect } from '@/composables/use-mkselect.js';
|
||||||
|
import { useGlobalEvent } from '@/events.js';
|
||||||
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
|
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
|
||||||
import { Paginator } from '@/utility/paginator.js';
|
import { Paginator } from '@/utility/paginator.js';
|
||||||
|
|
||||||
|
|
@ -123,6 +124,12 @@ function onContextMenu(ev: MouseEvent, file): void {
|
||||||
os.contextMenu(getDriveFileMenu(file), ev);
|
os.contextMenu(getDriveFileMenu(file), ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useGlobalEvent('driveFilesDeleted', (files) => {
|
||||||
|
for (const f of files) {
|
||||||
|
paginator.removeItem(f.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
definePage(() => ({
|
definePage(() => ({
|
||||||
title: i18n.ts.drivecleaner,
|
title: i18n.ts.drivecleaner,
|
||||||
icon: 'ti ti-trash',
|
icon: 'ti ti-trash',
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</div>
|
</div>
|
||||||
<MkButton rounded full @click="emit('showMore')">{{ i18n.ts.showMore }} <i class="ti ti-arrow-right"></i></MkButton>
|
<MkButton rounded full @click="emit('showMore')">{{ i18n.ts.showMore }} <i class="ti ti-arrow-right"></i></MkButton>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</MkContainer>
|
</MkContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="_spacer" style="--MI_SPACER-w: 800px;">
|
<div class="_spacer" style="--MI_SPACER-w: 800px;">
|
||||||
<div :class="$style.root">
|
<div>
|
||||||
<MkStickyContainer>
|
<MkStickyContainer>
|
||||||
<template #header>
|
<template #header>
|
||||||
<MkTab
|
<MkTab
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<div :class="[$style.root, acrylic ? $style.acrylic : null]">
|
<div :class="[$style.root, acrylic ? $style.acrylic : null]">
|
||||||
<div :class="$style.body">
|
<div :class="$style.body">
|
||||||
<div :class="$style.left">
|
<div>
|
||||||
<button v-click-anime :class="[$style.item, $style.instance]" class="_button" @click="openInstanceMenu">
|
<button v-click-anime :class="[$style.item, $style.instance]" class="_button" @click="openInstanceMenu">
|
||||||
<img :class="$style.instanceIcon" :src="instance.iconUrl ?? '/favicon.ico'" draggable="false"/>
|
<img :class="$style.instanceIcon" :src="instance.iconUrl ?? '/favicon.ico'" draggable="false"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<div v-if="!forceIconOnly && prefer.r.showNavbarSubButtons.value" :class="$style.subButtons">
|
<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">
|
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
|
||||||
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
|
<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);"/>
|
<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">
|
<template v-if="!props.asDrawer">
|
||||||
<div :class="$style.subButtonGapFill"></div>
|
<div :class="$style.subButtonGapFill"></div>
|
||||||
<div :class="$style.subButtonGapFillDivider"></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">
|
<svg viewBox="0 0 16 64" :class="$style.subButtonShape">
|
||||||
<g transform="matrix(0.333333,0,0,0.222222,0.000895785,21.3333)">
|
<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);"/>
|
<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);"/>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
v-for="id in ids"
|
v-for="id in ids"
|
||||||
:ref="id"
|
:ref="id"
|
||||||
:key="id"
|
:key="id"
|
||||||
:class="[$style.column, { '_shadow': withWallpaper }]"
|
:class="{ '_shadow': withWallpaper }"
|
||||||
:column="columns.find(c => c.id === id)!"
|
:column="columns.find(c => c.id === id)!"
|
||||||
:isStacked="ids.length > 1"
|
:isStacked="ids.length > 1"
|
||||||
@headerWheel="onWheel"
|
@headerWheel="onWheel"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.root">
|
<div>
|
||||||
<div :class="$style.contents">
|
<div :class="$style.contents">
|
||||||
<!--
|
<!--
|
||||||
デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない)
|
デッキUIが設定されている場合はデッキUIに戻れるようにする (ただし?zenが明示された場合は表示しない)
|
||||||
|
|
@ -57,9 +57,6 @@ function goToDeck() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.root {
|
|
||||||
}
|
|
||||||
|
|
||||||
.contents {
|
.contents {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ export interface StringFormItem extends FormItemBase {
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
treatAsMfm?: boolean;
|
treatAsMfm?: boolean;
|
||||||
|
manualSave?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NumberFormItem extends FormItemBase {
|
export interface NumberFormItem extends FormItemBase {
|
||||||
|
|
@ -33,6 +34,7 @@ export interface NumberFormItem extends FormItemBase {
|
||||||
description?: string;
|
description?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
step?: number;
|
step?: number;
|
||||||
|
manualSave?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BooleanFormItem extends FormItemBase {
|
export interface BooleanFormItem extends FormItemBase {
|
||||||
|
|
@ -145,3 +147,11 @@ type GetItemType<Item extends FormItem> =
|
||||||
export type GetFormResultType<F extends Form> = {
|
export type GetFormResultType<F extends Form> = {
|
||||||
[P in keyof F]: GetItemType<F[P]>;
|
[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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@ const name = 'activity';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
import { onMounted, onUnmounted, useTemplateRef } from 'vue';
|
||||||
import { useWidgetPropsManager } from './widget.js';
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
|
import type { WidgetComponentProps, WidgetComponentEmits, WidgetComponentExpose } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
|
|
||||||
|
|
@ -20,6 +21,7 @@ const name = 'ai';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -37,10 +37,12 @@ const name = 'aiscript';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
script: {
|
script: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
label: i18n.ts.script,
|
||||||
multiline: true,
|
multiline: true,
|
||||||
default: '(1 + 1)',
|
default: '(1 + 1)',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import MkAsUi from '@/components/MkAsUi.vue';
|
import MkAsUi from '@/components/MkAsUi.vue';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import { registerAsUiLib } from '@/aiscript/ui.js';
|
import { registerAsUiLib } from '@/aiscript/ui.js';
|
||||||
|
|
@ -33,11 +34,13 @@ const name = 'aiscriptApp';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
script: {
|
script: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
label: i18n.ts.script,
|
||||||
multiline: true,
|
multiline: true,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,12 @@ import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
|
|
||||||
const name = i18n.ts._widgets.birthdayFollowings;
|
const name = 'birthdayFollowings';
|
||||||
|
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -20,20 +20,24 @@ import * as os from '@/os.js';
|
||||||
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
import { aiScriptReadline, createAiScriptEnv } from '@/aiscript/api.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
const name = 'button';
|
const name = 'button';
|
||||||
|
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
label: {
|
label: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
label: i18n.ts.label,
|
||||||
default: 'BUTTON',
|
default: 'BUTTON',
|
||||||
},
|
},
|
||||||
colored: {
|
colored: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._button.colored,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
script: {
|
script: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
label: i18n.ts.script,
|
||||||
multiline: true,
|
multiline: true,
|
||||||
default: 'Mk:dialog("hello" "world")',
|
default: 'Mk:dialog("hello" "world")',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ const name = 'calendar';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ const name = 'chat';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
import { useWidgetPropsManager } from './widget.js';
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import MkClickerGame from '@/components/MkClickerGame.vue';
|
import MkClickerGame from '@/components/MkClickerGame.vue';
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ const name = 'clicker';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,12 @@ const name = 'clock';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
label: i18n.ts._widgetOptions._clock.size,
|
||||||
default: 'medium',
|
default: 'medium',
|
||||||
options: [{
|
options: [{
|
||||||
value: 'small' as const,
|
value: 'small' as const,
|
||||||
|
|
@ -62,79 +64,89 @@ const widgetPropsDef = {
|
||||||
},
|
},
|
||||||
thickness: {
|
thickness: {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
label: i18n.ts._widgetOptions._clock.thickness,
|
||||||
default: 0.2,
|
default: 0.2,
|
||||||
options: [{
|
options: [{
|
||||||
value: 0.1 as const,
|
value: 0.1 as const,
|
||||||
label: 'thin',
|
label: i18n.ts._widgetOptions._clock.thicknessThin,
|
||||||
}, {
|
}, {
|
||||||
value: 0.2 as const,
|
value: 0.2 as const,
|
||||||
label: 'medium',
|
label: i18n.ts._widgetOptions._clock.thicknessMedium,
|
||||||
}, {
|
}, {
|
||||||
value: 0.3 as const,
|
value: 0.3 as const,
|
||||||
label: 'thick',
|
label: i18n.ts._widgetOptions._clock.thicknessThick,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
graduations: {
|
graduations: {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
label: i18n.ts._widgetOptions._clock.graduations,
|
||||||
default: 'numbers',
|
default: 'numbers',
|
||||||
options: [{
|
options: [{
|
||||||
value: 'none' as const,
|
value: 'none' as const,
|
||||||
label: 'None',
|
label: i18n.ts.none,
|
||||||
}, {
|
}, {
|
||||||
value: 'dots' as const,
|
value: 'dots' as const,
|
||||||
label: 'Dots',
|
label: i18n.ts._widgetOptions._clock.graduationDots,
|
||||||
}, {
|
}, {
|
||||||
value: 'numbers' as const,
|
value: 'numbers' as const,
|
||||||
label: 'Numbers',
|
label: i18n.ts._widgetOptions._clock.graduationArabic,
|
||||||
}],
|
}, /*, {
|
||||||
|
value: 'roman' as const,
|
||||||
|
label: i18n.ts._widgetOptions._clock.graduationRoman,
|
||||||
|
}*/],
|
||||||
},
|
},
|
||||||
fadeGraduations: {
|
fadeGraduations: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._clock.fadeGraduations,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
sAnimation: {
|
sAnimation: {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
label: i18n.ts._widgetOptions._clock.sAnimation,
|
||||||
default: 'elastic',
|
default: 'elastic',
|
||||||
options: [{
|
options: [{
|
||||||
value: 'none' as const,
|
value: 'none' as const,
|
||||||
label: 'None',
|
label: i18n.ts.none,
|
||||||
}, {
|
}, {
|
||||||
value: 'elastic' as const,
|
value: 'elastic' as const,
|
||||||
label: 'Elastic',
|
label: i18n.ts._widgetOptions._clock.sAnimationElastic,
|
||||||
}, {
|
}, {
|
||||||
value: 'easeOut' as const,
|
value: 'easeOut' as const,
|
||||||
label: 'Ease out',
|
label: i18n.ts._widgetOptions._clock.sAnimationEaseOut,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
twentyFour: {
|
twentyFour: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._clock.twentyFour,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
type: 'radio',
|
type: 'radio',
|
||||||
|
label: i18n.ts.label,
|
||||||
default: 'none',
|
default: 'none',
|
||||||
options: [{
|
options: [{
|
||||||
value: 'none' as const,
|
value: 'none' as const,
|
||||||
label: 'None',
|
label: i18n.ts.none,
|
||||||
}, {
|
}, {
|
||||||
value: 'time' as const,
|
value: 'time' as const,
|
||||||
label: 'Time',
|
label: i18n.ts._widgetOptions._clock.labelTime,
|
||||||
}, {
|
}, {
|
||||||
value: 'tz' as const,
|
value: 'tz' as const,
|
||||||
label: 'TZ',
|
label: i18n.ts._widgetOptions._clock.labelTz,
|
||||||
}, {
|
}, {
|
||||||
value: 'timeAndTz' as const,
|
value: 'timeAndTz' as const,
|
||||||
label: 'Time + TZ',
|
label: i18n.ts._widgetOptions._clock.labelTimeAndTz,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
timezone: {
|
timezone: {
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
|
label: i18n.ts._widgetOptions._clock.timezone,
|
||||||
default: null,
|
default: null,
|
||||||
enum: [...timezones.map((tz) => ({
|
enum: [...timezones.map((tz) => ({
|
||||||
label: tz.name,
|
label: tz.name,
|
||||||
value: tz.name.toLowerCase(),
|
value: tz.name.toLowerCase(),
|
||||||
})), {
|
})), {
|
||||||
label: '(auto)',
|
label: i18n.ts.auto,
|
||||||
value: null,
|
value: null,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useWidgetPropsManager } from './widget.js';
|
||||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
import { timezones } from '@/utility/timezones.js';
|
import { timezones } from '@/utility/timezones.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import MkDigitalClock from '@/components/MkDigitalClock.vue';
|
import MkDigitalClock from '@/components/MkDigitalClock.vue';
|
||||||
|
|
||||||
const name = 'digitalClock';
|
const name = 'digitalClock';
|
||||||
|
|
@ -26,29 +27,34 @@ const name = 'digitalClock';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts.fontSize,
|
||||||
default: 1.5,
|
default: 1.5,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
},
|
},
|
||||||
showMs: {
|
showMs: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._clock.showMs,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
showLabel: {
|
showLabel: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._clock.showLabel,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
timezone: {
|
timezone: {
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
|
label: i18n.ts._widgetOptions._clock.timezone,
|
||||||
default: null,
|
default: null,
|
||||||
enum: [...timezones.map((tz) => ({
|
enum: [...timezones.map((tz) => ({
|
||||||
label: tz.name,
|
label: tz.name,
|
||||||
value: tz.name.toLowerCase(),
|
value: tz.name.toLowerCase(),
|
||||||
})), {
|
})), {
|
||||||
label: '(auto)',
|
label: i18n.ts.auto,
|
||||||
value: null,
|
value: null,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ const name = 'federation';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,14 @@ import MkTagCloud from '@/components/MkTagCloud.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
|
import { getProxiedImageUrlNullable } from '@/utility/media-proxy.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
const name = 'instanceCloud';
|
const name = 'instanceCloud';
|
||||||
|
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -61,16 +61,19 @@ import * as sound from '@/utility/sound.js';
|
||||||
import { deepClone } from '@/utility/clone.js';
|
import { deepClone } from '@/utility/clone.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import { genId } from '@/utility/id.js';
|
import { genId } from '@/utility/id.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
const name = 'jobQueue';
|
const name = 'jobQueue';
|
||||||
|
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
sound: {
|
sound: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._jobQueue.sound,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,12 @@ const name = 'memo';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts.height,
|
||||||
default: 100,
|
default: 100,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,12 @@ const name = 'notifications';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts.height,
|
||||||
default: 300,
|
default: 300,
|
||||||
},
|
},
|
||||||
excludeTypes: {
|
excludeTypes: {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ const name = 'onlineUsers';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -39,10 +39,12 @@ const name = 'photos';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -25,28 +25,33 @@ import * as Misskey from 'misskey-js';
|
||||||
import { url as base } from '@@/js/config.js';
|
import { url as base } from '@@/js/config.js';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
import { useWidgetPropsManager } from './widget.js';
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
|
||||||
|
|
||||||
const name = 'rss';
|
const name = 'rss';
|
||||||
|
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
url: {
|
url: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
label: i18n.ts._widgetOptions._rss.url,
|
||||||
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
|
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
|
||||||
|
manualSave: true,
|
||||||
},
|
},
|
||||||
refreshIntervalSec: {
|
refreshIntervalSec: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts._widgetOptions._rss.refreshIntervalSec,
|
||||||
default: 60,
|
default: 60,
|
||||||
},
|
},
|
||||||
maxEntries: {
|
maxEntries: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts._widgetOptions._rss.maxEntries,
|
||||||
default: 15,
|
default: 15,
|
||||||
},
|
},
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
@ -68,7 +73,7 @@ const fetching = ref(true);
|
||||||
const fetchEndpoint = computed(() => {
|
const fetchEndpoint = computed(() => {
|
||||||
const url = new URL('/api/fetch-rss', base);
|
const url = new URL('/api/fetch-rss', base);
|
||||||
url.searchParams.set('url', widgetProps.url);
|
url.searchParams.set('url', widgetProps.url);
|
||||||
return url;
|
return url.toString();
|
||||||
});
|
});
|
||||||
const intervalClear = ref<(() => void) | undefined>();
|
const intervalClear = ref<(() => void) | undefined>();
|
||||||
|
|
||||||
|
|
@ -83,7 +88,7 @@ const tick = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => fetchEndpoint, tick);
|
watch(fetchEndpoint, tick);
|
||||||
watch(() => widgetProps.refreshIntervalSec, () => {
|
watch(() => widgetProps.refreshIntervalSec, () => {
|
||||||
if (intervalClear.value) {
|
if (intervalClear.value) {
|
||||||
intervalClear.value();
|
intervalClear.value();
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import MkMarqueeText from '@/components/MkMarqueeText.vue';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
import MkContainer from '@/components/MkContainer.vue';
|
import MkContainer from '@/components/MkContainer.vue';
|
||||||
import { shuffle } from '@/utility/shuffle.js';
|
import { shuffle } from '@/utility/shuffle.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import { url as base } from '@@/js/config.js';
|
import { url as base } from '@@/js/config.js';
|
||||||
import { useInterval } from '@@/js/use-interval.js';
|
import { useInterval } from '@@/js/use-interval.js';
|
||||||
|
|
||||||
|
|
@ -43,22 +44,28 @@ const name = 'rssTicker';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
url: {
|
url: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
label: i18n.ts._widgetOptions._rss.url,
|
||||||
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
|
default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews',
|
||||||
|
manualSave: true,
|
||||||
},
|
},
|
||||||
shuffle: {
|
shuffle: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._rssTicker.shuffle,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
refreshIntervalSec: {
|
refreshIntervalSec: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts._widgetOptions._rss.refreshIntervalSec,
|
||||||
default: 60,
|
default: 60,
|
||||||
},
|
},
|
||||||
maxEntries: {
|
maxEntries: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts._widgetOptions._rss.maxEntries,
|
||||||
default: 15,
|
default: 15,
|
||||||
},
|
},
|
||||||
duration: {
|
duration: {
|
||||||
type: 'range',
|
type: 'range',
|
||||||
|
label: i18n.ts._widgetOptions._rssTicker.duration,
|
||||||
default: 70,
|
default: 70,
|
||||||
step: 1,
|
step: 1,
|
||||||
min: 5,
|
min: 5,
|
||||||
|
|
@ -66,14 +73,17 @@ const widgetPropsDef = {
|
||||||
},
|
},
|
||||||
reverse: {
|
reverse: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._rssTicker.reverse,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
@ -119,7 +129,7 @@ const tick = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(() => fetchEndpoint, tick);
|
watch(fetchEndpoint, tick);
|
||||||
watch(() => widgetProps.refreshIntervalSec, () => {
|
watch(() => widgetProps.refreshIntervalSec, () => {
|
||||||
if (intervalClear.value) {
|
if (intervalClear.value) {
|
||||||
intervalClear.value();
|
intervalClear.value();
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const name = 'slideshow';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
height: {
|
height: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts._widgetOptions.height,
|
||||||
default: 300,
|
default: 300,
|
||||||
},
|
},
|
||||||
folderId: {
|
folderId: {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const name = 'hashtags';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { onUnmounted, ref, watch } from 'vue';
|
import { onUnmounted, ref, watch } from 'vue';
|
||||||
import { useWidgetPropsManager } from './widget.js';
|
import { useWidgetPropsManager } from './widget.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
import type { WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget.js';
|
||||||
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
import type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
|
|
||||||
|
|
@ -26,19 +27,23 @@ const name = 'unixClock';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
fontSize: {
|
fontSize: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
|
label: i18n.ts.fontSize,
|
||||||
default: 1.5,
|
default: 1.5,
|
||||||
step: 0.1,
|
step: 0.1,
|
||||||
},
|
},
|
||||||
showMs: {
|
showMs: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._clock.showMs,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
showLabel: {
|
showLabel: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions._clock.showLabel,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
} satisfies FormWithDefault;
|
} satisfies FormWithDefault;
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ const name = 'userList';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
listId: {
|
listId: {
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,12 @@ const name = 'serverMetric';
|
||||||
const widgetPropsDef = {
|
const widgetPropsDef = {
|
||||||
showHeader: {
|
showHeader: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.showHeader,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
transparent: {
|
transparent: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
|
label: i18n.ts._widgetOptions.transparent,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
view: {
|
view: {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive, watch } from 'vue';
|
import { defineAsyncComponent, reactive, watch } from 'vue';
|
||||||
import type { Reactive } from 'vue';
|
|
||||||
import { throttle } from 'throttle-debounce';
|
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 type { FormWithDefault, GetFormResultType } from '@/utility/form.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { deepClone } from '@/utility/clone.js';
|
import { deepClone } from '@/utility/clone.js';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
|
||||||
export type Widget<P extends Record<string, unknown>> = {
|
export type Widget<P extends Record<string, unknown>> = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,19 +41,23 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
|
||||||
save: () => void;
|
save: () => void;
|
||||||
configure: () => void;
|
configure: () => void;
|
||||||
} => {
|
} => {
|
||||||
const widgetProps = reactive<GetFormResultType<F>>((props.widget ? deepClone(props.widget.data) : {}) as GetFormResultType<F>);
|
const widgetProps = reactive((() => {
|
||||||
|
const np = getDefaultFormValues(propsDef);
|
||||||
const mergeProps = () => {
|
if (props.widget?.data != null) {
|
||||||
for (const prop of Object.keys(propsDef)) {
|
for (const key of Object.keys(props.widget.data) as (keyof F)[]) {
|
||||||
if (typeof widgetProps[prop] === 'undefined') {
|
np[key] = props.widget.data[key] as GetFormResultType<F>[typeof key];
|
||||||
widgetProps[prop] = propsDef[prop].default;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
return np;
|
||||||
|
})());
|
||||||
|
|
||||||
watch(widgetProps, () => {
|
watch(() => props.widget?.data, (to) => {
|
||||||
mergeProps();
|
if (to != null) {
|
||||||
}, { deep: true, immediate: true });
|
for (const key of Object.keys(propsDef)) {
|
||||||
|
widgetProps[key] = to[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
const save = throttle(3000, () => {
|
const save = throttle(3000, () => {
|
||||||
emit('updateProps', widgetProps as GetFormResultType<F>);
|
emit('updateProps', widgetProps as GetFormResultType<F>);
|
||||||
|
|
@ -62,11 +68,36 @@ export const useWidgetPropsManager = <F extends FormWithDefault>(
|
||||||
for (const item of Object.keys(form)) {
|
for (const item of Object.keys(form)) {
|
||||||
form[item].default = widgetProps[item];
|
form[item].default = widgetProps[item];
|
||||||
}
|
}
|
||||||
const { canceled, result } = await os.form(name, form);
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
for (const key of Object.keys(result)) {
|
const res = await new Promise<{
|
||||||
widgetProps[key] = result[key];
|
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();
|
save();
|
||||||
|
|
|
||||||
|
|
@ -5639,6 +5639,10 @@ export interface Locale extends ILocale {
|
||||||
* ゼロ埋め
|
* ゼロ埋め
|
||||||
*/
|
*/
|
||||||
"zeroPadding": string;
|
"zeroPadding": string;
|
||||||
|
/**
|
||||||
|
* 設定項目はありません
|
||||||
|
*/
|
||||||
|
"nothingToConfigure": string;
|
||||||
"_imageEditing": {
|
"_imageEditing": {
|
||||||
"_vars": {
|
"_vars": {
|
||||||
/**
|
/**
|
||||||
|
|
@ -9889,6 +9893,138 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"chat": string;
|
"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": {
|
"_cw": {
|
||||||
/**
|
/**
|
||||||
* 隠す
|
* 隠す
|
||||||
|
|
@ -12763,10 +12899,6 @@ export interface Locale extends ILocale {
|
||||||
* 変更を破棄して終了しますか?
|
* 変更を破棄して終了しますか?
|
||||||
*/
|
*/
|
||||||
"discardChangesConfirm": string;
|
"discardChangesConfirm": string;
|
||||||
/**
|
|
||||||
* 設定項目はありません
|
|
||||||
*/
|
|
||||||
"nothingToConfigure": string;
|
|
||||||
/**
|
/**
|
||||||
* 画像の読み込みに失敗しました
|
* 画像の読み込みに失敗しました
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue