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