mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-18 13:33:09 +02:00
MisskeyPlay (#9467)
* wip * wip * wip * wip * wip * Update ui.ts * wip * wip * wip * wip * wip * wip * wip * wip * Update CHANGELOG.md * wip * wip * wip * wip * 🎨 * wip * ✌️
This commit is contained in:
parent
5d904b05dd
commit
ebe340d510
45 changed files with 2465 additions and 93 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -19,7 +19,7 @@ You should also include the user name that made the change.
|
|||
- Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator
|
||||
- You may have to `yarn run clean-all`, `sudo corepack enable` and `yarn set version berry` before running `yarn install` if you're still on yarn classic
|
||||
- 新たに動的なPagesを作ることはできなくなりました
|
||||
- 代わりに今後AiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能の実装を予定しています。
|
||||
- 代わりにAiScriptを用いてより柔軟に動的なコンテンツを作成できるMisskey Play機能が実装されています。
|
||||
- AiScriptが0.12.0にアップデートされました
|
||||
- 0.12.0の変更点についてはこちら https://github.com/syuilo/aiscript/blob/master/CHANGELOG.md#0120
|
||||
- 0.12.0未満のプラグインは読み込むことはできません
|
||||
|
@ -33,12 +33,13 @@ You should also include the user name that made the change.
|
|||
- API: `instance`エンティティに`latestStatus`、`lastCommunicatedAt`、`latestRequestSentAt`プロパティが含まれなくなりました
|
||||
|
||||
### Improvements
|
||||
- Push notification of Antenna note @tamaina
|
||||
- AVIF support @tamaina
|
||||
- Add Cloudflare Turnstile CAPTCHA support @CyberRex0
|
||||
- Misskey Play @syuilo
|
||||
- Introduce retention-rate aggregation @syuilo
|
||||
- Make possible to export favorited notes @syuilo
|
||||
- Add per user pv chart @syuilo
|
||||
- Push notification of Antenna note @tamaina
|
||||
- AVIF support @tamaina
|
||||
- Add Cloudflare Turnstile CAPTCHA support @CyberRex0
|
||||
- Server: signToActivityPubGet is set to true by default @syuilo
|
||||
- Server: improve syslog performance @syuilo
|
||||
- Server: improve note scoring for featured notes @CyberRex0
|
||||
|
@ -47,6 +48,7 @@ You should also include the user name that made the change.
|
|||
- Server: delete outdated notes of antenna regularly to improve db performance @syuilo
|
||||
- Server: improve activitypub deliver performance @syuilo
|
||||
- Client: use tabler-icons instead of fontawesome to better design @syuilo
|
||||
- Client: Add AiScript App widget
|
||||
- Client: Add new gabber kick sounds (thanks for noizenecio)
|
||||
- Client: Add link to user RSS feed in profile menu @ssmucny
|
||||
- Client: Compress non-animated PNG files @saschanaz
|
||||
|
|
|
@ -916,6 +916,10 @@ loggedInAsBot: "Botアカウントでログイン中"
|
|||
tools: "ツール"
|
||||
cannotLoad: "読み込めません"
|
||||
numberOfProfileView: "プロフィール表示回数"
|
||||
like: "いいね!"
|
||||
unlike: "いいねを解除"
|
||||
numberOfLikes: "いいね数"
|
||||
show: "表示"
|
||||
|
||||
_sensitiveMediaDetection:
|
||||
description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てることができます。サーバーの負荷が少し増えます。"
|
||||
|
@ -1348,6 +1352,7 @@ _widgets:
|
|||
jobQueue: "ジョブキュー"
|
||||
serverMetric: "サーバーメトリクス"
|
||||
aiscript: "AiScriptコンソール"
|
||||
aiscriptApp: "AiScript App"
|
||||
aichan: "藍"
|
||||
userList: "ユーザーリスト"
|
||||
_userList:
|
||||
|
@ -1463,6 +1468,22 @@ _timelines:
|
|||
social: "ソーシャル"
|
||||
global: "グローバル"
|
||||
|
||||
_play:
|
||||
new: "Playの作成"
|
||||
edit: "Playの編集"
|
||||
created: "Playを作成しました"
|
||||
updated: "Playを更新しました"
|
||||
deleted: "Playを削除しました"
|
||||
pageSetting: "Play設定"
|
||||
editThisPage: "このPlayを編集"
|
||||
viewSource: "ソースを表示"
|
||||
my: "自分のPlay"
|
||||
liked: "いいねしたPlay"
|
||||
featured: "人気"
|
||||
title: "タイトル"
|
||||
script: "スクリプト"
|
||||
summary: "説明"
|
||||
|
||||
_pages:
|
||||
newPage: "ページの作成"
|
||||
editPage: "ページの編集"
|
||||
|
|
29
packages/backend/migration/1672822262496-Flash.js
Normal file
29
packages/backend/migration/1672822262496-Flash.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
export class Flash1672822262496 {
|
||||
name = 'Flash1672822262496'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE TABLE "flash" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "summary" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "script" character varying(16384) NOT NULL, "permissions" character varying(256) array NOT NULL DEFAULT '{}', "likedCount" integer NOT NULL DEFAULT '0', CONSTRAINT "PK_0c01a2c1c5f2266942dd1b3fdbc" PRIMARY KEY ("id")); COMMENT ON COLUMN "flash"."createdAt" IS 'The created date of the Flash.'; COMMENT ON COLUMN "flash"."updatedAt" IS 'The updated date of the Flash.'; COMMENT ON COLUMN "flash"."userId" IS 'The ID of author.'`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_149d2e44785707548c82999b01" ON "flash" ("createdAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_3aa8ea9a8f15214ad91638c0a7" ON "flash" ("updatedAt") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_9b88250fc2fd009b8f1b5623ed" ON "flash" ("userId") `);
|
||||
await queryRunner.query(`CREATE TABLE "flash_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "flashId" character varying(32) NOT NULL, CONSTRAINT "PK_d110109ee310588d63d6183b233" PRIMARY KEY ("id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_60c4af1c19a7a75f1592f93b28" ON "flash_like" ("userId") `);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_cfbfeeccb0cbedcd660b17eb07" ON "flash_like" ("userId", "flashId") `);
|
||||
await queryRunner.query(`ALTER TABLE "flash" ADD CONSTRAINT "FK_9b88250fc2fd009b8f1b5623ed5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "flash_like" ADD CONSTRAINT "FK_60c4af1c19a7a75f1592f93b287" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "flash_like" ADD CONSTRAINT "FK_6c16fe0e93b7a1951eca624b76a" FOREIGN KEY ("flashId") REFERENCES "flash"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "flash_like" DROP CONSTRAINT "FK_6c16fe0e93b7a1951eca624b76a"`);
|
||||
await queryRunner.query(`ALTER TABLE "flash_like" DROP CONSTRAINT "FK_60c4af1c19a7a75f1592f93b287"`);
|
||||
await queryRunner.query(`ALTER TABLE "flash" DROP CONSTRAINT "FK_9b88250fc2fd009b8f1b5623ed5"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_cfbfeeccb0cbedcd660b17eb07"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_60c4af1c19a7a75f1592f93b28"`);
|
||||
await queryRunner.query(`DROP TABLE "flash_like"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_9b88250fc2fd009b8f1b5623ed"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_3aa8ea9a8f15214ad91638c0a7"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_149d2e44785707548c82999b01"`);
|
||||
await queryRunner.query(`DROP TABLE "flash"`);
|
||||
}
|
||||
}
|
|
@ -95,6 +95,8 @@ import { UserEntityService } from './entities/UserEntityService.js';
|
|||
import { UserGroupEntityService } from './entities/UserGroupEntityService.js';
|
||||
import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js';
|
||||
import { UserListEntityService } from './entities/UserListEntityService.js';
|
||||
import { FlashEntityService } from './entities/FlashEntityService.js';
|
||||
import { FlashLikeEntityService } from './entities/FlashLikeEntityService.js';
|
||||
import { ApAudienceService } from './activitypub/ApAudienceService.js';
|
||||
import { ApDbResolverService } from './activitypub/ApDbResolverService.js';
|
||||
import { ApDeliverManagerService } from './activitypub/ApDeliverManagerService.js';
|
||||
|
@ -216,6 +218,8 @@ const $UserEntityService: Provider = { provide: 'UserEntityService', useExisting
|
|||
const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useExisting: UserGroupEntityService };
|
||||
const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useExisting: UserGroupInvitationEntityService };
|
||||
const $UserListEntityService: Provider = { provide: 'UserListEntityService', useExisting: UserListEntityService };
|
||||
const $FlashEntityService: Provider = { provide: 'FlashEntityService', useExisting: FlashEntityService };
|
||||
const $FlashLikeEntityService: Provider = { provide: 'FlashLikeEntityService', useExisting: FlashLikeEntityService };
|
||||
|
||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
|
||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
|
||||
|
@ -338,6 +342,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
UserGroupEntityService,
|
||||
UserGroupInvitationEntityService,
|
||||
UserListEntityService,
|
||||
FlashEntityService,
|
||||
FlashLikeEntityService,
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
ApDeliverManagerService,
|
||||
|
@ -455,6 +461,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$UserGroupEntityService,
|
||||
$UserGroupInvitationEntityService,
|
||||
$UserListEntityService,
|
||||
$FlashEntityService,
|
||||
$FlashLikeEntityService,
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
$ApDeliverManagerService,
|
||||
|
@ -572,6 +580,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
UserGroupEntityService,
|
||||
UserGroupInvitationEntityService,
|
||||
UserListEntityService,
|
||||
FlashEntityService,
|
||||
FlashLikeEntityService,
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
ApDeliverManagerService,
|
||||
|
@ -688,6 +698,8 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
|||
$UserGroupEntityService,
|
||||
$UserGroupInvitationEntityService,
|
||||
$UserListEntityService,
|
||||
$FlashEntityService,
|
||||
$FlashLikeEntityService,
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
$ApDeliverManagerService,
|
||||
|
|
55
packages/backend/src/core/entities/FlashEntityService.ts
Normal file
55
packages/backend/src/core/entities/FlashEntityService.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import type { } from '@/models/entities/Blocking.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Flash } from '@/models/entities/Flash.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class FlashEntityService {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: Flash['id'] | Flash,
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
): Promise<Packed<'Flash'>> {
|
||||
const meId = me ? me.id : null;
|
||||
const flash = typeof src === 'object' ? src : await this.flashsRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return await awaitAll({
|
||||
id: flash.id,
|
||||
createdAt: flash.createdAt.toISOString(),
|
||||
updatedAt: flash.updatedAt.toISOString(),
|
||||
userId: flash.userId,
|
||||
user: this.userEntityService.pack(flash.user ?? flash.userId, me), // { detail: true } すると無限ループするので注意
|
||||
title: flash.title,
|
||||
summary: flash.summary,
|
||||
script: flash.script,
|
||||
likedCount: flash.likedCount,
|
||||
isLiked: meId ? await this.flashLikesRepository.findOneBy({ flashId: flash.id, userId: meId }).then(x => x != null) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public packMany(
|
||||
flashs: Flash[],
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
) {
|
||||
return Promise.all(flashs.map(x => this.pack(x, me)));
|
||||
}
|
||||
}
|
||||
|
44
packages/backend/src/core/entities/FlashLikeEntityService.ts
Normal file
44
packages/backend/src/core/entities/FlashLikeEntityService.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FlashLikesRepository } from '@/models/index.js';
|
||||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import type { } from '@/models/entities/Blocking.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { FlashLike } from '@/models/entities/FlashLike.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { UserEntityService } from './UserEntityService.js';
|
||||
import { FlashEntityService } from './FlashEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class FlashLikeEntityService {
|
||||
constructor(
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private flashEntityService: FlashEntityService,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: FlashLike['id'] | FlashLike,
|
||||
me?: { id: User['id'] } | null | undefined,
|
||||
) {
|
||||
const like = typeof src === 'object' ? src : await this.flashLikesRepository.findOneByOrFail({ id: src });
|
||||
|
||||
return {
|
||||
id: like.id,
|
||||
flash: await this.flashEntityService.pack(like.flash ?? like.flashId, me),
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public packMany(
|
||||
likes: any[],
|
||||
me: { id: User['id'] },
|
||||
) {
|
||||
return Promise.all(likes.map(x => this.pack(x, me)));
|
||||
}
|
||||
}
|
||||
|
|
@ -69,5 +69,7 @@ export const DI = {
|
|||
adsRepository: Symbol('adsRepository'),
|
||||
passwordResetRequestsRepository: Symbol('passwordResetRequestsRepository'),
|
||||
retentionAggregationsRepository: Symbol('retentionAggregationsRepository'),
|
||||
flashsRepository: Symbol('flashsRepository'),
|
||||
flashLikesRepository: Symbol('flashLikesRepository'),
|
||||
//#endregion
|
||||
};
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation } from './index.js';
|
||||
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash } from './index.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
|
@ -388,6 +388,18 @@ const $retentionAggregationsRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $flashsRepository: Provider = {
|
||||
provide: DI.flashsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Flash),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $flashLikesRepository: Provider = {
|
||||
provide: DI.flashLikesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(FlashLike),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
],
|
||||
|
@ -456,6 +468,8 @@ const $retentionAggregationsRepository: Provider = {
|
|||
$adsRepository,
|
||||
$passwordResetRequestsRepository,
|
||||
$retentionAggregationsRepository,
|
||||
$flashsRepository,
|
||||
$flashLikesRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
|
@ -522,6 +536,8 @@ const $retentionAggregationsRepository: Provider = {
|
|||
$adsRepository,
|
||||
$passwordResetRequestsRepository,
|
||||
$retentionAggregationsRepository,
|
||||
$flashsRepository,
|
||||
$flashLikesRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {}
|
||||
|
|
60
packages/backend/src/models/entities/Flash.ts
Normal file
60
packages/backend/src/models/entities/Flash.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
|
||||
import { id } from '../id.js';
|
||||
import { User } from './User.js';
|
||||
import { DriveFile } from './DriveFile.js';
|
||||
|
||||
@Entity()
|
||||
export class Flash {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The created date of the Flash.',
|
||||
})
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
comment: 'The updated date of the Flash.',
|
||||
})
|
||||
public updatedAt: Date;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256,
|
||||
})
|
||||
public title: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 1024,
|
||||
})
|
||||
public summary: string;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
comment: 'The ID of author.',
|
||||
})
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 16384,
|
||||
})
|
||||
public script: string;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 256, array: true, default: '{}',
|
||||
})
|
||||
public permissions: string[];
|
||||
|
||||
@Column('integer', {
|
||||
default: 0,
|
||||
})
|
||||
public likedCount: number;
|
||||
}
|
33
packages/backend/src/models/entities/FlashLike.ts
Normal file
33
packages/backend/src/models/entities/FlashLike.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
|
||||
import { id } from '../id.js';
|
||||
import { User } from './User.js';
|
||||
import { Flash } from './Flash.js';
|
||||
|
||||
@Entity()
|
||||
@Index(['userId', 'flashId'], { unique: true })
|
||||
export class FlashLike {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
||||
@Column('timestamp with time zone')
|
||||
public createdAt: Date;
|
||||
|
||||
@Index()
|
||||
@Column(id())
|
||||
public userId: User['id'];
|
||||
|
||||
@ManyToOne(type => User, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public user: User | null;
|
||||
|
||||
@Column(id())
|
||||
public flashId: Flash['id'];
|
||||
|
||||
@ManyToOne(type => Flash, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public flash: Flash | null;
|
||||
}
|
|
@ -62,6 +62,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js';
|
|||
import { Webhook } from '@/models/entities/Webhook.js';
|
||||
import { Channel } from '@/models/entities/Channel.js';
|
||||
import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js';
|
||||
import { Flash } from '@/models/entities/Flash.js';
|
||||
import { FlashLike } from '@/models/entities/FlashLike.js';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
export {
|
||||
|
@ -129,6 +131,8 @@ export {
|
|||
Webhook,
|
||||
Channel,
|
||||
RetentionAggregation,
|
||||
Flash,
|
||||
FlashLike,
|
||||
};
|
||||
|
||||
export type AbuseUserReportsRepository = Repository<AbuseUserReport>;
|
||||
|
@ -195,3 +199,5 @@ export type UserSecurityKeysRepository = Repository<UserSecurityKey>;
|
|||
export type WebhooksRepository = Repository<Webhook>;
|
||||
export type ChannelsRepository = Repository<Channel>;
|
||||
export type RetentionAggregationsRepository = Repository<RetentionAggregation>;
|
||||
export type FlashsRepository = Repository<Flash>;
|
||||
export type FlashLikesRepository = Repository<FlashLike>;
|
||||
|
|
|
@ -70,6 +70,8 @@ import { UserSecurityKey } from '@/models/entities/UserSecurityKey.js';
|
|||
import { Webhook } from '@/models/entities/Webhook.js';
|
||||
import { Channel } from '@/models/entities/Channel.js';
|
||||
import { RetentionAggregation } from '@/models/entities/RetentionAggregation.js';
|
||||
import { Flash } from '@/models/entities/Flash.js';
|
||||
import { FlashLike } from '@/models/entities/FlashLike.js';
|
||||
|
||||
import { Config } from '@/config.js';
|
||||
import MisskeyLogger from '@/logger.js';
|
||||
|
@ -184,6 +186,8 @@ export const entities = [
|
|||
Webhook,
|
||||
UserIp,
|
||||
RetentionAggregation,
|
||||
Flash,
|
||||
FlashLike,
|
||||
...charts,
|
||||
];
|
||||
|
||||
|
|
|
@ -266,6 +266,15 @@ import * as ep___pages_like from './endpoints/pages/like.js';
|
|||
import * as ep___pages_show from './endpoints/pages/show.js';
|
||||
import * as ep___pages_unlike from './endpoints/pages/unlike.js';
|
||||
import * as ep___pages_update from './endpoints/pages/update.js';
|
||||
import * as ep___flash_create from './endpoints/flash/create.js';
|
||||
import * as ep___flash_delete from './endpoints/flash/delete.js';
|
||||
import * as ep___flash_featured from './endpoints/flash/featured.js';
|
||||
import * as ep___flash_like from './endpoints/flash/like.js';
|
||||
import * as ep___flash_show from './endpoints/flash/show.js';
|
||||
import * as ep___flash_unlike from './endpoints/flash/unlike.js';
|
||||
import * as ep___flash_update from './endpoints/flash/update.js';
|
||||
import * as ep___flash_my from './endpoints/flash/my.js';
|
||||
import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
|
||||
import * as ep___ping from './endpoints/ping.js';
|
||||
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
|
||||
import * as ep___promo_read from './endpoints/promo/read.js';
|
||||
|
@ -587,6 +596,15 @@ const $pages_like: Provider = { provide: 'ep:pages/like', useClass: ep___pages_l
|
|||
const $pages_show: Provider = { provide: 'ep:pages/show', useClass: ep___pages_show.default };
|
||||
const $pages_unlike: Provider = { provide: 'ep:pages/unlike', useClass: ep___pages_unlike.default };
|
||||
const $pages_update: Provider = { provide: 'ep:pages/update', useClass: ep___pages_update.default };
|
||||
const $flash_create: Provider = { provide: 'ep:flash/create', useClass: ep___flash_create.default };
|
||||
const $flash_delete: Provider = { provide: 'ep:flash/delete', useClass: ep___flash_delete.default };
|
||||
const $flash_featured: Provider = { provide: 'ep:flash/featured', useClass: ep___flash_featured.default };
|
||||
const $flash_like: Provider = { provide: 'ep:flash/like', useClass: ep___flash_like.default };
|
||||
const $flash_show: Provider = { provide: 'ep:flash/show', useClass: ep___flash_show.default };
|
||||
const $flash_unlike: Provider = { provide: 'ep:flash/unlike', useClass: ep___flash_unlike.default };
|
||||
const $flash_update: Provider = { provide: 'ep:flash/update', useClass: ep___flash_update.default };
|
||||
const $flash_my: Provider = { provide: 'ep:flash/my', useClass: ep___flash_my.default };
|
||||
const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___flash_myLikes.default };
|
||||
const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default };
|
||||
const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default };
|
||||
const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default };
|
||||
|
@ -912,6 +930,15 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$pages_show,
|
||||
$pages_unlike,
|
||||
$pages_update,
|
||||
$flash_create,
|
||||
$flash_delete,
|
||||
$flash_featured,
|
||||
$flash_like,
|
||||
$flash_show,
|
||||
$flash_unlike,
|
||||
$flash_update,
|
||||
$flash_my,
|
||||
$flash_myLikes,
|
||||
$ping,
|
||||
$pinnedUsers,
|
||||
$promo_read,
|
||||
|
@ -1231,6 +1258,15 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$pages_show,
|
||||
$pages_unlike,
|
||||
$pages_update,
|
||||
$flash_create,
|
||||
$flash_delete,
|
||||
$flash_featured,
|
||||
$flash_like,
|
||||
$flash_show,
|
||||
$flash_unlike,
|
||||
$flash_update,
|
||||
$flash_my,
|
||||
$flash_myLikes,
|
||||
$ping,
|
||||
$pinnedUsers,
|
||||
$promo_read,
|
||||
|
|
|
@ -265,6 +265,15 @@ import * as ep___pages_like from './endpoints/pages/like.js';
|
|||
import * as ep___pages_show from './endpoints/pages/show.js';
|
||||
import * as ep___pages_unlike from './endpoints/pages/unlike.js';
|
||||
import * as ep___pages_update from './endpoints/pages/update.js';
|
||||
import * as ep___flash_create from './endpoints/flash/create.js';
|
||||
import * as ep___flash_delete from './endpoints/flash/delete.js';
|
||||
import * as ep___flash_featured from './endpoints/flash/featured.js';
|
||||
import * as ep___flash_like from './endpoints/flash/like.js';
|
||||
import * as ep___flash_show from './endpoints/flash/show.js';
|
||||
import * as ep___flash_unlike from './endpoints/flash/unlike.js';
|
||||
import * as ep___flash_update from './endpoints/flash/update.js';
|
||||
import * as ep___flash_my from './endpoints/flash/my.js';
|
||||
import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
|
||||
import * as ep___ping from './endpoints/ping.js';
|
||||
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
|
||||
import * as ep___promo_read from './endpoints/promo/read.js';
|
||||
|
@ -584,6 +593,15 @@ const eps = [
|
|||
['pages/show', ep___pages_show],
|
||||
['pages/unlike', ep___pages_unlike],
|
||||
['pages/update', ep___pages_update],
|
||||
['flash/create', ep___flash_create],
|
||||
['flash/delete', ep___flash_delete],
|
||||
['flash/featured', ep___flash_featured],
|
||||
['flash/like', ep___flash_like],
|
||||
['flash/show', ep___flash_show],
|
||||
['flash/unlike', ep___flash_unlike],
|
||||
['flash/update', ep___flash_update],
|
||||
['flash/my', ep___flash_my],
|
||||
['flash/my-likes', ep___flash_myLikes],
|
||||
['ping', ep___ping],
|
||||
['pinned-users', ep___pinnedUsers],
|
||||
['promo/read', ep___promo_read],
|
||||
|
|
66
packages/backend/src/server/api/endpoints/flash/create.ts
Normal file
66
packages/backend/src/server/api/endpoints/flash/create.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import ms from 'ms';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { DriveFilesRepository, FlashsRepository, PagesRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Page } from '@/models/entities/Page.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { PageEntityService } from '@/core/entities/PageEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:flash',
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 10,
|
||||
},
|
||||
|
||||
errors: {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
script: { type: 'string' },
|
||||
permissions: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
required: ['title', 'summary', 'script', 'permissions'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
private flashEntityService: FlashEntityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const flash = await this.flashsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
userId: me.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
title: ps.title,
|
||||
summary: ps.summary,
|
||||
script: ps.script,
|
||||
permissions: ps.permissions,
|
||||
}).then(x => this.flashsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
return await this.flashEntityService.pack(flash);
|
||||
});
|
||||
}
|
||||
}
|
56
packages/backend/src/server/api/endpoints/flash/delete.ts
Normal file
56
packages/backend/src/server/api/endpoints/flash/delete.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FlashsRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flashs'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:flash',
|
||||
|
||||
errors: {
|
||||
noSuchFlash: {
|
||||
message: 'No such flash.',
|
||||
code: 'NO_SUCH_FLASH',
|
||||
id: 'de1623ef-bbb3-4289-a71e-14cfa83d9740',
|
||||
},
|
||||
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '1036ad7b-9f92-4fff-89c3-0e50dc941704',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flashId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['flashId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
|
||||
if (flash == null) {
|
||||
throw new ApiError(meta.errors.noSuchFlash);
|
||||
}
|
||||
if (flash.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
await this.flashsRepository.delete(flash.id);
|
||||
});
|
||||
}
|
||||
}
|
48
packages/backend/src/server/api/endpoints/flash/featured.ts
Normal file
48
packages/backend/src/server/api/endpoints/flash/featured.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FlashsRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Flash',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
private flashEntityService: FlashEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.flashsRepository.createQueryBuilder('flash')
|
||||
.andWhere('flash.likedCount > 0')
|
||||
.orderBy('flash.likedCount', 'DESC');
|
||||
|
||||
const flashs = await query.take(10).getMany();
|
||||
|
||||
return await this.flashEntityService.packMany(flashs, me);
|
||||
});
|
||||
}
|
||||
}
|
87
packages/backend/src/server/api/endpoints/flash/like.ts
Normal file
87
packages/backend/src/server/api/endpoints/flash/like.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:flash-likes',
|
||||
|
||||
errors: {
|
||||
noSuchFlash: {
|
||||
message: 'No such flash.',
|
||||
code: 'NO_SUCH_FLASH',
|
||||
id: 'c07c1491-9161-4c5c-9d75-01906f911f73',
|
||||
},
|
||||
|
||||
yourFlash: {
|
||||
message: 'You cannot like your flash.',
|
||||
code: 'YOUR_FLASH',
|
||||
id: '3fd8a0e7-5955-4ba9-85bb-bf3e0c30e13b',
|
||||
},
|
||||
|
||||
alreadyLiked: {
|
||||
message: 'The flash has already been liked.',
|
||||
code: 'ALREADY_LIKED',
|
||||
id: '010065cf-ad43-40df-8067-abff9f4686e3',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flashId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['flashId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
|
||||
if (flash == null) {
|
||||
throw new ApiError(meta.errors.noSuchFlash);
|
||||
}
|
||||
|
||||
if (flash.userId === me.id) {
|
||||
throw new ApiError(meta.errors.yourFlash);
|
||||
}
|
||||
|
||||
// if already liked
|
||||
const exist = await this.flashLikesRepository.findOneBy({
|
||||
flashId: flash.id,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
if (exist != null) {
|
||||
throw new ApiError(meta.errors.alreadyLiked);
|
||||
}
|
||||
|
||||
// Create like
|
||||
await this.flashLikesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
flashId: flash.id,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
this.flashsRepository.increment({ id: flash.id }, 'likedCount', 1);
|
||||
});
|
||||
}
|
||||
}
|
68
packages/backend/src/server/api/endpoints/flash/my-likes.ts
Normal file
68
packages/backend/src/server/api/endpoints/flash/my-likes.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { FlashLikesRepository } from '@/models/index.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { FlashLikeEntityService } from '@/core/entities/FlashLikeEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account', 'flash'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:flash-likes',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
},
|
||||
flash: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Flash',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
|
||||
private flashLikeEntityService: FlashLikeEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), ps.sinceId, ps.untilId)
|
||||
.andWhere('like.userId = :meId', { meId: me.id })
|
||||
.leftJoinAndSelect('like.flash', 'flash');
|
||||
|
||||
const likes = await query
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return this.flashLikeEntityService.packMany(likes, me);
|
||||
});
|
||||
}
|
||||
}
|
57
packages/backend/src/server/api/endpoints/flash/my.ts
Normal file
57
packages/backend/src/server/api/endpoints/flash/my.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import type { FlashsRepository } from '@/models/index.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account', 'flash'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'read:flash',
|
||||
|
||||
res: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Flash',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { type: 'string', format: 'misskey:id' },
|
||||
untilId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
private flashEntityService: FlashEntityService,
|
||||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.flashsRepository.createQueryBuilder('flash'), ps.sinceId, ps.untilId)
|
||||
.andWhere('flash.userId = :meId', { meId: me.id });
|
||||
|
||||
const flashs = await query
|
||||
.take(ps.limit)
|
||||
.getMany();
|
||||
|
||||
return await this.flashEntityService.packMany(flashs);
|
||||
});
|
||||
}
|
||||
}
|
60
packages/backend/src/server/api/endpoints/flash/show.ts
Normal file
60
packages/backend/src/server/api/endpoints/flash/show.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { IsNull } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository, FlashsRepository } from '@/models/index.js';
|
||||
import type { Flash } from '@/models/entities/Flash.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flashs'],
|
||||
|
||||
requireCredential: false,
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Flash',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchFlash: {
|
||||
message: 'No such flash.',
|
||||
code: 'NO_SUCH_FLASH',
|
||||
id: 'f0d34a1a-d29a-401d-90ba-1982122b5630',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flashId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['flashId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
private flashEntityService: FlashEntityService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
|
||||
|
||||
if (flash == null) {
|
||||
throw new ApiError(meta.errors.noSuchFlash);
|
||||
}
|
||||
|
||||
return await this.flashEntityService.pack(flash, me);
|
||||
});
|
||||
}
|
||||
}
|
68
packages/backend/src/server/api/endpoints/flash/unlike.ts
Normal file
68
packages/backend/src/server/api/endpoints/flash/unlike.ts
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FlashsRepository, FlashLikesRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:flash-likes',
|
||||
|
||||
errors: {
|
||||
noSuchFlash: {
|
||||
message: 'No such flash.',
|
||||
code: 'NO_SUCH_FLASH',
|
||||
id: 'afe8424a-a69e-432d-a5f2-2f0740c62410',
|
||||
},
|
||||
|
||||
notLiked: {
|
||||
message: 'You have not liked that flash.',
|
||||
code: 'NOT_LIKED',
|
||||
id: '755f25a7-9871-4f65-9f34-51eaad9ae0ac',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flashId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['flashId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.flashLikesRepository)
|
||||
private flashLikesRepository: FlashLikesRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
|
||||
if (flash == null) {
|
||||
throw new ApiError(meta.errors.noSuchFlash);
|
||||
}
|
||||
|
||||
const exist = await this.flashLikesRepository.findOneBy({
|
||||
flashId: flash.id,
|
||||
userId: me.id,
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
throw new ApiError(meta.errors.notLiked);
|
||||
}
|
||||
|
||||
// Delete like
|
||||
await this.flashLikesRepository.delete(exist.id);
|
||||
|
||||
this.flashsRepository.decrement({ id: flash.id }, 'likedCount', 1);
|
||||
});
|
||||
}
|
||||
}
|
78
packages/backend/src/server/api/endpoints/flash/update.ts
Normal file
78
packages/backend/src/server/api/endpoints/flash/update.ts
Normal file
|
@ -0,0 +1,78 @@
|
|||
import ms from 'ms';
|
||||
import { Not } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { FlashsRepository, DriveFilesRepository } from '@/models/index.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['flash'],
|
||||
|
||||
requireCredential: true,
|
||||
|
||||
kind: 'write:flash',
|
||||
|
||||
limit: {
|
||||
duration: ms('1hour'),
|
||||
max: 300,
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchFlash: {
|
||||
message: 'No such flash.',
|
||||
code: 'NO_SUCH_FLASH',
|
||||
id: '611e13d2-309e-419a-a5e4-e0422da39b02',
|
||||
},
|
||||
|
||||
accessDenied: {
|
||||
message: 'Access denied.',
|
||||
code: 'ACCESS_DENIED',
|
||||
id: '08e60c88-5948-478e-a132-02ec701d67b2',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
flashId: { type: 'string', format: 'misskey:id' },
|
||||
title: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
script: { type: 'string' },
|
||||
permissions: { type: 'array', items: {
|
||||
type: 'string',
|
||||
} },
|
||||
},
|
||||
required: ['flashId', 'title', 'summary', 'script', 'permissions'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.flashsRepository)
|
||||
private flashsRepository: FlashsRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
|
||||
if (flash == null) {
|
||||
throw new ApiError(meta.errors.noSuchFlash);
|
||||
}
|
||||
if (flash.userId !== me.id) {
|
||||
throw new ApiError(meta.errors.accessDenied);
|
||||
}
|
||||
|
||||
await this.flashsRepository.update(flash.id, {
|
||||
updatedAt: new Date(),
|
||||
title: ps.title,
|
||||
summary: ps.summary,
|
||||
script: ps.script,
|
||||
permissions: ps.permissions,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
107
packages/frontend/src/components/MkAsUi.vue
Normal file
107
packages/frontend/src/components/MkAsUi.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="c.type === 'root'" :class="$style.root">
|
||||
<template v-for="child in c.children" :key="child">
|
||||
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
|
||||
</template>
|
||||
</div>
|
||||
<span v-else-if="c.type === 'text'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, fontWeight: c.bold ? 'bold' : null, color: c.color ?? null }">{{ c.text }}</span>
|
||||
<Mfm v-else-if="c.type === 'mfm'" :class="{ [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace' }" :style="{ fontSize: c.size ? `${c.size * 100}%` : null, color: c.color ?? null }" :text="c.text"/>
|
||||
<MkButton v-else-if="c.type === 'button'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="c.onClick">{{ c.text }}</MkButton>
|
||||
<div v-else-if="c.type === 'buttons'" style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<MkButton v-for="button in c.buttons" :primary="button.primary" :rounded="button.rounded" :small="size === 'small'" @click="button.onClick">{{ button.text }}</MkButton>
|
||||
</div>
|
||||
<MkSwitch v-else-if="c.type === 'switch'" :model-value="valueForSwitch" @update:model-value="onSwitchUpdate">
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
</MkSwitch>
|
||||
<MkTextarea v-else-if="c.type === 'textarea'" :model-value="c.default" @update:model-value="c.onInput">
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
</MkTextarea>
|
||||
<MkInput v-else-if="c.type === 'textInput'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onInput">
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
</MkInput>
|
||||
<MkInput v-else-if="c.type === 'numberInput'" :small="size === 'small'" :model-value="c.default" type="number" @update:model-value="c.onInput">
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
</MkInput>
|
||||
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :model-value="c.default" @update:model-value="c.onChange">
|
||||
<template v-if="c.label" #label>{{ c.label }}</template>
|
||||
<template v-if="c.caption" #caption>{{ c.caption }}</template>
|
||||
<option v-for="item in c.items" :key="item.value" :value="item.value">{{ item.text }}</option>
|
||||
</MkSelect>
|
||||
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" @click="openPostForm">{{ c.text }}</MkButton>
|
||||
<div v-else-if="c.type === 'container'" :class="[$style.container, { [$style.fontSerif]: c.font === 'serif', [$style.fontMonospace]: c.font === 'monospace', [$style.containerCenter]: c.align === 'center' }]" :style="{ backgroundColor: c.bgColor ?? null, color: c.fgColor ?? null, borderWidth: c.borderWidth ? `${c.borderWidth}px` : 0, borderColor: c.borderColor ?? 'var(--divider)', padding: c.padding ? `${c.padding}px` : 0, borderRadius: c.rounded ? '8px' : 0 }">
|
||||
<template v-for="child in c.children" :key="child">
|
||||
<MkAsUi v-if="!g(child).hidden" :component="g(child)" :components="props.components" :size="size"/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, onMounted, onUnmounted, Ref } from 'vue';
|
||||
import * as os from '@/os';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import MkInput from '@/components/form/input.vue';
|
||||
import MkSwitch from '@/components/form/switch.vue';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
import MkSelect from '@/components/form/select.vue';
|
||||
import { AsUiComponent } from '@/scripts/aiscript/ui';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
component: AsUiComponent;
|
||||
components: Ref<AsUiComponent>[];
|
||||
size: 'small' | 'medium' | 'large';
|
||||
}>(), {
|
||||
size: 'medium',
|
||||
});
|
||||
|
||||
const c = props.component;
|
||||
|
||||
function g(id) {
|
||||
return props.components.find(x => x.value.id === id).value;
|
||||
}
|
||||
|
||||
let valueForSwitch = $ref(c.default ?? false);
|
||||
|
||||
function onSwitchUpdate(v) {
|
||||
valueForSwitch = v;
|
||||
if (c.onChange) c.onChange(v);
|
||||
}
|
||||
|
||||
function openPostForm() {
|
||||
os.post({
|
||||
initialText: c.form.text,
|
||||
instant: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.containerCenter {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fontSerif {
|
||||
font-family: serif;
|
||||
}
|
||||
|
||||
.fontMonospace {
|
||||
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
|
||||
}
|
||||
</style>
|
|
@ -2,7 +2,7 @@
|
|||
<button
|
||||
v-if="!link"
|
||||
ref="el" class="bghgjjyj _button"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full, small }"
|
||||
:class="{ inline, primary, gradate, danger, rounded, full, small, large, asLike }"
|
||||
:type="type"
|
||||
@click="emit('click', $event)"
|
||||
@mousedown="onMousedown"
|
||||
|
@ -41,6 +41,8 @@ const props = defineProps<{
|
|||
danger?: boolean;
|
||||
full?: boolean;
|
||||
small?: boolean;
|
||||
large?: boolean;
|
||||
asLike?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
@ -131,6 +133,11 @@ function onMousedown(evt: MouseEvent): void {
|
|||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
&.large {
|
||||
font-size: 100%;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
&.full {
|
||||
width: 100%;
|
||||
}
|
||||
|
@ -153,6 +160,37 @@ function onMousedown(evt: MouseEvent): void {
|
|||
}
|
||||
}
|
||||
|
||||
&.asLike {
|
||||
background: rgba(255, 86, 125, 0.07);
|
||||
color: #ff002f;
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: rgba(255, 74, 116, 0.11);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: rgba(224, 57, 96, 0.125);
|
||||
}
|
||||
|
||||
> .ripples {
|
||||
::v-deep(div) {
|
||||
background: rgba(255, 60, 106, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: rgb(241 97 132);
|
||||
|
||||
&:not(:disabled):hover {
|
||||
background: rgb(241 92 128);
|
||||
}
|
||||
|
||||
&:not(:disabled):active {
|
||||
background: rgb(241 92 128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.gradate {
|
||||
font-weight: bold;
|
||||
color: var(--fgOnAccent) !important;
|
||||
|
|
|
@ -59,7 +59,7 @@ defineExpose({
|
|||
|
||||
&.disabled {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
> .box {
|
||||
|
|
112
packages/frontend/src/components/MkFlashPreview.vue
Normal file
112
packages/frontend/src/components/MkFlashPreview.vue
Normal file
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<MkA :to="`/play/${flash.id}`" class="vhpxefrk _block" tabindex="-1">
|
||||
<article>
|
||||
<header>
|
||||
<h1 :title="flash.title">{{ flash.title }}</h1>
|
||||
</header>
|
||||
<p v-if="flash.summary" :title="flash.summary">{{ flash.summary.length > 85 ? flash.summary.slice(0, 85) + '…' : flash.summary }}</p>
|
||||
<footer>
|
||||
<img class="icon" :src="flash.user.avatarUrl"/>
|
||||
<p>{{ userName(flash.user) }}</p>
|
||||
</footer>
|
||||
</article>
|
||||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { userName } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
|
||||
const props = defineProps<{
|
||||
//flash: misskey.entities.Flash;
|
||||
flash: any;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.vhpxefrk {
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
> article {
|
||||
padding: 16px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 8px;
|
||||
|
||||
> h1 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
color: var(--urlPreviewTitle);
|
||||
}
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
color: var(--urlPreviewText);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 8px;
|
||||
height: 16px;
|
||||
|
||||
> img {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 4px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
> p {
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
color: var(--urlPreviewInfo);
|
||||
font-size: 0.8em;
|
||||
line-height: 16px;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
}
|
||||
|
||||
@media (max-width: 550px) {
|
||||
font-size: 12px;
|
||||
|
||||
> article {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
font-size: 10px;
|
||||
|
||||
> article {
|
||||
padding: 8px;
|
||||
|
||||
> header {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
> footer {
|
||||
margin-top: 4px;
|
||||
|
||||
> img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
|
@ -50,7 +50,7 @@ const menu = defaultStore.state.menu;
|
|||
|
||||
const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => navbarItemDef[k]).filter(def => def.show == null ? true : def.show).map(def => ({
|
||||
type: def.to ? 'link' : 'button',
|
||||
text: i18n.ts[def.title],
|
||||
text: def.title,
|
||||
icon: def.icon,
|
||||
to: def.to,
|
||||
action: def.action,
|
||||
|
|
|
@ -14,22 +14,15 @@
|
|||
</MkA>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
<script lang="ts" setup>
|
||||
import { } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import { userName } from '@/filters/user';
|
||||
import * as os from '@/os';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
page: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
userName,
|
||||
},
|
||||
});
|
||||
const props = defineProps<{
|
||||
page: misskey.entities.Page;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -126,7 +126,7 @@ const onClick = (ev: MouseEvent) => {
|
|||
const pushOption = (option: VNode) => {
|
||||
menu.push({
|
||||
text: option.children,
|
||||
active: v.value === option.props.value,
|
||||
active: computed(() => v.value === option.props.value),
|
||||
action: () => {
|
||||
v.value = option.props.value;
|
||||
},
|
||||
|
|
|
@ -8,97 +8,102 @@ import { unisonReload } from '@/scripts/unison-reload';
|
|||
|
||||
export const navbarItemDef = reactive({
|
||||
notifications: {
|
||||
title: 'notifications',
|
||||
title: i18n.ts.notifications,
|
||||
icon: 'ti ti-bell',
|
||||
show: computed(() => $i != null),
|
||||
indicated: computed(() => $i != null && $i.hasUnreadNotification),
|
||||
to: '/my/notifications',
|
||||
},
|
||||
messaging: {
|
||||
title: 'messaging',
|
||||
title: i18n.ts.messaging,
|
||||
icon: 'ti ti-messages',
|
||||
show: computed(() => $i != null),
|
||||
indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage),
|
||||
to: '/my/messaging',
|
||||
},
|
||||
drive: {
|
||||
title: 'drive',
|
||||
title: i18n.ts.drive,
|
||||
icon: 'ti ti-cloud',
|
||||
show: computed(() => $i != null),
|
||||
to: '/my/drive',
|
||||
},
|
||||
followRequests: {
|
||||
title: 'followRequests',
|
||||
title: i18n.ts.followRequests,
|
||||
icon: 'ti ti-user-plus',
|
||||
show: computed(() => $i != null && $i.isLocked),
|
||||
indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest),
|
||||
to: '/my/follow-requests',
|
||||
},
|
||||
explore: {
|
||||
title: 'explore',
|
||||
title: i18n.ts.explore,
|
||||
icon: 'ti ti-hash',
|
||||
to: '/explore',
|
||||
},
|
||||
announcements: {
|
||||
title: 'announcements',
|
||||
title: i18n.ts.announcements,
|
||||
icon: 'ti ti-speakerphone',
|
||||
indicated: computed(() => $i != null && $i.hasUnreadAnnouncement),
|
||||
to: '/announcements',
|
||||
},
|
||||
search: {
|
||||
title: 'search',
|
||||
title: i18n.ts.search,
|
||||
icon: 'ti ti-search',
|
||||
action: () => search(),
|
||||
},
|
||||
lists: {
|
||||
title: 'lists',
|
||||
title: i18n.ts.lists,
|
||||
icon: 'ti ti-list',
|
||||
show: computed(() => $i != null),
|
||||
to: '/my/lists',
|
||||
},
|
||||
/*
|
||||
groups: {
|
||||
title: 'groups',
|
||||
title: i18n.ts.groups,
|
||||
icon: 'ti ti-users',
|
||||
show: computed(() => $i != null),
|
||||
to: '/my/groups',
|
||||
},
|
||||
*/
|
||||
antennas: {
|
||||
title: 'antennas',
|
||||
title: i18n.ts.antennas,
|
||||
icon: 'ti ti-antenna',
|
||||
show: computed(() => $i != null),
|
||||
to: '/my/antennas',
|
||||
},
|
||||
favorites: {
|
||||
title: 'favorites',
|
||||
title: i18n.ts.favorites,
|
||||
icon: 'ti ti-star',
|
||||
show: computed(() => $i != null),
|
||||
to: '/my/favorites',
|
||||
},
|
||||
pages: {
|
||||
title: 'pages',
|
||||
title: i18n.ts.pages,
|
||||
icon: 'ti ti-news',
|
||||
to: '/pages',
|
||||
},
|
||||
play: {
|
||||
title: 'Play',
|
||||
icon: 'ti ti-player-play',
|
||||
to: '/play',
|
||||
},
|
||||
gallery: {
|
||||
title: 'gallery',
|
||||
title: i18n.ts.gallery,
|
||||
icon: 'ti ti-icons',
|
||||
to: '/gallery',
|
||||
},
|
||||
clips: {
|
||||
title: 'clip',
|
||||
title: i18n.ts.clip,
|
||||
icon: 'ti ti-paperclip',
|
||||
show: computed(() => $i != null),
|
||||
to: '/my/clips',
|
||||
},
|
||||
channels: {
|
||||
title: 'channel',
|
||||
title: i18n.ts.channel,
|
||||
icon: 'ti ti-device-tv',
|
||||
to: '/channels',
|
||||
},
|
||||
ui: {
|
||||
title: 'switchUi',
|
||||
title: i18n.ts.switchUi,
|
||||
icon: 'ti ti-devices',
|
||||
action: (ev) => {
|
||||
os.popupMenu([{
|
||||
|
@ -126,7 +131,7 @@ export const navbarItemDef = reactive({
|
|||
},
|
||||
},
|
||||
reload: {
|
||||
title: 'reload',
|
||||
title: i18n.ts.reload,
|
||||
icon: 'ti ti-refresh',
|
||||
action: (ev) => {
|
||||
location.reload();
|
||||
|
|
111
packages/frontend/src/pages/flash/flash-edit.vue
Normal file
111
packages/frontend/src/pages/flash/flash-edit.vue
Normal file
|
@ -0,0 +1,111 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700">
|
||||
<MkInput v-model="title" class="_formBlock">
|
||||
<template #label>{{ i18n.ts._play.title }}</template>
|
||||
</MkInput>
|
||||
<MkTextarea v-model="summary" class="_formBlock">
|
||||
<template #label>{{ i18n.ts._play.summary }}</template>
|
||||
</MkTextarea>
|
||||
<MkTextarea v-model="script" class="_formBlock _monospace" tall spellcheck="false">
|
||||
<template #label>{{ i18n.ts._play.script }}</template>
|
||||
</MkTextarea>
|
||||
<div style="display: flex; gap: var(--margin); flex-wrap: wrap;">
|
||||
<MkButton primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
|
||||
<MkButton @click="show"><i class="ti ti-eye"></i> {{ i18n.ts.show }}</MkButton>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os';
|
||||
import { url } from '@/config';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
import MkInput from '@/components/form/input.vue';
|
||||
import { useRouter } from '@/router';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const props = defineProps<{
|
||||
id?: string;
|
||||
}>();
|
||||
|
||||
let flash = $ref(null);
|
||||
|
||||
if (props.id) {
|
||||
flash = await os.api('flash/show', {
|
||||
flashId: props.id,
|
||||
});
|
||||
}
|
||||
|
||||
let title = $ref(flash?.title ?? 'New Play');
|
||||
let summary = $ref(flash?.summary ?? '');
|
||||
let permissions = $ref(flash?.permissions ?? []);
|
||||
let script = $ref(flash?.script ?? `/// @ 0.12.0
|
||||
|
||||
var name = ""
|
||||
|
||||
Ui:render([
|
||||
Ui:C:textInput({
|
||||
label: "Your name"
|
||||
onInput: @(v) { name = v }
|
||||
})
|
||||
Ui:C:button({
|
||||
text: "Hello"
|
||||
onClick: @() {
|
||||
Mk:dialog(null \`Hello, {name}!\`)
|
||||
}
|
||||
})
|
||||
])
|
||||
`);
|
||||
|
||||
async function save() {
|
||||
if (flash) {
|
||||
os.apiWithDialog('flash/update', {
|
||||
flashId: props.id,
|
||||
title,
|
||||
summary,
|
||||
permissions,
|
||||
script,
|
||||
});
|
||||
} else {
|
||||
const created = await os.apiWithDialog('flash/create', {
|
||||
title,
|
||||
summary,
|
||||
permissions,
|
||||
script,
|
||||
});
|
||||
router.push('/play/' + created.id + '/edit');
|
||||
}
|
||||
}
|
||||
|
||||
function show() {
|
||||
if (flash == null) {
|
||||
os.alert({
|
||||
text: 'Please save',
|
||||
});
|
||||
} else {
|
||||
os.pageWindow(`/play/${flash.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
definePageMetadata(computed(() => flash ? {
|
||||
title: i18n.ts._play.edit + ': ' + flash.title,
|
||||
} : {
|
||||
title: i18n.ts._play.new,
|
||||
}));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
99
packages/frontend/src/pages/flash/flash-index.vue
Normal file
99
packages/frontend/src/pages/flash/flash-index.vue
Normal file
|
@ -0,0 +1,99 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700">
|
||||
<div v-if="tab === 'featured'" class="">
|
||||
<MkPagination v-slot="{items}" :pagination="featuredFlashsPagination">
|
||||
<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
|
||||
</MkPagination>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'my'" class="my">
|
||||
<MkButton class="new" @click="create()"><i class="ti ti-plus"></i></MkButton>
|
||||
<MkPagination v-slot="{items}" :pagination="myFlashsPagination">
|
||||
<MkFlashPreview v-for="flash in items" :key="flash.id" class="" :flash="flash"/>
|
||||
</MkPagination>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tab === 'liked'" class="">
|
||||
<MkPagination v-slot="{items}" :pagination="likedFlashsPagination">
|
||||
<MkFlashPreview v-for="like in items" :key="like.flash.id" class="" :flash="like.flash"/>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import MkFlashPreview from '@/components/MkFlashPreview.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import { useRouter } from '@/router';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
let tab = $ref('featured');
|
||||
|
||||
const featuredFlashsPagination = {
|
||||
endpoint: 'flash/featured' as const,
|
||||
noPaging: true,
|
||||
};
|
||||
const myFlashsPagination = {
|
||||
endpoint: 'flash/my' as const,
|
||||
limit: 5,
|
||||
};
|
||||
const likedFlashsPagination = {
|
||||
endpoint: 'flash/my-likes' as const,
|
||||
limit: 5,
|
||||
};
|
||||
|
||||
function create() {
|
||||
router.push('/play/new');
|
||||
}
|
||||
|
||||
const headerActions = $computed(() => [{
|
||||
icon: 'ti ti-plus',
|
||||
text: i18n.ts.create,
|
||||
handler: create,
|
||||
}]);
|
||||
|
||||
const headerTabs = $computed(() => [{
|
||||
key: 'featured',
|
||||
title: i18n.ts._play.featured,
|
||||
icon: 'fas fa-fire-alt',
|
||||
}, {
|
||||
key: 'my',
|
||||
title: i18n.ts._play.my,
|
||||
icon: 'ti ti-edit',
|
||||
}, {
|
||||
key: 'liked',
|
||||
title: i18n.ts._play.liked,
|
||||
icon: 'ti ti-heart',
|
||||
}]);
|
||||
|
||||
definePageMetadata(computed(() => ({
|
||||
title: 'Play',
|
||||
icon: 'ti ti-player-play',
|
||||
})));
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.rknalgpo {
|
||||
&.my .ckltabjg:first-child {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ckltabjg:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 500px) {
|
||||
.ckltabjg:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
291
packages/frontend/src/pages/flash/flash.vue
Normal file
291
packages/frontend/src/pages/flash/flash.vue
Normal file
|
@ -0,0 +1,291 @@
|
|||
<template>
|
||||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="700">
|
||||
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
|
||||
<div v-if="flash" :key="flash.id">
|
||||
<Transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in">
|
||||
<div v-if="started" :class="$style.started">
|
||||
<div class="main _panel">
|
||||
<MkAsUi v-if="root" :component="root" :components="components"/>
|
||||
</div>
|
||||
<div class="actions _panel">
|
||||
<MkButton v-if="flash.isLiked" v-tooltip="i18n.ts.unlike" as-like class="button" rounded primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="flash.likedCount > 0" class="count">{{ flash.likedCount }}</span></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts.like" as-like class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash.likedCount > 0" class="count">{{ flash.likedCount }}</span></MkButton>
|
||||
<MkButton v-tooltip="i18n.ts.shareWithNote" class="button" rounded @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></MkButton>
|
||||
<MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.ready">
|
||||
<div class="_panel main">
|
||||
<div class="title">{{ flash.title }}</div>
|
||||
<div class="summary">{{ flash.summary }}</div>
|
||||
<MkButton class="start" gradate rounded large @click="start">Play</MkButton>
|
||||
<div class="info">
|
||||
<span v-tooltip="i18n.ts.numberOfLikes"><i class="ti ti-heart"></i> {{ flash.likedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<FormFolder class="_formBlock">
|
||||
<template #icon><i class="ti ti-code"></i></template>
|
||||
<template #label>{{ i18n.ts._play.viewSource }}</template>
|
||||
|
||||
<MkTextarea :model-value="flash.script" readonly tall class="_monospace" spellcheck="false"></MkTextarea>
|
||||
</FormFolder>
|
||||
<div :class="$style.footer">
|
||||
<Mfm :text="`By @${flash.user.username}`"/>
|
||||
<div class="date">
|
||||
<div v-if="flash.createdAt != flash.updatedAt"><i class="ti ti-clock"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="flash.updatedAt" mode="detail"/></div>
|
||||
<div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="flash.createdAt" mode="detail"/></div>
|
||||
</div>
|
||||
</div>
|
||||
<MkA v-if="$i && $i.id === flash.userId" :to="`/play/${flash.id}/edit`" style="color: var(--accent);">{{ i18n.ts._play.editThisPage }}</MkA>
|
||||
<MkAd :prefer="['horizontal', 'horizontal-big']"/>
|
||||
</div>
|
||||
<MkError v-else-if="error" @retry="fetchPage()"/>
|
||||
<MkLoading v-else/>
|
||||
</Transition>
|
||||
</MkSpacer>
|
||||
</MkStickyContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
|
||||
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os';
|
||||
import { url } from '@/config';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import MkPagePreview from '@/components/MkPagePreview.vue';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import MkAsUi from '@/components/MkAsUi.vue';
|
||||
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
|
||||
import { createAiScriptEnv } from '@/scripts/aiscript/api';
|
||||
import FormFolder from '@/components/form/folder.vue';
|
||||
import MkTextarea from '@/components/form/textarea.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
}>();
|
||||
|
||||
let flash = $ref(null);
|
||||
let error = $ref(null);
|
||||
|
||||
function fetchFlash() {
|
||||
flash = null;
|
||||
os.api('flash/show', {
|
||||
flashId: props.id,
|
||||
}).then(_flash => {
|
||||
flash = _flash;
|
||||
}).catch(err => {
|
||||
error = err;
|
||||
});
|
||||
}
|
||||
|
||||
function share() {
|
||||
navigator.share({
|
||||
title: flash.title,
|
||||
text: flash.summary,
|
||||
url: `${url}/play/${flash.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
function shareWithNote() {
|
||||
os.post({
|
||||
initialText: `${flash.title} ${url}/play/${flash.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
function like() {
|
||||
os.apiWithDialog('flash/like', {
|
||||
flashId: flash.id,
|
||||
}).then(() => {
|
||||
flash.isLiked = true;
|
||||
flash.likedCount++;
|
||||
});
|
||||
}
|
||||
|
||||
async function unlike() {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
text: i18n.ts.unlikeConfirm,
|
||||
});
|
||||
if (confirm.canceled) return;
|
||||
os.apiWithDialog('flash/unlike', {
|
||||
flashId: flash.id,
|
||||
}).then(() => {
|
||||
flash.isLiked = false;
|
||||
flash.likedCount--;
|
||||
});
|
||||
}
|
||||
|
||||
watch(() => props.id, fetchFlash, { immediate: true });
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
let started = $ref(false);
|
||||
let aiscript = $shallowRef<Interpreter | null>(null);
|
||||
const root = ref<AsUiRoot>();
|
||||
const components: Ref<AsUiComponent>[] = [];
|
||||
|
||||
function start() {
|
||||
started = true;
|
||||
run();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (aiscript) aiscript.abort();
|
||||
|
||||
aiscript = new Interpreter({
|
||||
...createAiScriptEnv({
|
||||
storageKey: 'flash:' + flash.id,
|
||||
}),
|
||||
...registerAsUiLib(components, (_root) => {
|
||||
root.value = _root.value;
|
||||
}),
|
||||
}, {
|
||||
in: (q) => {
|
||||
return new Promise(ok => {
|
||||
os.inputText({
|
||||
title: q,
|
||||
}).then(({ canceled, result: a }) => {
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
},
|
||||
out: (value) => {
|
||||
// nop
|
||||
},
|
||||
log: (type, params) => {
|
||||
// nop
|
||||
},
|
||||
});
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parser.parse(flash.script);
|
||||
} catch (err) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: 'Syntax error :(',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await aiscript.exec(ast);
|
||||
} catch (err) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: 'AiScript Error',
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onDeactivated(() => {
|
||||
if (aiscript) aiscript.abort();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (aiscript) aiscript.abort();
|
||||
});
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
||||
definePageMetadata(computed(() => flash ? {
|
||||
title: flash.title,
|
||||
avatar: flash.user,
|
||||
path: `/play/${flash.id}`,
|
||||
share: {
|
||||
title: flash.title,
|
||||
text: flash.summary,
|
||||
},
|
||||
} : null));
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.ready {
|
||||
&:global {
|
||||
> .main {
|
||||
padding: 32px;
|
||||
|
||||
> .title {
|
||||
font-size: 1.4em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .summary {
|
||||
font-size: 1.1em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
> .start {
|
||||
margin: 1em auto 1em auto;
|
||||
}
|
||||
|
||||
> .info {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 16px;
|
||||
|
||||
&:global {
|
||||
> .date {
|
||||
margin: 8px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.started {
|
||||
&:global {
|
||||
> .main {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
> .actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.125s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.zoom-enter-active,
|
||||
.zoom-leave-active {
|
||||
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
|
||||
}
|
||||
.zoom-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
.zoom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
</style>
|
|
@ -18,8 +18,8 @@
|
|||
</div>
|
||||
<div class="actions">
|
||||
<div class="like">
|
||||
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
|
||||
<MkButton v-if="page.isLiked" v-tooltip="i18n.ts._pages.unlike" class="button" as-like primary @click="unlike()"><i class="ti ti-heart-off"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
|
||||
<MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" as-like @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton>
|
||||
</div>
|
||||
<div class="other">
|
||||
<button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button>
|
||||
|
@ -207,20 +207,6 @@ definePageMetadata(computed(() => page ? {
|
|||
padding: 16px 0 0 0;
|
||||
border-top: solid 0.5px var(--divider);
|
||||
|
||||
> .like {
|
||||
> .button {
|
||||
--accent: rgb(241 97 132);
|
||||
--X8: rgb(241 92 128);
|
||||
--buttonBg: rgb(216 71 106 / 5%);
|
||||
--buttonHoverBg: rgb(216 71 106 / 10%);
|
||||
color: #ff002f;
|
||||
|
||||
::v-deep(.count) {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .other {
|
||||
margin-left: auto;
|
||||
|
||||
|
|
|
@ -1,25 +1,34 @@
|
|||
<template>
|
||||
<div class="iltifgqe">
|
||||
<div class="editor _panel _gap">
|
||||
<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
|
||||
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
|
||||
</div>
|
||||
|
||||
<MkContainer :foldable="true" class="_gap">
|
||||
<template #header>{{ i18n.ts.output }}</template>
|
||||
<div class="bepmlvbi">
|
||||
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
|
||||
<MkSpacer :content-max="800">
|
||||
<div :class="$style.root">
|
||||
<div :class="$style.editor" class="_panel">
|
||||
<PrismEditor v-model="code" class="_code code" :highlight="highlighter" :line-numbers="false"/>
|
||||
<MkButton style="position: absolute; top: 8px; right: 8px;" primary @click="run()"><i class="ti ti-player-play"></i></MkButton>
|
||||
</div>
|
||||
</MkContainer>
|
||||
|
||||
<div class="_gap">
|
||||
{{ i18n.ts.scratchpadDescription }}
|
||||
<MkContainer v-if="root && components.length > 0" :key="uiKey" :foldable="true">
|
||||
<template #header>UI</template>
|
||||
<div :class="$style.ui">
|
||||
<MkAsUi :component="root" :components="components" size="small"/>
|
||||
</div>
|
||||
</MkContainer>
|
||||
|
||||
<MkContainer :foldable="true" class="">
|
||||
<template #header>{{ i18n.ts.output }}</template>
|
||||
<div :class="$style.logs">
|
||||
<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
|
||||
</div>
|
||||
</MkContainer>
|
||||
|
||||
<div class="">
|
||||
{{ i18n.ts.scratchpadDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MkSpacer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { onDeactivated, onUnmounted, Ref, ref, watch } from 'vue';
|
||||
import 'prismjs';
|
||||
import { highlight, languages } from 'prismjs/components/prism-core';
|
||||
import 'prismjs/components/prism-clike';
|
||||
|
@ -35,11 +44,16 @@ import * as os from '@/os';
|
|||
import { $i } from '@/account';
|
||||
import { i18n } from '@/i18n';
|
||||
import { definePageMetadata } from '@/scripts/page-metadata';
|
||||
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
|
||||
import MkAsUi from '@/components/MkAsUi.vue';
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
let aiscript: Interpreter;
|
||||
const code = ref('');
|
||||
const logs = ref<any[]>([]);
|
||||
const root = ref<AsUiRoot>();
|
||||
let components: Ref<AsUiComponent>[] = [];
|
||||
let uiKey = $ref(0);
|
||||
|
||||
const saved = localStorage.getItem('scratchpad');
|
||||
if (saved) {
|
||||
|
@ -51,10 +65,19 @@ watch(code, () => {
|
|||
});
|
||||
|
||||
async function run() {
|
||||
if (aiscript) aiscript.abort();
|
||||
root.value = undefined;
|
||||
components = [];
|
||||
uiKey++;
|
||||
logs.value = [];
|
||||
const aiscript = new Interpreter(createAiScriptEnv({
|
||||
storageKey: 'scratchpad',
|
||||
token: $i?.token,
|
||||
aiscript = new Interpreter(({
|
||||
...createAiScriptEnv({
|
||||
storageKey: 'widget',
|
||||
token: $i?.token,
|
||||
}),
|
||||
...registerAsUiLib(components, (_root) => {
|
||||
root.value = _root.value;
|
||||
}),
|
||||
}), {
|
||||
in: (q) => {
|
||||
return new Promise(ok => {
|
||||
|
@ -96,10 +119,11 @@ async function run() {
|
|||
}
|
||||
try {
|
||||
await aiscript.exec(ast);
|
||||
} catch (error: any) {
|
||||
} catch (err: any) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: error.message,
|
||||
title: 'AiScript Error',
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -108,6 +132,14 @@ function highlighter(code) {
|
|||
return highlight(code, languages.js, 'javascript');
|
||||
}
|
||||
|
||||
onDeactivated(() => {
|
||||
if (aiscript) aiscript.abort();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (aiscript) aiscript.abort();
|
||||
});
|
||||
|
||||
const headerActions = $computed(() => []);
|
||||
|
||||
const headerTabs = $computed(() => []);
|
||||
|
@ -118,21 +150,29 @@ definePageMetadata({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.iltifgqe {
|
||||
padding: 16px;
|
||||
|
||||
> .editor {
|
||||
position: relative;
|
||||
}
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--margin);
|
||||
}
|
||||
|
||||
.bepmlvbi {
|
||||
.editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ui {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.logs {
|
||||
padding: 16px;
|
||||
|
||||
> .log {
|
||||
&:not(.print) {
|
||||
opacity: 0.7;
|
||||
&:global {
|
||||
> .log {
|
||||
&:not(.print) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ async function addItem() {
|
|||
const { canceled, result: item } = await os.select({
|
||||
title: i18n.ts.addItem,
|
||||
items: [...menu.map(k => ({
|
||||
value: k, text: i18n.ts[navbarItemDef[k].title],
|
||||
value: k, text: navbarItemDef[k].title,
|
||||
})), {
|
||||
value: '-', text: i18n.ts.divider,
|
||||
}],
|
||||
|
|
|
@ -262,6 +262,20 @@ export const routes = [{
|
|||
}, {
|
||||
path: '/pages',
|
||||
component: page(() => import('./pages/pages.vue')),
|
||||
}, {
|
||||
path: '/play/:id/edit',
|
||||
component: page(() => import('./pages/flash/flash-edit.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/play/new',
|
||||
component: page(() => import('./pages/flash/flash-edit.vue')),
|
||||
loginRequired: true,
|
||||
}, {
|
||||
path: '/play/:id',
|
||||
component: page(() => import('./pages/flash/flash.vue')),
|
||||
}, {
|
||||
path: '/play',
|
||||
component: page(() => import('./pages/flash/flash-index.vue')),
|
||||
}, {
|
||||
path: '/gallery/:postId/edit',
|
||||
component: page(() => import('./pages/gallery/edit.vue')),
|
||||
|
|
526
packages/frontend/src/scripts/aiscript/ui.ts
Normal file
526
packages/frontend/src/scripts/aiscript/ui.ts
Normal file
|
@ -0,0 +1,526 @@
|
|||
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ref, Ref } from 'vue';
|
||||
|
||||
export type AsUiComponentBase = {
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export type AsUiRoot = AsUiComponentBase & {
|
||||
type: 'root';
|
||||
children: AsUiComponent['id'][];
|
||||
};
|
||||
|
||||
export type AsUiContainer = AsUiComponentBase & {
|
||||
type: 'container';
|
||||
children?: AsUiComponent['id'][];
|
||||
align?: 'left' | 'center' | 'right';
|
||||
bgColor?: string;
|
||||
fgColor?: string;
|
||||
font?: 'serif' | 'sans-serif' | 'monospace';
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
padding?: number;
|
||||
rounded?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export type AsUiText = AsUiComponentBase & {
|
||||
type: 'text';
|
||||
text?: string;
|
||||
size?: number;
|
||||
bold?: boolean;
|
||||
color?: string;
|
||||
font?: 'serif' | 'sans-serif' | 'monospace';
|
||||
};
|
||||
|
||||
export type AsUiMfm = AsUiComponentBase & {
|
||||
type: 'mfm';
|
||||
text?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
font?: 'serif' | 'sans-serif' | 'monospace';
|
||||
};
|
||||
|
||||
export type AsUiButton = AsUiComponentBase & {
|
||||
type: 'button';
|
||||
text?: string;
|
||||
onClick?: () => void;
|
||||
primary?: boolean;
|
||||
rounded?: boolean;
|
||||
};
|
||||
|
||||
export type AsUiButtons = AsUiComponentBase & {
|
||||
type: 'buttons';
|
||||
buttons?: AsUiButton[];
|
||||
};
|
||||
|
||||
export type AsUiSwitch = AsUiComponentBase & {
|
||||
type: 'switch';
|
||||
onChange?: (v: boolean) => void;
|
||||
default?: boolean;
|
||||
label?: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AsUiTextarea = AsUiComponentBase & {
|
||||
type: 'textarea';
|
||||
onInput?: (v: string) => void;
|
||||
default?: string;
|
||||
label?: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AsUiTextInput = AsUiComponentBase & {
|
||||
type: 'textInput';
|
||||
onInput?: (v: string) => void;
|
||||
default?: string;
|
||||
label?: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AsUiNumberInput = AsUiComponentBase & {
|
||||
type: 'numberInput';
|
||||
onInput?: (v: number) => void;
|
||||
default?: number;
|
||||
label?: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AsUiSelect = AsUiComponentBase & {
|
||||
type: 'select';
|
||||
items?: {
|
||||
text: string;
|
||||
value: string;
|
||||
}[];
|
||||
onChange?: (v: string) => void;
|
||||
default?: string;
|
||||
label?: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type AsUiPostFormButton = AsUiComponentBase & {
|
||||
type: 'postFormButton';
|
||||
text?: string;
|
||||
primary?: boolean;
|
||||
rounded?: boolean;
|
||||
form?: {
|
||||
text: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type AsUiComponent = AsUiRoot | AsUiContainer | AsUiText | AsUiMfm | AsUiButton | AsUiButtons | AsUiSwitch | AsUiTextarea | AsUiTextInput | AsUiNumberInput | AsUiSelect | AsUiPostFormButton;
|
||||
|
||||
export function patch(id: string, def: values.Value, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
function getRootOptions(def: values.Value | undefined): Omit<AsUiRoot, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const children = def.value.get('children');
|
||||
utils.assertArray(children);
|
||||
|
||||
return {
|
||||
children: children.value.map(v => {
|
||||
utils.assertObject(v);
|
||||
return v.value.get('id').value;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function getContainerOptions(def: values.Value | undefined): Omit<AsUiContainer, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const children = def.value.get('children');
|
||||
if (children) utils.assertArray(children);
|
||||
const align = def.value.get('align');
|
||||
if (align) utils.assertString(align);
|
||||
const bgColor = def.value.get('bgColor');
|
||||
if (bgColor) utils.assertString(bgColor);
|
||||
const fgColor = def.value.get('fgColor');
|
||||
if (fgColor) utils.assertString(fgColor);
|
||||
const font = def.value.get('font');
|
||||
if (font) utils.assertString(font);
|
||||
const borderWidth = def.value.get('borderWidth');
|
||||
if (borderWidth) utils.assertNumber(borderWidth);
|
||||
const borderColor = def.value.get('borderColor');
|
||||
if (borderColor) utils.assertString(borderColor);
|
||||
const padding = def.value.get('padding');
|
||||
if (padding) utils.assertNumber(padding);
|
||||
const rounded = def.value.get('rounded');
|
||||
if (rounded) utils.assertBoolean(rounded);
|
||||
const hidden = def.value.get('hidden');
|
||||
if (hidden) utils.assertBoolean(hidden);
|
||||
|
||||
return {
|
||||
children: children ? children.value.map(v => {
|
||||
utils.assertObject(v);
|
||||
return v.value.get('id').value;
|
||||
}) : [],
|
||||
align: align?.value,
|
||||
fgColor: fgColor?.value,
|
||||
bgColor: bgColor?.value,
|
||||
font: font?.value,
|
||||
borderWidth: borderWidth?.value,
|
||||
borderColor: borderColor?.value,
|
||||
padding: padding?.value,
|
||||
rounded: rounded?.value,
|
||||
hidden: hidden?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getTextOptions(def: values.Value | undefined): Omit<AsUiText, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const text = def.value.get('text');
|
||||
if (text) utils.assertString(text);
|
||||
const size = def.value.get('size');
|
||||
if (size) utils.assertNumber(size);
|
||||
const bold = def.value.get('bold');
|
||||
if (bold) utils.assertBoolean(bold);
|
||||
const color = def.value.get('color');
|
||||
if (color) utils.assertString(color);
|
||||
const font = def.value.get('font');
|
||||
if (font) utils.assertString(font);
|
||||
|
||||
return {
|
||||
text: text?.value,
|
||||
size: size?.value,
|
||||
bold: bold?.value,
|
||||
color: color?.value,
|
||||
font: font?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getMfmOptions(def: values.Value | undefined): Omit<AsUiMfm, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const text = def.value.get('text');
|
||||
if (text) utils.assertString(text);
|
||||
const size = def.value.get('size');
|
||||
if (size) utils.assertNumber(size);
|
||||
const color = def.value.get('color');
|
||||
if (color) utils.assertString(color);
|
||||
const font = def.value.get('font');
|
||||
if (font) utils.assertString(font);
|
||||
|
||||
return {
|
||||
text: text?.value,
|
||||
size: size?.value,
|
||||
color: color?.value,
|
||||
font: font?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getTextInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextInput, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const onInput = def.value.get('onInput');
|
||||
if (onInput) utils.assertFunction(onInput);
|
||||
const defaultValue = def.value.get('default');
|
||||
if (defaultValue) utils.assertString(defaultValue);
|
||||
const label = def.value.get('label');
|
||||
if (label) utils.assertString(label);
|
||||
const caption = def.value.get('caption');
|
||||
if (caption) utils.assertString(caption);
|
||||
|
||||
return {
|
||||
onInput: (v) => {
|
||||
if (onInput) call(onInput, [utils.jsToVal(v)]);
|
||||
},
|
||||
default: defaultValue?.value,
|
||||
label: label?.value,
|
||||
caption: caption?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getTextareaOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiTextarea, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const onInput = def.value.get('onInput');
|
||||
if (onInput) utils.assertFunction(onInput);
|
||||
const defaultValue = def.value.get('default');
|
||||
if (defaultValue) utils.assertString(defaultValue);
|
||||
const label = def.value.get('label');
|
||||
if (label) utils.assertString(label);
|
||||
const caption = def.value.get('caption');
|
||||
if (caption) utils.assertString(caption);
|
||||
|
||||
return {
|
||||
onInput: (v) => {
|
||||
if (onInput) call(onInput, [utils.jsToVal(v)]);
|
||||
},
|
||||
default: defaultValue?.value,
|
||||
label: label?.value,
|
||||
caption: caption?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getNumberInputOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiNumberInput, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const onInput = def.value.get('onInput');
|
||||
if (onInput) utils.assertFunction(onInput);
|
||||
const defaultValue = def.value.get('default');
|
||||
if (defaultValue) utils.assertNumber(defaultValue);
|
||||
const label = def.value.get('label');
|
||||
if (label) utils.assertString(label);
|
||||
const caption = def.value.get('caption');
|
||||
if (caption) utils.assertString(caption);
|
||||
|
||||
return {
|
||||
onInput: (v) => {
|
||||
if (onInput) call(onInput, [utils.jsToVal(v)]);
|
||||
},
|
||||
default: defaultValue?.value,
|
||||
label: label?.value,
|
||||
caption: caption?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButton, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const text = def.value.get('text');
|
||||
if (text) utils.assertString(text);
|
||||
const onClick = def.value.get('onClick');
|
||||
if (onClick) utils.assertFunction(onClick);
|
||||
const primary = def.value.get('primary');
|
||||
if (primary) utils.assertBoolean(primary);
|
||||
const rounded = def.value.get('rounded');
|
||||
if (rounded) utils.assertBoolean(rounded);
|
||||
|
||||
return {
|
||||
text: text?.value,
|
||||
onClick: () => {
|
||||
if (onClick) call(onClick, []);
|
||||
},
|
||||
primary: primary?.value,
|
||||
rounded: rounded?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getButtonsOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiButtons, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const buttons = def.value.get('buttons');
|
||||
if (buttons) utils.assertArray(buttons);
|
||||
|
||||
return {
|
||||
buttons: buttons ? buttons.value.map(button => {
|
||||
utils.assertObject(button);
|
||||
const text = button.value.get('text');
|
||||
utils.assertString(text);
|
||||
const onClick = button.value.get('onClick');
|
||||
utils.assertFunction(onClick);
|
||||
const primary = button.value.get('primary');
|
||||
if (primary) utils.assertBoolean(primary);
|
||||
const rounded = button.value.get('rounded');
|
||||
if (rounded) utils.assertBoolean(rounded);
|
||||
|
||||
return {
|
||||
text: text.value,
|
||||
onClick: () => {
|
||||
call(onClick, []);
|
||||
},
|
||||
primary: primary?.value,
|
||||
rounded: rounded?.value,
|
||||
};
|
||||
}) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function getSwitchOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSwitch, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const onChange = def.value.get('onChange');
|
||||
if (onChange) utils.assertFunction(onChange);
|
||||
const defaultValue = def.value.get('default');
|
||||
if (defaultValue) utils.assertBoolean(defaultValue);
|
||||
const label = def.value.get('label');
|
||||
if (label) utils.assertString(label);
|
||||
const caption = def.value.get('caption');
|
||||
if (caption) utils.assertString(caption);
|
||||
|
||||
return {
|
||||
onChange: (v) => {
|
||||
if (onChange) call(onChange, [utils.jsToVal(v)]);
|
||||
},
|
||||
default: defaultValue?.value,
|
||||
label: label?.value,
|
||||
caption: caption?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getSelectOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiSelect, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const items = def.value.get('items');
|
||||
if (items) utils.assertArray(items);
|
||||
const onChange = def.value.get('onChange');
|
||||
if (onChange) utils.assertFunction(onChange);
|
||||
const defaultValue = def.value.get('default');
|
||||
if (defaultValue) utils.assertString(defaultValue);
|
||||
const label = def.value.get('label');
|
||||
if (label) utils.assertString(label);
|
||||
const caption = def.value.get('caption');
|
||||
if (caption) utils.assertString(caption);
|
||||
|
||||
return {
|
||||
items: items ? items.value.map(item => {
|
||||
utils.assertObject(item);
|
||||
const text = item.value.get('text');
|
||||
utils.assertString(text);
|
||||
const value = item.value.get('value');
|
||||
if (value) utils.assertString(value);
|
||||
return {
|
||||
text: text.value,
|
||||
value: value ? value.value : text.value,
|
||||
};
|
||||
}) : [],
|
||||
onChange: (v) => {
|
||||
if (onChange) call(onChange, [utils.jsToVal(v)]);
|
||||
},
|
||||
default: defaultValue?.value,
|
||||
label: label?.value,
|
||||
caption: caption?.value,
|
||||
};
|
||||
}
|
||||
|
||||
function getPostFormButtonOptions(def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>): Omit<AsUiPostFormButton, 'id' | 'type'> {
|
||||
utils.assertObject(def);
|
||||
|
||||
const text = def.value.get('text');
|
||||
if (text) utils.assertString(text);
|
||||
const primary = def.value.get('primary');
|
||||
if (primary) utils.assertBoolean(primary);
|
||||
const rounded = def.value.get('rounded');
|
||||
if (rounded) utils.assertBoolean(rounded);
|
||||
const form = def.value.get('form');
|
||||
if (form) utils.assertObject(form);
|
||||
|
||||
const getForm = () => {
|
||||
const text = form!.value.get('text');
|
||||
utils.assertString(text);
|
||||
return {
|
||||
text: text.value,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
text: text?.value,
|
||||
primary: primary?.value,
|
||||
rounded: rounded?.value,
|
||||
form: form ? getForm() : {
|
||||
text: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerAsUiLib(components: Ref<AsUiComponent>[], done: (root: Ref<AsUiRoot>) => void) {
|
||||
const instances = {};
|
||||
|
||||
function createComponentInstance(type: AsUiComponent['type'], def: values.Value | undefined, id: values.Value | undefined, getOptions: (def: values.Value | undefined, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) => any, call: (fn: values.VFn, args: values.Value[]) => Promise<values.Value>) {
|
||||
if (id) utils.assertString(id);
|
||||
const _id = id?.value ?? uuid();
|
||||
const component = ref({
|
||||
...getOptions(def, call),
|
||||
type,
|
||||
id: _id,
|
||||
});
|
||||
components.push(component);
|
||||
const instance = values.OBJ(new Map([
|
||||
['id', values.STR(_id)],
|
||||
['update', values.FN_NATIVE(async ([def], opts) => {
|
||||
utils.assertObject(def);
|
||||
const updates = getOptions(def, call);
|
||||
for (const update of def.value.keys()) {
|
||||
if (!Object.hasOwn(updates, update)) continue;
|
||||
component.value[update] = updates[update];
|
||||
}
|
||||
})],
|
||||
]));
|
||||
instances[_id] = instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
const rootInstance = createComponentInstance('root', utils.jsToVal({ children: [] }), utils.jsToVal('___root___'), getRootOptions, () => {});
|
||||
const rootComponent = components[0] as Ref<AsUiRoot>;
|
||||
done(rootComponent);
|
||||
|
||||
return {
|
||||
'Ui:root': rootInstance,
|
||||
|
||||
'Ui:patch': values.FN_NATIVE(async ([id, val], opts) => {
|
||||
utils.assertString(id);
|
||||
utils.assertArray(val);
|
||||
patch(id.value, val.value, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:get': values.FN_NATIVE(async ([id], opts) => {
|
||||
utils.assertString(id);
|
||||
const instance = instances[id.value];
|
||||
if (instance) {
|
||||
return instance;
|
||||
} else {
|
||||
return values.NULL;
|
||||
}
|
||||
}),
|
||||
|
||||
// Ui:root.update({ children: [...] }) の糖衣構文
|
||||
'Ui:render': values.FN_NATIVE(async ([children], opts) => {
|
||||
utils.assertArray(children);
|
||||
|
||||
rootComponent.value.children = children.value.map(v => {
|
||||
utils.assertObject(v);
|
||||
return v.value.get('id').value;
|
||||
});
|
||||
}),
|
||||
|
||||
'Ui:C:container': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('container', def, id, getContainerOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:text': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('text', def, id, getTextOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:mfm': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('mfm', def, id, getMfmOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:textarea': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('textarea', def, id, getTextareaOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:textInput': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('textInput', def, id, getTextInputOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:numberInput': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('numberInput', def, id, getNumberInputOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:button': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('button', def, id, getButtonOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:buttons': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('buttons', def, id, getButtonsOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:switch': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('switch', def, id, getSwitchOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:select': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('select', def, id, getSelectOptions, opts.call);
|
||||
}),
|
||||
|
||||
'Ui:C:postFormButton': values.FN_NATIVE(async ([def, id], opts) => {
|
||||
return createComponentInstance('postFormButton', def, id, getPostFormButtonOptions, opts.call);
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -14,7 +14,7 @@
|
|||
<template v-for="item in menu">
|
||||
<div v-if="item === '-'" class="divider"></div>
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: navbarItemDef[item].active }]" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
|
||||
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
|
||||
</component>
|
||||
</template>
|
||||
|
|
|
@ -17,14 +17,14 @@
|
|||
:is="navbarItemDef[item].to ? 'MkA' : 'button'"
|
||||
v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)"
|
||||
v-click-anime
|
||||
v-tooltip.noDelay.right="i18n.ts[navbarItemDef[item].title]"
|
||||
v-tooltip.noDelay.right="navbarItemDef[item].title"
|
||||
class="item _button"
|
||||
:class="[item, { active: navbarItemDef[item].active }]"
|
||||
active-class="active"
|
||||
:to="navbarItemDef[item].to"
|
||||
v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}"
|
||||
>
|
||||
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ i18n.ts[navbarItemDef[item].title] }}</span>
|
||||
<i class="icon ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="icon _indicatorCircle"></i></span>
|
||||
</component>
|
||||
</template>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</MkA>
|
||||
<template v-for="item in menu">
|
||||
<div v-if="item === '-'" class="divider"></div>
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="$ts[navbarItemDef[item].title]" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime v-tooltip="navbarItemDef[item].title" class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<i class="ti-fw" :class="navbarItemDef[item].icon"></i>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
</component>
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<template v-for="item in menu">
|
||||
<div v-if="item === '-'" class="divider"></div>
|
||||
<component :is="navbarItemDef[item].to ? 'MkA' : 'button'" v-else-if="navbarItemDef[item] && (navbarItemDef[item].show !== false)" v-click-anime class="item _button" :class="item" active-class="active" :to="navbarItemDef[item].to" v-on="navbarItemDef[item].action ? { click: navbarItemDef[item].action } : {}">
|
||||
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ $ts[navbarItemDef[item].title] }}</span>
|
||||
<i class="ti-fw" :class="navbarItemDef[item].icon"></i><span class="text">{{ navbarItemDef[item].title }}</span>
|
||||
<span v-if="navbarItemDef[item].indicated" class="indicator"><i class="_indicatorCircle"></i></span>
|
||||
</component>
|
||||
</template>
|
||||
|
|
122
packages/frontend/src/widgets/aiscript-app.vue
Normal file
122
packages/frontend/src/widgets/aiscript-app.vue
Normal file
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<MkContainer :show-header="widgetProps.showHeader" class="mkw-aiscriptApp">
|
||||
<template #header>App</template>
|
||||
<div :class="$style.root">
|
||||
<MkAsUi v-if="root" :component="root" :components="components" size="small"/>
|
||||
</div>
|
||||
</MkContainer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, Ref, ref, watch } from 'vue';
|
||||
import { Interpreter, Parser, utils, values } from '@syuilo/aiscript';
|
||||
import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget';
|
||||
import { GetFormResultType } from '@/scripts/form';
|
||||
import * as os from '@/os';
|
||||
import { createAiScriptEnv } from '@/scripts/aiscript/api';
|
||||
import { $i } from '@/account';
|
||||
import MkAsUi from '@/components/MkAsUi.vue';
|
||||
import MkContainer from '@/components/MkContainer.vue';
|
||||
import { AsUiComponent, AsUiRoot, patch, registerAsUiLib, render } from '@/scripts/aiscript/ui';
|
||||
|
||||
const name = 'aiscriptApp';
|
||||
|
||||
const widgetPropsDef = {
|
||||
script: {
|
||||
type: 'string' as const,
|
||||
multiline: true,
|
||||
default: '',
|
||||
},
|
||||
showHeader: {
|
||||
type: 'boolean' as const,
|
||||
default: true,
|
||||
},
|
||||
};
|
||||
|
||||
type WidgetProps = GetFormResultType<typeof widgetPropsDef>;
|
||||
|
||||
// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない
|
||||
//const props = defineProps<WidgetComponentProps<WidgetProps>>();
|
||||
//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>();
|
||||
const props = defineProps<{ widget?: Widget<WidgetProps>; }>();
|
||||
const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>();
|
||||
|
||||
const { widgetProps, configure } = useWidgetPropsManager(name,
|
||||
widgetPropsDef,
|
||||
props,
|
||||
emit,
|
||||
);
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
const root = ref<AsUiRoot>();
|
||||
const components: Ref<AsUiComponent>[] = [];
|
||||
|
||||
async function run() {
|
||||
const aiscript = new Interpreter({
|
||||
...createAiScriptEnv({
|
||||
storageKey: 'widget',
|
||||
token: $i?.token,
|
||||
}),
|
||||
...registerAsUiLib(components, (_root) => {
|
||||
root.value = _root.value;
|
||||
}),
|
||||
}, {
|
||||
in: (q) => {
|
||||
return new Promise(ok => {
|
||||
os.inputText({
|
||||
title: q,
|
||||
}).then(({ canceled, result: a }) => {
|
||||
ok(a);
|
||||
});
|
||||
});
|
||||
},
|
||||
out: (value) => {
|
||||
// nop
|
||||
},
|
||||
log: (type, params) => {
|
||||
// nop
|
||||
},
|
||||
});
|
||||
|
||||
let ast;
|
||||
try {
|
||||
ast = parser.parse(widgetProps.script);
|
||||
} catch (err) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: 'Syntax error :(',
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await aiscript.exec(ast);
|
||||
} catch (err) {
|
||||
os.alert({
|
||||
type: 'error',
|
||||
title: 'AiScript Error',
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => widgetProps.script, () => {
|
||||
run();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
run();
|
||||
});
|
||||
|
||||
defineExpose<WidgetComponentExpose>({
|
||||
name,
|
||||
configure,
|
||||
id: props.widget ? props.widget.id : null,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.root {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
|
@ -22,6 +22,7 @@ export default function(app: App) {
|
|||
app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue')));
|
||||
app.component('MkwButton', defineAsyncComponent(() => import('./button.vue')));
|
||||
app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue')));
|
||||
app.component('MkwAiscriptApp', defineAsyncComponent(() => import('./aiscript-app.vue')));
|
||||
app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue')));
|
||||
app.component('MkwUserList', defineAsyncComponent(() => import('./user-list.vue')));
|
||||
}
|
||||
|
@ -48,6 +49,7 @@ export const widgets = [
|
|||
'jobQueue',
|
||||
'button',
|
||||
'aiscript',
|
||||
'aiscriptApp',
|
||||
'aichan',
|
||||
'userList',
|
||||
];
|
||||
|
|
Loading…
Reference in a new issue