This commit is contained in:
github-actions[bot] 2025-09-24 08:01:52 +00:00 committed by GitHub
commit 09e1e830ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
183 changed files with 4102 additions and 1672 deletions

View File

@ -105,6 +105,16 @@ port: 3000
# socket: /path/to/misskey.sock # socket: /path/to/misskey.sock
# chmodSocket: '777' # chmodSocket: '777'
# Proxy trust settings
#
# Changes how the server interpret the origin IP of the request.
#
# Any format supported by Fastify is accepted.
# Default: trust all proxies (i.e. trustProxy: true)
# See: https://fastify.dev/docs/latest/reference/server/#trustproxy
#
# trustProxy: 1
# ┌──────────────────────────┐ # ┌──────────────────────────┐
#───┘ PostgreSQL configuration └──────────────────────────────── #───┘ PostgreSQL configuration └────────────────────────────────

View File

@ -1,3 +1,24 @@
## 2025.9.1
### NOTE
- pnpm 10.16.0 が必要です
### General
- Enhance: 広告ごとにセンシティブフラグを設定できるようになりました
### Client
- Feat: アカウントのQRコードを表示・読み取りできるようになりました
- Feat: 動画を圧縮してアップロードできるようになりました
- Enhance: チャットの日本語名称がダイレクトメッセージに戻るとともに、ベータ版機能ではなくなりました
- Enhance: 画像編集にマスクエフェクト(塗りつぶし、ぼかし)を追加
- Enhance: ウォーターマークにアカウントのQRコードを追加できるように
- Enhance: 絵文字ピッカーのサイズをより大きくできるように
- Enhance: 時刻計算のための基準値を一か所で管理するようにし、パフォーマンスを向上
- Fix: iOSで、デバイスがダークモードだと初回読み込み時にエラーになる問題を修正
### Server
- Enhance: ユーザーIPを確実に取得できるために設定ファイルにFastifyOptions.trustProxyを追加しました
## 2025.9.0 ## 2025.9.0
### Client ### Client

View File

@ -3194,6 +3194,7 @@ _imageEffector:
mirror: "Mirror" mirror: "Mirror"
invert: "Invert Colors" invert: "Invert Colors"
grayscale: "Grayscale" grayscale: "Grayscale"
blur: "Blur"
colorAdjust: "Color Correction" colorAdjust: "Color Correction"
colorClamp: "Color Compression" colorClamp: "Color Compression"
colorClampAdvanced: "Color Compression (Advanced)" colorClampAdvanced: "Color Compression (Advanced)"
@ -3209,6 +3210,8 @@ _imageEffector:
angle: "Angle" angle: "Angle"
scale: "Size" scale: "Size"
size: "Size" size: "Size"
radius: "Radius"
samples: "Samples"
color: "Color" color: "Color"
opacity: "Opacity" opacity: "Opacity"
normalize: "Normalize" normalize: "Normalize"

236
locales/index.d.ts vendored
View File

@ -1030,6 +1030,10 @@ export interface Locale extends ILocale {
* *
*/ */
"processing": string; "processing": string;
/**
*
*/
"preprocessing": string;
/** /**
* *
*/ */
@ -1227,7 +1231,7 @@ export interface Locale extends ILocale {
*/ */
"noMoreHistory": string; "noMoreHistory": string;
/** /**
* *
*/ */
"startChat": string; "startChat": string;
/** /**
@ -1927,7 +1931,7 @@ export interface Locale extends ILocale {
*/ */
"markAsReadAllUnreadNotes": string; "markAsReadAllUnreadNotes": string;
/** /**
* *
*/ */
"markAsReadAllTalkMessages": string; "markAsReadAllTalkMessages": string;
/** /**
@ -5390,6 +5394,14 @@ export interface Locale extends ILocale {
* *
*/ */
"chat": string; "chat": string;
/**
*
*/
"directMessage": string;
/**
*
*/
"directMessage_short": string;
/** /**
* *
*/ */
@ -5501,6 +5513,14 @@ export interface Locale extends ILocale {
* <br> * <br>
*/ */
"defaultImageCompressionLevel_description": string; "defaultImageCompressionLevel_description": string;
/**
*
*/
"defaultCompressionLevel": string;
/**
* <br>
*/
"defaultCompressionLevel_description": string;
/** /**
* *
*/ */
@ -5529,6 +5549,40 @@ export interface Locale extends ILocale {
* *
*/ */
"thankYouForTestingBeta": string; "thankYouForTestingBeta": string;
/**
*
*/
"createUserSpecifiedNote": string;
"_compression": {
"_quality": {
/**
*
*/
"high": string;
/**
*
*/
"medium": string;
/**
*
*/
"low": string;
};
"_size": {
/**
*
*/
"large": string;
/**
*
*/
"medium": string;
/**
*
*/
"small": string;
};
};
"_order": { "_order": {
/** /**
* *
@ -5540,6 +5594,10 @@ export interface Locale extends ILocale {
"oldest": string; "oldest": string;
}; };
"_chat": { "_chat": {
/**
*
*/
"messages": string;
/** /**
* *
*/ */
@ -5549,36 +5607,36 @@ export interface Locale extends ILocale {
*/ */
"newMessage": string; "newMessage": string;
/** /**
* *
*/ */
"individualChat": string; "individualChat": string;
/** /**
* *
*/ */
"individualChat_description": string; "individualChat_description": string;
/** /**
* *
*/ */
"roomChat": string; "roomChat": string;
/** /**
* *
* *
*/ */
"roomChat_description": string; "roomChat_description": string;
/** /**
* *
*/ */
"createRoom": string; "createRoom": string;
/** /**
* *
*/ */
"inviteUserToChat": string; "inviteUserToChat": string;
/** /**
* *
*/ */
"yourRooms": string; "yourRooms": string;
/** /**
* *
*/ */
"joiningRooms": string; "joiningRooms": string;
/** /**
@ -5598,7 +5656,7 @@ export interface Locale extends ILocale {
*/ */
"noHistory": string; "noHistory": string;
/** /**
* *
*/ */
"noRooms": string; "noRooms": string;
/** /**
@ -5618,7 +5676,7 @@ export interface Locale extends ILocale {
*/ */
"ignore": string; "ignore": string;
/** /**
* 退 * 退
*/ */
"leave": string; "leave": string;
/** /**
@ -5642,35 +5700,35 @@ export interface Locale extends ILocale {
*/ */
"newline": string; "newline": string;
/** /**
* *
*/ */
"muteThisRoom": string; "muteThisRoom": string;
/** /**
* *
*/ */
"deleteRoom": string; "deleteRoom": string;
/** /**
* *
*/ */
"chatNotAvailableForThisAccountOrServer": string; "chatNotAvailableForThisAccountOrServer": string;
/** /**
* *
*/ */
"chatIsReadOnlyForThisAccountOrServer": string; "chatIsReadOnlyForThisAccountOrServer": string;
/** /**
* 使 * 使
*/ */
"chatNotAvailableInOtherAccount": string; "chatNotAvailableInOtherAccount": string;
/** /**
* *
*/ */
"cannotChatWithTheUser": string; "cannotChatWithTheUser": string;
/** /**
* 使 * 使
*/ */
"cannotChatWithTheUser_description": string; "cannotChatWithTheUser_description": string;
/** /**
* *
*/ */
"youAreNotAMemberOfThisRoomButInvited": string; "youAreNotAMemberOfThisRoomButInvited": string;
/** /**
@ -5678,31 +5736,31 @@ export interface Locale extends ILocale {
*/ */
"doYouAcceptInvitation": string; "doYouAcceptInvitation": string;
/** /**
* *
*/ */
"chatWithThisUser": string; "chatWithThisUser": string;
/** /**
* *
*/ */
"thisUserAllowsChatOnlyFromFollowers": string; "thisUserAllowsChatOnlyFromFollowers": string;
/** /**
* *
*/ */
"thisUserAllowsChatOnlyFromFollowing": string; "thisUserAllowsChatOnlyFromFollowing": string;
/** /**
* *
*/ */
"thisUserAllowsChatOnlyFromMutualFollowing": string; "thisUserAllowsChatOnlyFromMutualFollowing": string;
/** /**
* *
*/ */
"thisUserNotAllowedChatAnyone": string; "thisUserNotAllowedChatAnyone": string;
/** /**
* *
*/ */
"chatAllowedUsers": string; "chatAllowedUsers": string;
/** /**
* *
*/ */
"chatAllowedUsers_note": string; "chatAllowedUsers_note": string;
"_chatAllowedUsers": { "_chatAllowedUsers": {
@ -7856,7 +7914,7 @@ export interface Locale extends ILocale {
*/ */
"canImportUserLists": string; "canImportUserLists": string;
/** /**
* *
*/ */
"chatAvailability": string; "chatAvailability": string;
/** /**
@ -8706,7 +8764,7 @@ export interface Locale extends ILocale {
*/ */
"badge": string; "badge": string;
/** /**
* *
*/ */
"messageBg": string; "messageBg": string;
/** /**
@ -8733,7 +8791,7 @@ export interface Locale extends ILocale {
*/ */
"reaction": string; "reaction": string;
/** /**
* *
*/ */
"chatMessage": string; "chatMessage": string;
}; };
@ -9017,11 +9075,11 @@ export interface Locale extends ILocale {
*/ */
"write:following": string; "write:following": string;
/** /**
* *
*/ */
"read:messaging": string; "read:messaging": string;
/** /**
* *
*/ */
"write:messaging": string; "write:messaging": string;
/** /**
@ -9313,11 +9371,11 @@ export interface Locale extends ILocale {
*/ */
"write:report-abuse": string; "write:report-abuse": string;
/** /**
* *
*/ */
"write:chat": string; "write:chat": string;
/** /**
* *
*/ */
"read:chat": string; "read:chat": string;
}; };
@ -9543,7 +9601,7 @@ export interface Locale extends ILocale {
*/ */
"birthdayFollowings": string; "birthdayFollowings": string;
/** /**
* *
*/ */
"chat": string; "chat": string;
}; };
@ -10283,7 +10341,7 @@ export interface Locale extends ILocale {
*/ */
"roleAssigned": string; "roleAssigned": string;
/** /**
* *
*/ */
"chatRoomInvitationReceived": string; "chatRoomInvitationReceived": string;
/** /**
@ -10396,7 +10454,7 @@ export interface Locale extends ILocale {
*/ */
"roleAssigned": string; "roleAssigned": string;
/** /**
* *
*/ */
"chatRoomInvitationReceived": string; "chatRoomInvitationReceived": string;
/** /**
@ -10578,7 +10636,7 @@ export interface Locale extends ILocale {
*/ */
"roleTimeline": string; "roleTimeline": string;
/** /**
* *
*/ */
"chat": string; "chat": string;
}; };
@ -10945,7 +11003,7 @@ export interface Locale extends ILocale {
*/ */
"deleteGalleryPost": string; "deleteGalleryPost": string;
/** /**
* *
*/ */
"deleteChatRoom": string; "deleteChatRoom": string;
/** /**
@ -12219,10 +12277,18 @@ export interface Locale extends ILocale {
* *
*/ */
"text": string; "text": string;
/**
*
*/
"qr": string;
/** /**
* *
*/ */
"position": string; "position": string;
/**
*
*/
"margin": string;
/** /**
* *
*/ */
@ -12279,6 +12345,10 @@ export interface Locale extends ILocale {
* *
*/ */
"polkadotSubDotDivisions": string; "polkadotSubDotDivisions": string;
/**
* URLになります
*/
"leaveBlankToAccountUrl": string;
}; };
"_imageEffector": { "_imageEffector": {
/** /**
@ -12318,6 +12388,10 @@ export interface Locale extends ILocale {
* *
*/ */
"grayscale": string; "grayscale": string;
/**
*
*/
"blur": string;
/** /**
* 調 * 調
*/ */
@ -12362,6 +12436,10 @@ export interface Locale extends ILocale {
* *
*/ */
"tearing": string; "tearing": string;
/**
*
*/
"fill": string;
}; };
"_fxProps": { "_fxProps": {
/** /**
@ -12376,6 +12454,18 @@ export interface Locale extends ILocale {
* *
*/ */
"size": string; "size": string;
/**
*
*/
"radius": string;
/**
*
*/
"samples": string;
/**
*
*/
"offset": string;
/** /**
* *
*/ */
@ -12488,6 +12578,10 @@ export interface Locale extends ILocale {
* *
*/ */
"zoomLinesBlack": string; "zoomLinesBlack": string;
/**
*
*/
"circle": string;
}; };
}; };
/** /**
@ -12548,6 +12642,68 @@ export interface Locale extends ILocale {
*/ */
"listDrafts": string; "listDrafts": string;
}; };
/**
*
*/
"qr": string;
"_qr": {
/**
*
*/
"showTabTitle": string;
/**
*
*/
"readTabTitle": string;
/**
* {name} {acct}
*/
"shareTitle": ParameterizedString<"name" | "acct">;
/**
* Fediverseで私をフォローしてください
*/
"shareText": string;
/**
*
*/
"chooseCamera": string;
/**
*
*/
"cannotToggleFlash": string;
/**
*
*/
"turnOnFlash": string;
/**
*
*/
"turnOffFlash": string;
/**
*
*/
"startQr": string;
/**
*
*/
"stopQr": string;
/**
* QRコードが見つかりません
*/
"noQrCodeFound": string;
/**
*
*/
"scanFile": string;
/**
*
*/
"raw": string;
/**
* MFM
*/
"mfm": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View File

@ -253,6 +253,7 @@ noteDeleteConfirm: "このノートを削除しますか?"
pinLimitExceeded: "これ以上ピン留めできません" pinLimitExceeded: "これ以上ピン留めできません"
done: "完了" done: "完了"
processing: "処理中" processing: "処理中"
preprocessing: "準備中"
preview: "プレビュー" preview: "プレビュー"
default: "デフォルト" default: "デフォルト"
defaultValueIs: "デフォルト: {value}" defaultValueIs: "デフォルト: {value}"
@ -302,7 +303,7 @@ uploadNFiles: "{n}個のファイルをアップロード"
explore: "みつける" explore: "みつける"
messageRead: "既読" messageRead: "既読"
noMoreHistory: "これより過去の履歴はありません" noMoreHistory: "これより過去の履歴はありません"
startChat: "チャットを始める" startChat: "メッセージを送る"
nUsersRead: "{n}人が読みました" nUsersRead: "{n}人が読みました"
agreeTo: "{0}に同意" agreeTo: "{0}に同意"
agree: "同意する" agree: "同意する"
@ -477,7 +478,7 @@ notFoundDescription: "指定されたURLに該当するページはありませ
uploadFolder: "既定アップロード先" uploadFolder: "既定アップロード先"
markAsReadAllNotifications: "すべての通知を既読にする" markAsReadAllNotifications: "すべての通知を既読にする"
markAsReadAllUnreadNotes: "すべての投稿を既読にする" markAsReadAllUnreadNotes: "すべての投稿を既読にする"
markAsReadAllTalkMessages: "すべてのチャットを既読にする" markAsReadAllTalkMessages: "すべてのダイレクトメッセージを既読にする"
help: "ヘルプ" help: "ヘルプ"
inputMessageHere: "ここにメッセージを入力" inputMessageHere: "ここにメッセージを入力"
close: "閉じる" close: "閉じる"
@ -1343,6 +1344,8 @@ postForm: "投稿フォーム"
textCount: "文字数" textCount: "文字数"
information: "情報" information: "情報"
chat: "チャット" chat: "チャット"
directMessage: "ダイレクトメッセージ"
directMessage_short: "メッセージ"
migrateOldSettings: "旧設定情報を移行" migrateOldSettings: "旧設定情報を移行"
migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。" migrateOldSettings_description: "通常これは自動で行われていますが、何らかの理由により上手く移行されなかった場合は手動で移行処理をトリガーできます。現在の設定情報は上書きされます。"
compress: "圧縮" compress: "圧縮"
@ -1370,6 +1373,8 @@ redisplayAllTips: "全ての「ヒントとコツ」を再表示"
hideAllTips: "全ての「ヒントとコツ」を非表示" hideAllTips: "全ての「ヒントとコツ」を非表示"
defaultImageCompressionLevel: "デフォルトの画像圧縮度" defaultImageCompressionLevel: "デフォルトの画像圧縮度"
defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。" defaultImageCompressionLevel_description: "低くすると画質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、画質は低下します。"
defaultCompressionLevel: "デフォルトの圧縮度"
defaultCompressionLevel_description: "低くすると品質を保てますが、ファイルサイズは増加します。<br>高くするとファイルサイズを減らせますが、品質は低下します。"
inMinutes: "分" inMinutes: "分"
inDays: "日" inDays: "日"
safeModeEnabled: "セーフモードが有効です" safeModeEnabled: "セーフモードが有効です"
@ -1377,53 +1382,65 @@ pluginsAreDisabledBecauseSafeMode: "セーフモードが有効なため、プ
customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。" customCssIsDisabledBecauseSafeMode: "セーフモードが有効なため、カスタムCSSは適用されていません。"
themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。" themeIsDefaultBecauseSafeMode: "セーフモードが有効な間はデフォルトのテーマが使用されます。セーフモードをオフにすると元に戻ります。"
thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!" thankYouForTestingBeta: "ベータ版の検証にご協力いただきありがとうございます!"
createUserSpecifiedNote: "ユーザー指定ノートを作成"
_compression:
_quality:
high: "高品質"
medium: "中品質"
low: "低品質"
_size:
large: "サイズ大"
medium: "サイズ中"
small: "サイズ小"
_order: _order:
newest: "新しい順" newest: "新しい順"
oldest: "古い順" oldest: "古い順"
_chat: _chat:
messages: "メッセージ"
noMessagesYet: "まだメッセージはありません" noMessagesYet: "まだメッセージはありません"
newMessage: "新しいメッセージ" newMessage: "新しいメッセージ"
individualChat: "個人チャット" individualChat: "個"
individualChat_description: "特定ユーザーとの一対一のチャットができます。" individualChat_description: "特定ユーザーと個別にメッセージのやりとりができます。"
roomChat: "ルームチャット" roomChat: "グループ"
roomChat_description: "複数人でのチャットができます。\nまた、個人チャットを許可していないユーザーとでも、相手が受け入れればチャットができます。" roomChat_description: "複数人でメッセージのやりとりができます。\nまた、個別のメッセージを許可していないユーザーとでも、相手が受け入れればやりとりできます。"
createRoom: "ルームを作成" createRoom: "グループを作成"
inviteUserToChat: "ユーザーを招待してチャットを始めましょう" inviteUserToChat: "ユーザーを招待してメッセージを送信しましょう"
yourRooms: "作成したルーム" yourRooms: "作成したグループ"
joiningRooms: "参加中のルーム" joiningRooms: "参加中のグループ"
invitations: "招待" invitations: "招待"
noInvitations: "招待はありません" noInvitations: "招待はありません"
history: "履歴" history: "履歴"
noHistory: "履歴はありません" noHistory: "履歴はありません"
noRooms: "ルームはありません" noRooms: "グループはありません"
inviteUser: "ユーザーを招待" inviteUser: "ユーザーを招待"
sentInvitations: "送信した招待" sentInvitations: "送信した招待"
join: "参加" join: "参加"
ignore: "無視" ignore: "無視"
leave: "ルームから退出" leave: "グループから退出"
members: "メンバー" members: "メンバー"
searchMessages: "メッセージを検索" searchMessages: "メッセージを検索"
home: "ホーム" home: "ホーム"
send: "送信" send: "送信"
newline: "改行" newline: "改行"
muteThisRoom: "このルームをミュート" muteThisRoom: "このグループをミュート"
deleteRoom: "ルームを削除" deleteRoom: "グループを削除"
chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは有効化されていません。" chatNotAvailableForThisAccountOrServer: "このサーバー、またはこのアカウントでダイレクトメッセージは有効化されていません。"
chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでチャットは読み取り専用となっています。新たに書き込んだり、チャットルームを作成・参加したりすることはできません。" chatIsReadOnlyForThisAccountOrServer: "このサーバー、またはこのアカウントでダイレクトメッセージは読み取り専用となっています。新たに書き込んだり、グループを作成・参加したりすることはできません。"
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えない状態になっています。" chatNotAvailableInOtherAccount: "相手のアカウントでダイレクトメッセージが使えない状態になっています。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できません" cannotChatWithTheUser: "このユーザーとのダイレクトメッセージを開始できません"
cannotChatWithTheUser_description: "チャットが使えない状態になっているか、相手がチャットを開放していません。" cannotChatWithTheUser_description: "ダイレクトメッセージが使えない状態になっているか、相手がダイレクトメッセージを開放していません。"
youAreNotAMemberOfThisRoomButInvited: "あなたはこのルームの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。" youAreNotAMemberOfThisRoomButInvited: "あなたはこのグループの参加者ではありませんが、招待が届いています。参加するには、招待を承認してください。"
doYouAcceptInvitation: "招待を承認しますか?" doYouAcceptInvitation: "招待を承認しますか?"
chatWithThisUser: "チャットする" chatWithThisUser: "ダイレクトメッセージ"
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみチャットを受け付けています。" thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのみメッセージを受け付けています。"
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみチャットを受け付けています。" thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしているユーザーからのみメッセージを受け付けています。"
thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみチャットを受け付けています。" thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのみメッセージを受け付けています。"
thisUserNotAllowedChatAnyone: "このユーザーは誰からもチャットを受け付けていません。" thisUserNotAllowedChatAnyone: "このユーザーは誰からもメッセージを受け付けていません。"
chatAllowedUsers: "チャットを許可する相手" chatAllowedUsers: "メッセージを許可する相手"
chatAllowedUsers_note: "自分からチャットメッセージを送った相手とはこの設定に関わらずチャットが可能です。" chatAllowedUsers_note: "自分からメッセージを送った相手とはこの設定に関わらずメッセージの送受信が可能です。"
_chatAllowedUsers: _chatAllowedUsers:
everyone: "誰でも" everyone: "誰でも"
followers: "自分のフォロワーのみ" followers: "自分のフォロワーのみ"
@ -2034,7 +2051,7 @@ _role:
canImportFollowing: "フォローのインポートを許可" canImportFollowing: "フォローのインポートを許可"
canImportMuting: "ミュートのインポートを許可" canImportMuting: "ミュートのインポートを許可"
canImportUserLists: "リストのインポートを許可" canImportUserLists: "リストのインポートを許可"
chatAvailability: "チャットを許可" chatAvailability: "ダイレクトメッセージを許可"
uploadableFileTypes: "アップロード可能なファイル種別" uploadableFileTypes: "アップロード可能なファイル種別"
uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)" uploadableFileTypes_caption: "MIMEタイプを指定します。改行で区切って複数指定できるほか、アスタリスク(*)でワイルドカード指定できます。(例: image/*)"
uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。" uploadableFileTypes_caption2: "ファイルによっては種別を判定できないことがあります。そのようなファイルを許可する場合は {x} を指定に追加してください。"
@ -2281,7 +2298,7 @@ _theme:
buttonHoverBg: "ボタンの背景 (ホバー)" buttonHoverBg: "ボタンの背景 (ホバー)"
inputBorder: "入力ボックスの縁取り" inputBorder: "入力ボックスの縁取り"
badge: "バッジ" badge: "バッジ"
messageBg: "チャットの背景" messageBg: "メッセージの背景"
fgHighlighted: "強調された文字" fgHighlighted: "強調された文字"
_sfx: _sfx:
@ -2289,7 +2306,7 @@ _sfx:
noteMy: "ノート(自分)" noteMy: "ノート(自分)"
notification: "通知" notification: "通知"
reaction: "リアクション選択時" reaction: "リアクション選択時"
chatMessage: "チャットのメッセージ" chatMessage: "ダイレクトメッセージ"
_soundSettings: _soundSettings:
driveFile: "ドライブの音声を使用" driveFile: "ドライブの音声を使用"
@ -2369,8 +2386,8 @@ _permissions:
"write:favorites": "お気に入りを操作する" "write:favorites": "お気に入りを操作する"
"read:following": "フォローの情報を見る" "read:following": "フォローの情報を見る"
"write:following": "フォロー・フォロー解除する" "write:following": "フォロー・フォロー解除する"
"read:messaging": "チャットを見る" "read:messaging": "ダイレクトメッセージを見る"
"write:messaging": "チャットを操作する" "write:messaging": "ダイレクトメッセージを操作する"
"read:mutes": "ミュートを見る" "read:mutes": "ミュートを見る"
"write:mutes": "ミュートを操作する" "write:mutes": "ミュートを操作する"
"write:notes": "ノートを作成・削除する" "write:notes": "ノートを作成・削除する"
@ -2443,8 +2460,8 @@ _permissions:
"read:clip-favorite": "クリップのいいねを見る" "read:clip-favorite": "クリップのいいねを見る"
"read:federation": "連合に関する情報を取得する" "read:federation": "連合に関する情報を取得する"
"write:report-abuse": "違反を報告する" "write:report-abuse": "違反を報告する"
"write:chat": "チャットを操作する" "write:chat": "ダイレクトメッセージを操作する"
"read:chat": "チャットを閲覧する" "read:chat": "ダイレクトメッセージを閲覧する"
_auth: _auth:
shareAccessTitle: "アプリへのアクセス許可" shareAccessTitle: "アプリへのアクセス許可"
@ -2507,7 +2524,7 @@ _widgets:
chooseList: "リストを選択" chooseList: "リストを選択"
clicker: "クリッカー" clicker: "クリッカー"
birthdayFollowings: "今日誕生日のユーザー" birthdayFollowings: "今日誕生日のユーザー"
chat: "チャット" chat: "ダイレクトメッセージ"
_cw: _cw:
hide: "隠す" hide: "隠す"
@ -2714,7 +2731,7 @@ _notification:
newNote: "新しい投稿" newNote: "新しい投稿"
unreadAntennaNote: "アンテナ {name}" unreadAntennaNote: "アンテナ {name}"
roleAssigned: "ロールが付与されました" roleAssigned: "ロールが付与されました"
chatRoomInvitationReceived: "チャットルームへ招待されました" chatRoomInvitationReceived: "ダイレクトメッセージのグループへ招待されました"
emptyPushNotificationMessage: "プッシュ通知の更新をしました" emptyPushNotificationMessage: "プッシュ通知の更新をしました"
achievementEarned: "実績を獲得" achievementEarned: "実績を獲得"
testNotification: "通知テスト" testNotification: "通知テスト"
@ -2744,7 +2761,7 @@ _notification:
receiveFollowRequest: "フォロー申請を受け取った" receiveFollowRequest: "フォロー申請を受け取った"
followRequestAccepted: "フォローが受理された" followRequestAccepted: "フォローが受理された"
roleAssigned: "ロールが付与された" roleAssigned: "ロールが付与された"
chatRoomInvitationReceived: "チャットルームへ招待された" chatRoomInvitationReceived: "ダイレクトメッセージのグループへ招待された"
achievementEarned: "実績の獲得" achievementEarned: "実績の獲得"
exportCompleted: "エクスポートが完了した" exportCompleted: "エクスポートが完了した"
login: "ログイン" login: "ログイン"
@ -2794,7 +2811,7 @@ _deck:
mentions: "メンション" mentions: "メンション"
direct: "指名" direct: "指名"
roleTimeline: "ロールタイムライン" roleTimeline: "ロールタイムライン"
chat: "チャット" chat: "ダイレクトメッセージ"
_dialog: _dialog:
charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}"
@ -2897,7 +2914,7 @@ _moderationLogTypes:
deletePage: "ページを削除" deletePage: "ページを削除"
deleteFlash: "Playを削除" deleteFlash: "Playを削除"
deleteGalleryPost: "ギャラリーの投稿を削除" deleteGalleryPost: "ギャラリーの投稿を削除"
deleteChatRoom: "チャットルームを削除" deleteChatRoom: "ダイレクトメッセージのグループを削除"
updateProxyAccountDescription: "プロキシアカウントの説明を更新" updateProxyAccountDescription: "プロキシアカウントの説明を更新"
_fileViewer: _fileViewer:
@ -3271,7 +3288,9 @@ _watermarkEditor:
opacity: "不透明度" opacity: "不透明度"
scale: "サイズ" scale: "サイズ"
text: "テキスト" text: "テキスト"
qr: "二次元コード"
position: "位置" position: "位置"
margin: "マージン"
type: "タイプ" type: "タイプ"
image: "画像" image: "画像"
advanced: "高度" advanced: "高度"
@ -3286,6 +3305,7 @@ _watermarkEditor:
polkadotSubDotOpacity: "サブドットの不透明度" polkadotSubDotOpacity: "サブドットの不透明度"
polkadotSubDotRadius: "サブドットの大きさ" polkadotSubDotRadius: "サブドットの大きさ"
polkadotSubDotDivisions: "サブドットの数" polkadotSubDotDivisions: "サブドットの数"
leaveBlankToAccountUrl: "空欄にするとアカウントのURLになります"
_imageEffector: _imageEffector:
title: "エフェクト" title: "エフェクト"
@ -3299,6 +3319,7 @@ _imageEffector:
mirror: "ミラー" mirror: "ミラー"
invert: "色の反転" invert: "色の反転"
grayscale: "白黒" grayscale: "白黒"
blur: "ぼかし"
colorAdjust: "色調補正" colorAdjust: "色調補正"
colorClamp: "色の圧縮" colorClamp: "色の圧縮"
colorClampAdvanced: "色の圧縮(高度)" colorClampAdvanced: "色の圧縮(高度)"
@ -3310,11 +3331,15 @@ _imageEffector:
checker: "チェッカー" checker: "チェッカー"
blockNoise: "ブロックノイズ" blockNoise: "ブロックノイズ"
tearing: "ティアリング" tearing: "ティアリング"
fill: "塗りつぶし"
_fxProps: _fxProps:
angle: "角度" angle: "角度"
scale: "サイズ" scale: "サイズ"
size: "サイズ" size: "サイズ"
radius: "半径"
samples: "サンプル数"
offset: "位置"
color: "色" color: "色"
opacity: "不透明度" opacity: "不透明度"
normalize: "正規化" normalize: "正規化"
@ -3343,6 +3368,7 @@ _imageEffector:
zoomLinesThreshold: "集中線の幅" zoomLinesThreshold: "集中線の幅"
zoomLinesMaskSize: "中心径" zoomLinesMaskSize: "中心径"
zoomLinesBlack: "黒色にする" zoomLinesBlack: "黒色にする"
circle: "円形"
drafts: "下書き" drafts: "下書き"
_drafts: _drafts:
@ -3359,3 +3385,20 @@ _drafts:
restoreFromDraft: "下書きから復元" restoreFromDraft: "下書きから復元"
restore: "復元" restore: "復元"
listDrafts: "下書き一覧" listDrafts: "下書き一覧"
qr: "二次元コード"
_qr:
showTabTitle: "表示"
readTabTitle: "読み取る"
shareTitle: "{name} {acct}"
shareText: "Fediverseで私をフォローしてください"
chooseCamera: "カメラを選択"
cannotToggleFlash: "ライト選択不可"
turnOnFlash: "ライトをオンにする"
turnOffFlash: "ライトをオフにする"
startQr: "コードリーダーを再開"
stopQr: "コードリーダーを停止"
noQrCodeFound: "QRコードが見つかりません"
scanFile: "端末の画像をスキャン"
raw: "テキスト"
mfm: "MFM"

View File

@ -1,12 +1,12 @@
{ {
"name": "misskey", "name": "misskey",
"version": "2025.9.0", "version": "2025.9.1-alpha.1",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/misskey-dev/misskey.git" "url": "https://github.com/misskey-dev/misskey.git"
}, },
"packageManager": "pnpm@10.15.1", "packageManager": "pnpm@10.16.0",
"workspaces": [ "workspaces": [
"packages/frontend-shared", "packages/frontend-shared",
"packages/frontend", "packages/frontend",
@ -76,7 +76,7 @@
"eslint": "9.35.0", "eslint": "9.35.0",
"globals": "16.3.0", "globals": "16.3.0",
"ncp": "2.0.0", "ncp": "2.0.0",
"pnpm": "10.15.1", "pnpm": "10.16.0",
"start-server-and-test": "2.1.0" "start-server-and-test": "2.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {

View File

@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SensitiveAd1757823175259 {
name = 'SensitiveAd1757823175259'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" ADD "isSensitive" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "isSensitive"`);
}
}

View File

@ -7,6 +7,7 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path'; import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml'; import * as yaml from 'js-yaml';
import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node'; import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue'; import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis'; import type { RedisOptions } from 'ioredis';
@ -27,6 +28,7 @@ type Source = {
url?: string; url?: string;
port?: number; port?: number;
socket?: string; socket?: string;
trustProxy?: FastifyServerOptions['trustProxy'];
chmodSocket?: string; chmodSocket?: string;
disableHsts?: boolean; disableHsts?: boolean;
db: { db: {
@ -118,6 +120,7 @@ export type Config = {
url: string; url: string;
port: number; port: number;
socket: string | undefined; socket: string | undefined;
trustProxy: FastifyServerOptions['trustProxy'];
chmodSocket: string | undefined; chmodSocket: string | undefined;
disableHsts: boolean | undefined; disableHsts: boolean | undefined;
db: { db: {
@ -266,6 +269,7 @@ export function loadConfig(): Config {
url: url.origin, url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10), port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket, socket: config.socket,
trustProxy: config.trustProxy,
chmodSocket: config.chmodSocket, chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts, disableHsts: config.disableHsts,
host, host,

View File

@ -101,14 +101,15 @@ export const DEFAULT_POLICIES: RolePolicies = {
userEachUserListsLimit: 50, userEachUserListsLimit: 50,
rateLimitFactor: 1, rateLimitFactor: 1,
avatarDecorationLimit: 1, avatarDecorationLimit: 1,
canImportAntennas: true, canImportAntennas: false,
canImportBlocking: true, canImportBlocking: false,
canImportFollowing: true, canImportFollowing: false,
canImportMuting: true, canImportMuting: false,
canImportUserLists: true, canImportUserLists: false,
chatAvailability: 'available', chatAvailability: 'available',
uploadableFileTypes: [ uploadableFileTypes: [
'text/plain', 'text/plain',
'text/csv',
'application/json', 'application/json',
'image/*', 'image/*',
'video/*', 'video/*',

View File

@ -117,6 +117,7 @@ export class MetaEntityService {
ratio: ad.ratio, ratio: ad.ratio,
imageUrl: ad.imageUrl, imageUrl: ad.imageUrl,
dayOfWeek: ad.dayOfWeek, dayOfWeek: ad.dayOfWeek,
isSensitive: ad.isSensitive ? true : undefined,
})), })),
notesPerOneAd: instance.notesPerOneAd, notesPerOneAd: instance.notesPerOneAd,
enableEmail: instance.enableEmail, enableEmail: instance.enableEmail,

View File

@ -54,10 +54,17 @@ export class MiAd {
length: 8192, nullable: false, length: 8192, nullable: false,
}) })
public memo: string; public memo: string;
@Column('integer', { @Column('integer', {
default: 0, nullable: false, default: 0, nullable: false,
}) })
public dayOfWeek: number; public dayOfWeek: number;
@Column('boolean', {
default: false,
})
public isSensitive: boolean;
constructor(data: Partial<MiAd>) { constructor(data: Partial<MiAd>) {
if (data == null) return; if (data == null) return;

View File

@ -60,5 +60,10 @@ export const packedAdSchema = {
optional: false, optional: false,
nullable: false, nullable: false,
}, },
isSensitive: {
type: 'boolean',
optional: false,
nullable: false,
},
}, },
} as const; } as const;

View File

@ -195,6 +195,10 @@ export const packedMetaLiteSchema = {
type: 'integer', type: 'integer',
optional: false, nullable: false, optional: false, nullable: false,
}, },
isSensitive: {
type: 'boolean',
optional: true, nullable: false,
},
}, },
}, },
}, },

View File

@ -75,7 +75,7 @@ export class ServerService implements OnApplicationShutdown {
@bindThis @bindThis
public async launch(): Promise<void> { public async launch(): Promise<void> {
const fastify = Fastify({ const fastify = Fastify({
trustProxy: true, trustProxy: this.config.trustProxy ?? true,
logger: false, logger: false,
}); });
this.#fastify = fastify; this.#fastify = fastify;

View File

@ -34,13 +34,22 @@ export const meta = {
res: { res: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
ref: 'MeDetailed', allOf: [
properties: { {
token: { type: 'object',
type: 'string', ref: 'MeDetailed',
optional: false, nullable: false,
}, },
}, {
type: 'object',
optional: false, nullable: false,
properties: {
token: {
type: 'string',
optional: false, nullable: false,
},
},
}
],
}, },
} as const; } as const;

View File

@ -36,6 +36,7 @@ export const paramDef = {
startsAt: { type: 'integer' }, startsAt: { type: 'integer' },
imageUrl: { type: 'string', minLength: 1 }, imageUrl: { type: 'string', minLength: 1 },
dayOfWeek: { type: 'integer' }, dayOfWeek: { type: 'integer' },
isSensitive: { type: 'boolean' },
}, },
required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'], required: ['url', 'memo', 'place', 'priority', 'ratio', 'expiresAt', 'startsAt', 'imageUrl', 'dayOfWeek'],
} as const; } as const;
@ -55,6 +56,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
expiresAt: new Date(ps.expiresAt), expiresAt: new Date(ps.expiresAt),
startsAt: new Date(ps.startsAt), startsAt: new Date(ps.startsAt),
dayOfWeek: ps.dayOfWeek, dayOfWeek: ps.dayOfWeek,
isSensitive: ps.isSensitive,
url: ps.url, url: ps.url,
imageUrl: ps.imageUrl, imageUrl: ps.imageUrl,
priority: ps.priority, priority: ps.priority,
@ -73,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
expiresAt: ad.expiresAt.toISOString(), expiresAt: ad.expiresAt.toISOString(),
startsAt: ad.startsAt.toISOString(), startsAt: ad.startsAt.toISOString(),
dayOfWeek: ad.dayOfWeek, dayOfWeek: ad.dayOfWeek,
isSensitive: ad.isSensitive,
url: ad.url, url: ad.url,
imageUrl: ad.imageUrl, imageUrl: ad.imageUrl,
priority: ad.priority, priority: ad.priority,

View File

@ -63,6 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
expiresAt: ad.expiresAt.toISOString(), expiresAt: ad.expiresAt.toISOString(),
startsAt: ad.startsAt.toISOString(), startsAt: ad.startsAt.toISOString(),
dayOfWeek: ad.dayOfWeek, dayOfWeek: ad.dayOfWeek,
isSensitive: ad.isSensitive,
url: ad.url, url: ad.url,
imageUrl: ad.imageUrl, imageUrl: ad.imageUrl,
memo: ad.memo, memo: ad.memo,

View File

@ -39,6 +39,7 @@ export const paramDef = {
expiresAt: { type: 'integer' }, expiresAt: { type: 'integer' },
startsAt: { type: 'integer' }, startsAt: { type: 'integer' },
dayOfWeek: { type: 'integer' }, dayOfWeek: { type: 'integer' },
isSensitive: { type: 'boolean' },
}, },
required: ['id'], required: ['id'],
} as const; } as const;
@ -66,6 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined, expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : undefined,
startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined, startsAt: ps.startsAt ? new Date(ps.startsAt) : undefined,
dayOfWeek: ps.dayOfWeek, dayOfWeek: ps.dayOfWeek,
isSensitive: ps.isSensitive,
}); });
const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id }); const updatedAd = await this.adsRepository.findOneByOrFail({ id: ad.id });

View File

@ -18,9 +18,9 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { ApiError } from '../../error.js';
import { IdentifiableError } from '@/misc/identifiable-error.js'; import { IdentifiableError } from '@/misc/identifiable-error.js';
import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js'; import { FetchAllowSoftFailMask } from '@/core/activitypub/misc/check-against-url.js';
import { ApiError } from '../../error.js';
export const meta = { export const meta = {
tags: ['federation'], tags: ['federation'],

View File

@ -29,10 +29,16 @@ export const meta = {
id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d', id: '24fcbfc6-2e37-42b6-8388-c29b3861a08d',
}, },
signinRequired: { contentRestrictedByUser: {
message: 'Signin required.', message: 'Content restricted by user. Please sign in to view.',
code: 'SIGNIN_REQUIRED', code: 'CONTENT_RESTRICTED_BY_USER',
id: '8e75455b-738c-471d-9f80-62693f33372e', id: 'fbcc002d-37d9-4944-a6b0-d9e29f2d33ab',
},
contentRestrictedByServer: {
message: 'Content restricted by server settings. Please sign in to view.',
code: 'CONTENT_RESTRICTED_BY_SERVER',
id: '145f88d2-b03d-4087-8143-a78928883c4b',
}, },
}, },
} as const; } as const;
@ -61,15 +67,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}); });
if (note.user!.requireSigninToViewContents && me == null) { if (note.user!.requireSigninToViewContents && me == null) {
throw new ApiError(meta.errors.signinRequired); throw new ApiError(meta.errors.contentRestrictedByUser);
} }
if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) { if (this.serverSettings.ugcVisibilityForVisitor === 'none' && me == null) {
throw new ApiError(meta.errors.signinRequired); throw new ApiError(meta.errors.contentRestrictedByServer);
} }
if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) { if (this.serverSettings.ugcVisibilityForVisitor === 'local' && note.userHost != null && me == null) {
throw new ApiError(meta.errors.signinRequired); throw new ApiError(meta.errors.contentRestrictedByServer);
} }
return await this.noteEntityService.pack(note, me, { return await this.noteEntityService.pack(note, me, {

View File

@ -22,17 +22,26 @@ export const meta = {
res: { res: {
type: 'object', type: 'object',
optional: false, nullable: false, optional: false, nullable: false,
ref: 'UserList', allOf: [
properties: { {
likedCount: { type: 'object',
type: 'number', ref: 'UserList',
optional: true, nullable: false,
}, },
isLiked: { {
type: 'boolean', type: 'object',
optional: true, nullable: false, optional: false, nullable: false,
properties: {
likedCount: {
type: 'number',
optional: true, nullable: false,
},
isLiked: {
type: 'boolean',
optional: true, nullable: false,
},
},
}, },
}, ],
}, },
errors: { errors: {

View File

@ -13,7 +13,7 @@
}; };
window.onunhandledrejection = (e) => { window.onunhandledrejection = (e) => {
console.error(e); console.error(e);
renderError('SOMETHING_HAPPENED_IN_PROMISE', e); renderError('SOMETHING_HAPPENED_IN_PROMISE', e.reason || e);
}; };
let forceError = localStorage.getItem('forceError'); let forceError = localStorage.getItem('forceError');

View File

@ -68,7 +68,6 @@ async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse
return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => { return await client.request('admin/accounts/create', ADMIN_PARAMS).then(res => {
ADMIN_CACHE.set(host, { ADMIN_CACHE.set(host, {
id: res.id, id: res.id,
// @ts-expect-error FIXME: openapi-typescript generates incorrect response type for this endpoint, so ignore this
i: res.token, i: res.token,
}); });
return res as Misskey.entities.SignupResponse; return res as Misskey.entities.SignupResponse;

View File

@ -20,6 +20,6 @@
"dependencies": { "dependencies": {
"estree-walker": "3.0.3", "estree-walker": "3.0.3",
"magic-string": "0.30.17", "magic-string": "0.30.17",
"vite": "7.0.6" "vite": "7.0.7"
} }
} }

View File

@ -46,9 +46,71 @@ export default [
allowSingleExtends: true, allowSingleExtends: true,
}], }],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため // window ... グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため // e ... error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'], // close ... window.closeと衝突 or 紛らわしい
// open ... window.openと衝突 or 紛らわしい
// fetch ... window.fetchと衝突 or 紛らわしい
// location ... window.locationと衝突 or 紛らわしい
// document ... window.documentと衝突 or 紛らわしい
// history ... window.historyと衝突 or 紛らわしい
// scroll ... window.scrollと衝突 or 紛らわしい
// setTimeout ... window.setTimeoutと衝突 or 紛らわしい
// setInterval ... window.setIntervalと衝突 or 紛らわしい
// clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい
// clearInterval ... window.clearIntervalと衝突 or 紛らわしい
'id-denylist': ['error', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'],
'no-restricted-globals': [
'error',
{
'name': 'open',
'message': 'Use `window.open`.',
},
{
'name': 'close',
'message': 'Use `window.close`.',
},
{
'name': 'fetch',
'message': 'Use `window.fetch`.',
},
{
'name': 'location',
'message': 'Use `window.location`.',
},
{
'name': 'document',
'message': 'Use `window.document`.',
},
{
'name': 'history',
'message': 'Use `window.history`.',
},
{
'name': 'scroll',
'message': 'Use `window.scroll`.',
},
{
'name': 'setTimeout',
'message': 'Use `window.setTimeout`.',
},
{
'name': 'setInterval',
'message': 'Use `window.setInterval`.',
},
{
'name': 'clearTimeout',
'message': 'Use `window.clearTimeout`.',
},
{
'name': 'clearInterval',
'message': 'Use `window.clearInterval`.',
},
{
'name': 'name',
'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている',
},
],
'no-shadow': ['warn'], 'no-shadow': ['warn'],
'vue/attributes-order': ['error', { 'vue/attributes-order': ['error', {
alphabetical: false, alphabetical: false,

View File

@ -34,7 +34,7 @@
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.9.2", "typescript": "5.9.2",
"uuid": "11.1.0", "uuid": "11.1.0",
"vite": "7.1.4", "vite": "7.1.5",
"vue": "3.5.21" "vue": "3.5.21"
}, },
"devDependencies": { "devDependencies": {

View File

@ -33,7 +33,7 @@ import type { Theme } from '@/theme.js';
console.log('Misskey Embed'); console.log('Misskey Embed');
//#region Embedパラメータの取得・パース //#region Embedパラメータの取得・パース
const params = new URLSearchParams(location.search); const params = new URLSearchParams(window.location.search);
const embedParams = parseEmbedParams(params); const embedParams = parseEmbedParams(params);
if (_DEV_) console.log(embedParams); if (_DEV_) console.log(embedParams);
//#endregion //#endregion
@ -81,7 +81,7 @@ storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
//#endregion //#endregion
// サイズの制限 // サイズの制限
document.documentElement.style.maxWidth = '500px'; window.document.documentElement.style.maxWidth = '500px';
// iframeIdの設定 // iframeIdの設定
function setIframeIdHandler(event: MessageEvent) { function setIframeIdHandler(event: MessageEvent) {
@ -114,16 +114,16 @@ app.provide(DI.embedParams, embedParams);
const rootEl = ((): HTMLElement => { const rootEl = ((): HTMLElement => {
const MISSKEY_MOUNT_DIV_ID = 'misskey_app'; const MISSKEY_MOUNT_DIV_ID = 'misskey_app';
const currentRoot = document.getElementById(MISSKEY_MOUNT_DIV_ID); const currentRoot = window.document.getElementById(MISSKEY_MOUNT_DIV_ID);
if (currentRoot) { if (currentRoot) {
console.warn('multiple import detected'); console.warn('multiple import detected');
return currentRoot; return currentRoot;
} }
const root = document.createElement('div'); const root = window.document.createElement('div');
root.id = MISSKEY_MOUNT_DIV_ID; root.id = MISSKEY_MOUNT_DIV_ID;
document.body.appendChild(root); window.document.body.appendChild(root);
return root; return root;
})(); })();
@ -159,7 +159,7 @@ console.log(i18n.tsx._selfXssPrevention.description3({ link: 'https://misskey-hu
//#endregion //#endregion
function removeSplash() { function removeSplash() {
const splash = document.getElementById('splash'); const splash = window.document.getElementById('splash');
if (splash) { if (splash) {
splash.style.opacity = '0'; splash.style.opacity = '0';
splash.style.pointerEvents = 'none'; splash.style.pointerEvents = 'none';

View File

@ -19,7 +19,7 @@ import { extractAvgColorFromBlurhash } from '@@/js/extract-avg-color-from-blurha
const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => { const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resolve => {
// Web Worker // Web Worker
if (import.meta.env.MODE === 'test') { if (import.meta.env.MODE === 'test') {
const canvas = document.createElement('canvas'); const canvas = window.document.createElement('canvas');
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
resolve(canvas); resolve(canvas);
@ -34,7 +34,7 @@ const canvasPromise = new Promise<WorkerMultiDispatch | HTMLCanvasElement>(resol
); );
resolve(workers); resolve(workers);
} else { } else {
const canvas = document.createElement('canvas'); const canvas = window.document.createElement('canvas');
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
resolve(canvas); resolve(canvas);

View File

@ -29,7 +29,7 @@ const props = defineProps<{
// if no instance data is given, this is for the local instance // if no instance data is given, this is for the local instance
const instance = props.instance ?? { const instance = props.instance ?? {
name: serverMetadata.name, name: serverMetadata.name,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content, themeColor: (window.document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content,
}; };
const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico'); const faviconUrl = computed(() => props.instance ? mediaProxy.getProxiedImageUrlNullable(props.instance.faviconUrl, 'preview') : mediaProxy.getProxiedImageUrlNullable(serverMetadata.iconUrl, 'preview') ?? '/favicon.ico');

View File

@ -27,7 +27,7 @@ const canonical = props.host === localHost ? `@${props.username}` : `@${props.us
const url = `/${canonical}`; const url = `/${canonical}`;
const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--MI_THEME-mention')); const bg = tinycolor(getComputedStyle(window.document.documentElement).getPropertyValue('--MI_THEME-mention'));
bg.setAlpha(0.1); bg.setAlpha(0.1);
const bgCss = bg.toRgbString(); const bgCss = bg.toRgbString();
</script> </script>

View File

@ -134,7 +134,7 @@ const isBackTop = ref(false);
const empty = computed(() => items.value.size === 0); const empty = computed(() => items.value.size === 0);
const error = ref(false); const error = ref(false);
const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : document.body); const scrollableElement = computed(() => rootEl.value ? getScrollContainer(rootEl.value) : window.document.body);
const visibility = useDocumentVisibility(); const visibility = useDocumentVisibility();
@ -353,7 +353,7 @@ watch(visibility, () => {
BACKGROUND_PAUSE_WAIT_SEC * 1000); BACKGROUND_PAUSE_WAIT_SEC * 1000);
} else { // 'visible' } else { // 'visible'
if (timerForSetPause) { if (timerForSetPause) {
clearTimeout(timerForSetPause); window.clearTimeout(timerForSetPause);
timerForSetPause = null; timerForSetPause = null;
} else { } else {
isPausingUpdate = false; isPausingUpdate = false;
@ -447,11 +447,11 @@ onBeforeMount(() => {
init().then(() => { init().then(() => {
if (props.pagination.reversed) { if (props.pagination.reversed) {
nextTick(() => { nextTick(() => {
setTimeout(toBottom, 800); window.setTimeout(toBottom, 800);
// scrollToBottommoreFetching // scrollToBottommoreFetching
// more = true // more = true
setTimeout(() => { window.setTimeout(() => {
moreFetching.value = false; moreFetching.value = false;
}, 2000); }, 2000);
}); });
@ -461,11 +461,11 @@ onBeforeMount(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (timerForSetPause) { if (timerForSetPause) {
clearTimeout(timerForSetPause); window.clearTimeout(timerForSetPause);
timerForSetPause = null; timerForSetPause = null;
} }
if (preventAppearFetchMoreTimer.value) { if (preventAppearFetchMoreTimer.value) {
clearTimeout(preventAppearFetchMoreTimer.value); window.clearTimeout(preventAppearFetchMoreTimer.value);
preventAppearFetchMoreTimer.value = null; preventAppearFetchMoreTimer.value = null;
} }
scrollObserver.value?.disconnect(); scrollObserver.value?.disconnect();

View File

@ -4,7 +4,7 @@
*/ */
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
const providedContextEl = document.getElementById('misskey_embedCtx'); const providedContextEl = window.document.getElementById('misskey_embedCtx');
export type ServerContext = { export type ServerContext = {
clip?: Misskey.entities.Clip; clip?: Misskey.entities.Clip;

View File

@ -6,7 +6,7 @@
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/misskey-api.js'; import { misskeyApi } from '@/misskey-api.js';
const providedMetaEl = document.getElementById('misskey_meta'); const providedMetaEl = window.document.getElementById('misskey_meta');
const _serverMetadata: Misskey.entities.MetaDetailed | null = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null; const _serverMetadata: Misskey.entities.MetaDetailed | null = (providedMetaEl && providedMetaEl.textContent) ? JSON.parse(providedMetaEl.textContent) : null;

View File

@ -35,15 +35,15 @@ export function assertIsTheme(theme: Record<string, unknown>): theme is Theme {
export function applyTheme(theme: Theme, persist = true) { export function applyTheme(theme: Theme, persist = true) {
if (timeout) window.clearTimeout(timeout); if (timeout) window.clearTimeout(timeout);
document.documentElement.classList.add('_themeChanging_'); window.document.documentElement.classList.add('_themeChanging_');
timeout = window.setTimeout(() => { timeout = window.setTimeout(() => {
document.documentElement.classList.remove('_themeChanging_'); window.document.documentElement.classList.remove('_themeChanging_');
}, 1000); }, 1000);
const colorScheme = theme.base === 'dark' ? 'dark' : 'light'; const colorScheme = theme.base === 'dark' ? 'dark' : 'light';
document.documentElement.dataset.colorScheme = colorScheme; window.document.documentElement.dataset.colorScheme = colorScheme;
// Deep copy // Deep copy
const _theme = JSON.parse(JSON.stringify(theme)); const _theme = JSON.parse(JSON.stringify(theme));
@ -55,7 +55,7 @@ export function applyTheme(theme: Theme, persist = true) {
const props = compile(_theme); const props = compile(_theme);
for (const tag of document.head.children) { for (const tag of window.document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', props['htmlThemeColor']); tag.setAttribute('content', props['htmlThemeColor']);
break; break;
@ -63,7 +63,7 @@ export function applyTheme(theme: Theme, persist = true) {
} }
for (const [k, v] of Object.entries(props)) { for (const [k, v] of Object.entries(props)) {
document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString()); window.document.documentElement.style.setProperty(`--MI_THEME-${k}`, v.toString());
} }
// iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照 // iframeを正常に透過させるために、cssのcolor-schemeは `light dark;` 固定にしてある。style.scss参照

View File

@ -52,8 +52,8 @@ function safeURIDecode(str: string): string {
} }
} }
const page = location.pathname.split('/')[2]; const page = window.location.pathname.split('/')[2];
const contentId = safeURIDecode(location.pathname.split('/')[3]); const contentId = safeURIDecode(window.location.pathname.split('/')[3]);
if (_DEV_) console.log(page, contentId); if (_DEV_) console.log(page, contentId);
const embedParams = inject(DI.embedParams, defaultEmbedParams); const embedParams = inject(DI.embedParams, defaultEmbedParams);

View File

@ -64,6 +64,8 @@ function toBase62(n: number): string {
} }
export function getConfig(): UserConfig { export function getConfig(): UserConfig {
const localesHash = toBase62(hash(JSON.stringify(locales)));
return { return {
base: '/embed_vite/', base: '/embed_vite/',
@ -148,9 +150,9 @@ export function getConfig(): UserConfig {
// dependencies of i18n.ts // dependencies of i18n.ts
'config': ['@@/js/config.js'], 'config': ['@@/js/config.js'],
}, },
entryFileNames: 'scripts/[hash:8].js', entryFileNames: `scripts/${localesHash}-[hash:8].js`,
chunkFileNames: 'scripts/[hash:8].js', chunkFileNames: `scripts/${localesHash}-[hash:8].js`,
assetFileNames: 'assets/[hash:8][extname]', assetFileNames: `assets/${localesHash}-[hash:8][extname]`,
paths(id) { paths(id) {
for (const p of externalPackages) { for (const p of externalPackages) {
if (p.match.test(id)) { if (p.match.test(id)) {

View File

@ -51,9 +51,71 @@ export default [
allowSingleExtends: true, allowSingleExtends: true,
}], }],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'], 'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため // window ... グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため // e ... error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'], // close ... window.closeと衝突 or 紛らわしい
// open ... window.openと衝突 or 紛らわしい
// fetch ... window.fetchと衝突 or 紛らわしい
// location ... window.locationと衝突 or 紛らわしい
// document ... window.documentと衝突 or 紛らわしい
// history ... window.historyと衝突 or 紛らわしい
// scroll ... window.scrollと衝突 or 紛らわしい
// setTimeout ... window.setTimeoutと衝突 or 紛らわしい
// setInterval ... window.setIntervalと衝突 or 紛らわしい
// clearTimeout ... window.clearTimeoutと衝突 or 紛らわしい
// clearInterval ... window.clearIntervalと衝突 or 紛らわしい
'id-denylist': ['error', 'window', 'e', 'close', 'open', 'fetch', 'location', 'document', 'history', 'scroll', 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'],
'no-restricted-globals': [
'error',
{
'name': 'open',
'message': 'Use `window.open`.',
},
{
'name': 'close',
'message': 'Use `window.close`.',
},
{
'name': 'fetch',
'message': 'Use `window.fetch`.',
},
{
'name': 'location',
'message': 'Use `window.location`.',
},
{
'name': 'document',
'message': 'Use `window.document`.',
},
{
'name': 'history',
'message': 'Use `window.history`.',
},
{
'name': 'scroll',
'message': 'Use `window.scroll`.',
},
{
'name': 'setTimeout',
'message': 'Use `window.setTimeout`.',
},
{
'name': 'setInterval',
'message': 'Use `window.setInterval`.',
},
{
'name': 'clearTimeout',
'message': 'Use `window.clearTimeout`.',
},
{
'name': 'clearInterval',
'message': 'Use `window.clearInterval`.',
},
{
'name': 'name',
'message': 'Use `window.name`. もしくは name という変数名を定義し忘れている',
},
],
'no-shadow': ['warn'], 'no-shadow': ['warn'],
'vue/attributes-order': ['error', { 'vue/attributes-order': ['error', {
alphabetical: false, alphabetical: false,

View File

@ -4,15 +4,15 @@
*/ */
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href); const address = new URL(window.document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || window.location.href);
const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content; const siteName = window.document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
export const host = address.host; export const host = address.host;
export const hostname = address.hostname; export const hostname = address.hostname;
export const url = address.origin; export const url = address.origin;
export const port = address.port; export const port = address.port;
export const apiUrl = location.origin + '/api'; export const apiUrl = window.location.origin + '/api';
export const wsOrigin = location.origin; export const wsOrigin = window.location.origin;
export const lang = localStorage.getItem('lang') ?? 'en-US'; export const lang = localStorage.getItem('lang') ?? 'en-US';
export const langs = _LANGS_; export const langs = _LANGS_;
export const version = _VERSION_; export const version = _VERSION_;

View File

@ -51,7 +51,7 @@ export function onScrollTop(el: HTMLElement, cb: (topVisible: boolean) => unknow
// - toleranceの範囲内に収まる程度の微量なスクロールが発生した // - toleranceの範囲内に収まる程度の微量なスクロールが発生した
let prevTopVisible = firstTopVisible; let prevTopVisible = firstTopVisible;
const onScroll = () => { const onScroll = () => {
if (!document.body.contains(el)) return; if (!window.document.body.contains(el)) return;
const topVisible = isHeadVisible(el, tolerance); const topVisible = isHeadVisible(el, tolerance);
if (topVisible !== prevTopVisible) { if (topVisible !== prevTopVisible) {
@ -78,7 +78,7 @@ export function onScrollBottom(el: HTMLElement, cb: () => unknown, tolerance = 1
const containerOrWindow = container ?? window; const containerOrWindow = container ?? window;
const onScroll = () => { const onScroll = () => {
if (!document.body.contains(el)) return; if (!window.document.body.contains(el)) return;
if (isTailVisible(el, 1, container)) { if (isTailVisible(el, 1, container)) {
cb(); cb();
if (once) removeListener(); if (once) removeListener();
@ -145,8 +145,8 @@ export function isTailVisible(el: HTMLElement, tolerance = 1, container = getScr
// https://ja.javascript.info/size-and-scroll-window#ref-932 // https://ja.javascript.info/size-and-scroll-window#ref-932
export function getBodyScrollHeight() { export function getBodyScrollHeight() {
return Math.max( return Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight, window.document.body.scrollHeight, window.document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight, window.document.body.offsetHeight, window.document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight, window.document.body.clientHeight, window.document.documentElement.clientHeight,
); );
} }

View File

@ -7,18 +7,18 @@ import { onMounted, onUnmounted, ref } from 'vue';
import type { Ref } from 'vue'; import type { Ref } from 'vue';
export function useDocumentVisibility(): Ref<DocumentVisibilityState> { export function useDocumentVisibility(): Ref<DocumentVisibilityState> {
const visibility = ref(document.visibilityState); const visibility = ref(window.document.visibilityState);
const onChange = (): void => { const onChange = (): void => {
visibility.value = document.visibilityState; visibility.value = window.document.visibilityState;
}; };
onMounted(() => { onMounted(() => {
document.addEventListener('visibilitychange', onChange); window.document.addEventListener('visibilitychange', onChange);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('visibilitychange', onChange); window.document.removeEventListener('visibilitychange', onChange);
}); });
return visibility; return visibility;

View File

@ -57,12 +57,15 @@
"json5": "2.2.3", "json5": "2.2.3",
"magic-string": "0.30.18", "magic-string": "0.30.18",
"matter-js": "0.20.0", "matter-js": "0.20.0",
"mediabunny": "1.15.1",
"mfm-js": "0.25.0", "mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*", "misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*", "misskey-js": "workspace:*",
"misskey-reversi": "workspace:*", "misskey-reversi": "workspace:*",
"photoswipe": "5.4.4", "photoswipe": "5.4.4",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qr-code-styling": "1.9.2",
"qr-scanner": "1.4.2",
"rollup": "4.50.1", "rollup": "4.50.1",
"sanitize-html": "2.17.0", "sanitize-html": "2.17.0",
"sass": "1.92.1", "sass": "1.92.1",
@ -76,7 +79,7 @@
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "5.9.2", "typescript": "5.9.2",
"v-code-diff": "1.13.1", "v-code-diff": "1.13.1",
"vite": "7.1.4", "vite": "7.1.5",
"vue": "3.5.21", "vue": "3.5.21",
"vuedraggable": "next", "vuedraggable": "next",
"wanakana": "5.3.1" "wanakana": "5.3.1"

View File

@ -151,7 +151,21 @@ export async function common(createVue: () => Promise<App<Element>>) {
} }
//#endregion //#endregion
//#region Sync dark mode
if (prefer.s.syncDeviceDarkMode) {
store.set('darkMode', isDeviceDarkmode());
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
if (prefer.s.syncDeviceDarkMode) {
store.set('darkMode', mql.matches);
}
});
//#endregion
// NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため) // NOTE: この処理は必ずクライアント更新チェック処理より後に来ること(テーマ再構築のため)
// NOTE: この処理は必ずダークモード判定処理より後に来ること(初回のテーマ適用のため)
// see: https://github.com/misskey-dev/misskey/issues/16562
watch(store.r.darkMode, (darkMode) => { watch(store.r.darkMode, (darkMode) => {
const theme = (() => { const theme = (() => {
if (darkMode) { if (darkMode) {
@ -183,18 +197,6 @@ export async function common(createVue: () => Promise<App<Element>>) {
}); });
} }
//#region Sync dark mode
if (prefer.s.syncDeviceDarkMode) {
store.set('darkMode', isDeviceDarkmode());
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (mql) => {
if (prefer.s.syncDeviceDarkMode) {
store.set('darkMode', mql.matches);
}
});
//#endregion
if (!isSafeMode) { if (!isSafeMode) {
if (prefer.s.darkTheme && store.s.darkMode) { if (prefer.s.darkTheme && store.s.darkMode) {
if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme); if (miLocalStorage.getItem('themeId') !== prefer.s.darkTheme.id) applyTheme(prefer.s.darkTheme);

View File

@ -10,6 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, onUnmounted, useTemplateRef } from 'vue'; import { onMounted, onUnmounted, useTemplateRef } from 'vue';
import isChromatic from 'chromatic/isChromatic'; import isChromatic from 'chromatic/isChromatic';
import { initShaderProgram } from '@/utility/webgl.js';
const canvasEl = useTemplateRef('canvasEl'); const canvasEl = useTemplateRef('canvasEl');
@ -21,47 +22,6 @@ const props = withDefaults(defineProps<{
focus: 1.0, focus: 1.0,
}); });
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (shader == null) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
alert(
`falied to compile shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function initShaderProgram(gl: WebGLRenderingContext, vsSource: string, fsSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
const shaderProgram = gl.createProgram();
if (vertexShader == null || fragmentShader == null) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
alert(
`failed to init shader: ${gl.getProgramInfoLog(
shaderProgram,
)}`,
);
return null;
}
return shaderProgram;
}
let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null; let handle: ReturnType<typeof window['requestAnimationFrame']> | null = null;
onMounted(() => { onMounted(() => {
@ -71,7 +31,7 @@ onMounted(() => {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const maybeGl = canvas.getContext('webgl', { premultipliedAlpha: true }); const maybeGl = canvas.getContext('webgl2', { premultipliedAlpha: true });
if (maybeGl == null) return; if (maybeGl == null) return;
const gl = maybeGl; const gl = maybeGl;
@ -82,18 +42,16 @@ onMounted(() => {
const positionBuffer = gl.createBuffer(); const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const shaderProgram = initShaderProgram(gl, ` const shaderProgram = initShaderProgram(gl, `#version 300 es
attribute vec2 vertex; in vec2 position;
uniform vec2 u_scale; uniform vec2 u_scale;
out vec2 in_uv;
varying vec2 v_pos;
void main() { void main() {
gl_Position = vec4(vertex, 0.0, 1.0); gl_Position = vec4(position, 0.0, 1.0);
v_pos = vertex / u_scale; in_uv = position / u_scale;
} }
`, ` `, `#version 300 es
precision mediump float; precision mediump float;
vec3 mod289(vec3 x) { vec3 mod289(vec3 x) {
@ -143,6 +101,7 @@ onMounted(() => {
return 130.0 * dot(m, g); return 130.0 * dot(m, g);
} }
in vec2 in_uv;
uniform float u_time; uniform float u_time;
uniform vec2 u_resolution; uniform vec2 u_resolution;
uniform float u_spread; uniform float u_spread;
@ -150,8 +109,7 @@ onMounted(() => {
uniform float u_warp; uniform float u_warp;
uniform float u_focus; uniform float u_focus;
uniform float u_itensity; uniform float u_itensity;
out vec4 out_color;
varying vec2 v_pos;
float circle( in vec2 _pos, in vec2 _origin, in float _radius ) { float circle( in vec2 _pos, in vec2 _origin, in float _radius ) {
float SPREAD = 0.7 * u_spread; float SPREAD = 0.7 * u_spread;
@ -182,13 +140,13 @@ onMounted(() => {
float ratio = u_resolution.x / u_resolution.y; float ratio = u_resolution.x / u_resolution.y;
vec2 uv = vec2( v_pos.x, v_pos.y / ratio ) * 0.5 + 0.5; vec2 uv = vec2( in_uv.x, in_uv.y / ratio ) * 0.5 + 0.5;
vec3 color = vec3( 0.0 ); vec3 color = vec3( 0.0 );
float greenMix = snoise( v_pos * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5; float greenMix = snoise( in_uv * 1.31 + u_time * 0.8 * 0.00017 ) * 0.5 + 0.5;
float purpleMix = snoise( v_pos * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5; float purpleMix = snoise( in_uv * 1.26 + u_time * 0.8 * -0.0001 ) * 0.5 + 0.5;
float orangeMix = snoise( v_pos * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5; float orangeMix = snoise( in_uv * 1.34 + u_time * 0.8 * 0.00015 ) * 0.5 + 0.5;
float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 ); float alphaOne = 0.35 + 0.65 * pow( snoise( vec2( u_time * 0.00012, uv.x ) ) * 0.5 + 0.5, 1.2 );
float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 ); float alphaTwo = 0.35 + 0.65 * pow( snoise( vec2( ( u_time + 1561.0 ) * 0.00014, uv.x ) ) * 0.5 + 0.5, 1.2 );
@ -198,10 +156,10 @@ onMounted(() => {
color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix ); color += vec3( circle( uv, vec2( 0.90 + cos( u_time * 0.000166 ) * 0.06, 0.42 + sin( u_time * 0.000138 ) * 0.06 ), 0.18 ) ) * alphaTwo * ( green * greenMix + purple * purpleMix );
color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix ); color += vec3( circle( uv, vec2( 0.19 + sin( u_time * 0.000112 ) * 0.06, 0.25 + sin( u_time * 0.000192 ) * 0.06 ), 0.09 ) ) * alphaThree * ( orange * orangeMix );
color *= u_itensity + 1.0 * pow( snoise( vec2( v_pos.y + u_time * 0.00013, v_pos.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 ); color *= u_itensity + 1.0 * pow( snoise( vec2( in_uv.y + u_time * 0.00013, in_uv.x + u_time * -0.00009 ) ) * 0.5 + 0.5, 2.0 );
vec3 inverted = vec3( 1.0 ) - color; vec3 inverted = vec3( 1.0 ) - color;
gl_FragColor = vec4( color, max(max(color.x, color.y), color.z) ); out_color = vec4(color, max(max(color.x, color.y), color.z));
} }
`); `);
if (shaderProgram == null) return; if (shaderProgram == null) return;
@ -223,7 +181,7 @@ onMounted(() => {
gl.uniform1f(u_itensity, 0.5); gl.uniform1f(u_itensity, 0.5);
gl.uniform2fv(u_scale, [props.scale, props.scale]); gl.uniform2fv(u_scale, [props.scale, props.scale]);
const vertex = gl.getAttribLocation(shaderProgram, 'vertex'); const vertex = gl.getAttribLocation(shaderProgram, 'position');
gl.enableVertexAttribArray(vertex); gl.enableVertexAttribArray(vertex);
gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(vertex, 2, gl.FLOAT, false, 0, 0);

View File

@ -10,17 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="name"> <MkInput v-model="name">
<template #label>{{ i18n.ts.name }}</template> <template #label>{{ i18n.ts.name }}</template>
</MkInput> </MkInput>
<MkSelect v-model="src"> <MkSelect v-model="src" :items="antennaSourcesSelectDef">
<template #label>{{ i18n.ts.antennaSource }}</template> <template #label>{{ i18n.ts.antennaSource }}</template>
<option value="all">{{ i18n.ts._antennaSources.all }}</option>
<!--<option value="home">{{ i18n.ts._antennaSources.homeTimeline }}</option>-->
<option value="users">{{ i18n.ts._antennaSources.users }}</option>
<!--<option value="list">{{ i18n.ts._antennaSources.userList }}</option>-->
<option value="users_blacklist">{{ i18n.ts._antennaSources.userBlacklist }}</option>
</MkSelect> </MkSelect>
<MkSelect v-if="src === 'list'" v-model="userListId"> <MkSelect v-if="src === 'list'" v-model="userListId" :items="userListsSelectDef">
<template #label>{{ i18n.ts.userList }}</template> <template #label>{{ i18n.ts.userList }}</template>
<option v-for="list in userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
</MkSelect> </MkSelect>
<MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users"> <MkTextarea v-else-if="src === 'users' || src === 'users_blacklist'" v-model="users">
<template #label>{{ i18n.ts.users }}</template> <template #label>{{ i18n.ts.users }}</template>
@ -52,7 +46,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { watch, ref } from 'vue'; import { watch, ref, computed } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import type { DeepPartial } from '@/utility/merge.js'; import type { DeepPartial } from '@/utility/merge.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
@ -64,6 +58,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { deepMerge } from '@/utility/merge.js'; import { deepMerge } from '@/utility/merge.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & { type PartialAllowedAntenna = Omit<Misskey.entities.Antenna, 'id' | 'createdAt' | 'updatedAt'> & {
id?: string; id?: string;
@ -99,9 +94,35 @@ const emit = defineEmits<{
(ev: 'deleted'): void, (ev: 'deleted'): void,
}>(); }>();
const {
model: src,
def: antennaSourcesSelectDef,
} = useMkSelect({
items: [
{ value: 'all', label: i18n.ts._antennaSources.all },
//{ value: 'home', label: i18n.ts._antennaSources.homeTimeline },
{ value: 'users', label: i18n.ts._antennaSources.users },
//{ value: 'list', label: i18n.ts._antennaSources.userList },
{ value: 'users_blacklist', label: i18n.ts._antennaSources.userBlacklist },
],
initialValue: initialAntenna.src,
});
const {
model: userListId,
def: userListsSelectDef,
} = useMkSelect({
items: computed(() => {
if (userLists.value == null) return [];
return userLists.value.map(list => ({
value: list.id,
label: list.name,
}));
}),
initialValue: initialAntenna.userListId,
});
const name = ref<string>(initialAntenna.name); const name = ref<string>(initialAntenna.name);
const src = ref<Misskey.entities.AntennasCreateRequest['src']>(initialAntenna.src);
const userListId = ref<string | null>(initialAntenna.userListId);
const users = ref<string>(initialAntenna.users.join('\n')); const users = ref<string>(initialAntenna.users.join('\n'));
const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n')); const keywords = ref<string>(initialAntenna.keywords.map(x => x.join(' ')).join('\n'));
const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n')); const excludeKeywords = ref<string>(initialAntenna.excludeKeywords.map(x => x.join(' ')).join('\n'));

View File

@ -32,10 +32,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</template> <template v-if="c.caption" #caption>{{ c.caption }}</template>
</MkInput> </MkInput>
<MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" @update:modelValue="onSelectUpdate"> <MkSelect v-else-if="c.type === 'select'" :small="size === 'small'" :modelValue="valueForSelect" :items="selectDef" @update:modelValue="onSelectUpdate">
<template v-if="c.label" #label>{{ c.label }}</template> <template v-if="c.label" #label>{{ c.label }}</template>
<template v-if="c.caption" #caption>{{ c.caption }}</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> </MkSelect>
<MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton> <MkButton v-else-if="c.type === 'postFormButton'" :primary="c.primary" :rounded="c.rounded" :small="size === 'small'" inline @click="openPostForm">{{ c.text }}</MkButton>
<div v-else-if="c.type === 'postForm'" :class="$style.postForm"> <div v-else-if="c.type === 'postForm'" :class="$style.postForm">
@ -74,6 +73,7 @@ import MkSelect from '@/components/MkSelect.vue';
import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js'; import type { AsUiComponent, AsUiRoot, AsUiPostFormButton } from '@/aiscript/ui.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkPostForm from '@/components/MkPostForm.vue'; import MkPostForm from '@/components/MkPostForm.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
component: AsUiComponent; component: AsUiComponent;
@ -130,7 +130,19 @@ function onSwitchUpdate(v: boolean) {
} }
} }
const valueForSelect = ref('default' in c && typeof c.default !== 'boolean' ? c.default ?? null : null); const {
model: valueForSelect,
def: selectDef,
} = useMkSelect({
items: computed(() => {
if (c.type !== 'select') return [];
return (c.items ?? []).map(item => ({
value: item.value,
label: item.text,
}));
}),
initialValue: (c.type === 'select' && 'default' in c && typeof c.default !== 'boolean') ? c.default ?? null : null,
});
function onSelectUpdate(v) { function onSelectUpdate(v) {
valueForSelect.value = v; valueForSelect.value = v;

View File

@ -29,6 +29,6 @@ const users = ref<Misskey.entities.UserLite[]>([]);
onMounted(async () => { onMounted(async () => {
users.value = await misskeyApi('users/show', { users.value = await misskeyApi('users/show', {
userIds: props.userIds, userIds: props.userIds,
}) as unknown as Misskey.entities.UserLite[]; });
}); });
</script> </script>

View File

@ -29,16 +29,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/> <span v-else-if="okButtonDisabledReason === 'charactersBelow'" v-text="i18n.tsx._dialog.charactersBelow({ current: (inputValue as string)?.length ?? 0, min: input.minLength ?? 'NaN' })"/>
</template> </template>
</MkInput> </MkInput>
<MkSelect v-if="select" v-model="selectedValue" autofocus> <MkSelect v-if="select" v-model="selectedValue" :items="selectDef" autofocus></MkSelect>
<template v-if="select.items">
<template v-for="item in select.items">
<optgroup v-if="'sectionTitle' in item" :label="item.sectionTitle">
<option v-for="subItem in item.items" :value="subItem.value">{{ subItem.text }}</option>
</optgroup>
<option v-else :value="item.value">{{ item.text }}</option>
</template>
</template>
</MkSelect>
<div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons"> <div v-if="(showOkButton || showCancelButton) && !actions" :class="$style.buttons">
<MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton> <MkButton v-if="showOkButton" data-cy-modal-dialog-ok inline primary rounded :autofocus="!input && !select" :disabled="okButtonDisabledReason != null" @click="ok">{{ okText ?? ((showCancelButton || input || select) ? i18n.ts.ok : i18n.ts.gotIt) }}</MkButton>
<MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton> <MkButton v-if="showCancelButton || input || select" data-cy-modal-dialog-cancel inline rounded @click="cancel">{{ cancelText ?? i18n.ts.cancel }}</MkButton>
@ -56,6 +47,8 @@ import MkModal from '@/components/MkModal.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
type Input = { type Input = {
@ -67,17 +60,9 @@ type Input = {
maxLength?: number; maxLength?: number;
}; };
type SelectItem = {
value: any;
text: string;
};
type Select = { type Select = {
items: (SelectItem | { items: MkSelectItem[];
sectionTitle: string; default: OptionValue | null;
items: SelectItem[];
})[];
default: string | null;
}; };
type Result = string | number | true | null; type Result = string | number | true | null;
@ -115,7 +100,6 @@ const emit = defineEmits<{
const modal = useTemplateRef('modal'); const modal = useTemplateRef('modal');
const inputValue = ref<string | number | null>(props.input?.default ?? null); const inputValue = ref<string | number | null>(props.input?.default ?? null);
const selectedValue = ref(props.select?.default ?? null);
const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => { const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'charactersBelow'>(() => {
if (props.input) { if (props.input) {
@ -134,6 +118,14 @@ const okButtonDisabledReason = computed<null | 'charactersExceeded' | 'character
return null; return null;
}); });
const {
def: selectDef,
model: selectedValue,
} = useMkSelect({
items: computed(() => props.select?.items ?? []),
initialValue: props.select?.default ?? null,
});
// overload function 使 lint // overload function 使 lint
function done(canceled: true): void; function done(canceled: true): void;
function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare function done(canceled: false, result: Result): void; // eslint-disable-line no-redeclare

View File

@ -52,11 +52,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #suffix>px</template> <template #suffix>px</template>
<template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template> <template #caption>{{ i18n.ts._embedCodeGen.maxHeightDescription }}</template>
</MkInput> </MkInput>
<MkSelect v-model="colorMode"> <MkSelect v-model="colorMode" :items="colorModeDef">
<template #label>{{ i18n.ts.theme }}</template> <template #label>{{ i18n.ts.theme }}</template>
<option value="auto">{{ i18n.ts.syncDeviceDarkMode }}</option>
<option value="light">{{ i18n.ts.light }}</option>
<option value="dark">{{ i18n.ts.dark }}</option>
</MkSelect> </MkSelect>
<MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch> <MkSwitch v-if="isEmbedWithScrollbar" v-model="header">{{ i18n.ts._embedCodeGen.header }}</MkSwitch>
<MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch> <MkSwitch v-model="rounded">{{ i18n.ts._embedCodeGen.rounded }}</MkSwitch>
@ -105,6 +102,7 @@ import MkInfo from '@/components/MkInfo.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js'; import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js'; import { normalizeEmbedParams, getEmbedCode } from '@/utility/get-embed-code.js';
@ -162,7 +160,18 @@ const isEmbedWithScrollbar = computed(() => embedRouteWithScrollbar.includes(pro
const header = ref(props.params?.header ?? true); const header = ref(props.params?.header ?? true);
const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500); const maxHeight = ref(props.params?.maxHeight !== 0 ? props.params?.maxHeight ?? null : 500);
const colorMode = ref<'light' | 'dark' | 'auto'>(props.params?.colorMode ?? 'auto'); const {
model: colorMode,
def: colorModeDef,
} = useMkSelect({
items: [
{ value: 'auto', label: i18n.ts.syncDeviceDarkMode },
{ value: 'light', label: i18n.ts.light },
{ value: 'dark', label: i18n.ts.dark },
],
initialValue: props.params?.colorMode ?? 'auto',
});
const rounded = ref(props.params?.rounded ?? true); const rounded = ref(props.params?.rounded ?? true);
const border = ref(props.params?.border ?? true); const border = ref(props.params?.border ?? true);

View File

@ -530,6 +530,14 @@ defineExpose({
--eachSize: 50px; --eachSize: 50px;
} }
&.s4 {
--eachSize: 55px;
}
&.s5 {
--eachSize: 60px;
}
&.w1 { &.w1 {
width: calc((var(--eachSize) * 5) + (#{$pad} * 2)); width: calc((var(--eachSize) * 5) + (#{$pad} * 2));
--columns: 1fr 1fr 1fr 1fr 1fr; --columns: 1fr 1fr 1fr 1fr 1fr;

View File

@ -39,9 +39,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-text="v.label || k"></span> <span v-text="v.label || k"></span>
<template v-if="v.description" #caption>{{ v.description }}</template> <template v-if="v.description" #caption>{{ v.description }}</template>
</MkSwitch> </MkSwitch>
<MkSelect v-else-if="v.type === 'enum'" v-model="values[k]"> <MkSelect v-else-if="v.type === 'enum'" v-model="values[k]" :items="getMkSelectDef(v)">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
<option v-for="option in v.enum" :key="getEnumKey(option)" :value="getEnumValue(option)">{{ getEnumLabel(option) }}</option>
</MkSelect> </MkSelect>
<MkRadios v-else-if="v.type === 'radio'" v-model="values[k]"> <MkRadios v-else-if="v.type === 'radio'" v-model="values[k]">
<template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template> <template #label><span v-text="v.label || k"></span><span v-if="v.required === false"> ({{ i18n.ts.optional }})</span></template>
@ -77,7 +76,8 @@ import MkRange from './MkRange.vue';
import MkButton from './MkButton.vue'; import MkButton from './MkButton.vue';
import MkRadios from './MkRadios.vue'; import MkRadios from './MkRadios.vue';
import XFile from './MkFormDialog.file.vue'; import XFile from './MkFormDialog.file.vue';
import type { EnumItem, Form, RadioFormItem } from '@/utility/form.js'; import type { MkSelectItem } from '@/components/MkSelect.vue';
import type { Form, EnumFormItem, RadioFormItem } from '@/utility/form.js';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
@ -120,16 +120,14 @@ function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
function getEnumLabel(e: EnumItem) { function getMkSelectDef(def: EnumFormItem): MkSelectItem[] {
return typeof e === 'string' ? e : e.label; return def.enum.map((v) => {
} if (typeof v === 'string') {
return { value: v, label: v };
function getEnumValue(e: EnumItem) { } else {
return typeof e === 'string' ? e : e.value; return { value: v.value, label: v.label };
} }
});
function getEnumKey(e: EnumItem) {
return typeof e === 'string' ? e : typeof e.value === 'string' ? e.value : JSON.stringify(e.value);
} }
function getRadioKey(e: RadioFormItem['options'][number]) { function getRadioKey(e: RadioFormItem['options'][number]) {

View File

@ -19,9 +19,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.container"> <div :class="$style.container">
<div :class="$style.preview"> <div :class="$style.preview">
<canvas ref="canvasEl" :class="$style.previewCanvas"></canvas> <canvas ref="canvasEl" :class="$style.previewCanvas" @pointerdown="onImagePointerdown"></canvas>
<div :class="$style.previewContainer"> <div :class="$style.previewContainer">
<div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div> <div class="_acrylic" :class="$style.previewTitle">{{ i18n.ts.preview }}</div>
<div class="_acrylic" :class="$style.editControls">
<button class="_button" :class="[$style.previewControlsButton, penMode != null ? $style.active : null]" @click="showPenMenu"><i class="ti ti-pencil"></i></button>
</div>
<div class="_acrylic" :class="$style.previewControls"> <div class="_acrylic" :class="$style.previewControls">
<button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button> <button class="_button" :class="[$style.previewControlsButton, !enabled ? $style.active : null]" @click="enabled = false">Before</button>
<button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button> <button class="_button" :class="[$style.previewControlsButton, enabled ? $style.active : null]" @click="enabled = true">After</button>
@ -212,6 +215,129 @@ watch(enabled, () => {
renderer.render(); renderer.render();
} }
}); });
const penMode = ref<'fill' | 'blur' | null>(null);
function showPenMenu(ev: MouseEvent) {
os.popupMenu([{
text: i18n.ts._imageEffector._fxs.fill,
action: () => {
penMode.value = 'fill';
},
}, {
text: i18n.ts._imageEffector._fxs.blur,
action: () => {
penMode.value = 'blur';
},
}], ev.currentTarget ?? ev.target);
}
function onImagePointerdown(ev: PointerEvent) {
if (canvasEl.value == null || imageBitmap == null || penMode.value == null) return;
const AW = canvasEl.value.clientWidth;
const AH = canvasEl.value.clientHeight;
const BW = imageBitmap.width;
const BH = imageBitmap.height;
let xOffset = 0;
let yOffset = 0;
if (AW / AH < BW / BH) { //
yOffset = AH - BH * (AW / BW);
} else { //
xOffset = AW - BW * (AH / BH);
}
xOffset /= 2;
yOffset /= 2;
let startX = ev.offsetX - xOffset;
let startY = ev.offsetY - yOffset;
if (AW / AH < BW / BH) { //
startX = startX / (Math.max(AW, AH) / Math.max(BH / BW, 1));
startY = startY / (Math.max(AW, AH) / Math.max(BW / BH, 1));
} else { //
startX = startX / (Math.min(AW, AH) / Math.max(BH / BW, 1));
startY = startY / (Math.min(AW, AH) / Math.max(BW / BH, 1));
}
const id = genId();
if (penMode.value === 'fill') {
layers.push({
id,
fxId: 'fill',
params: {
offsetX: 0,
offsetY: 0,
scaleX: 0.1,
scaleY: 0.1,
angle: 0,
opacity: 1,
color: [1, 1, 1],
},
});
} else if (penMode.value === 'blur') {
layers.push({
id,
fxId: 'blur',
params: {
offsetX: 0,
offsetY: 0,
scaleX: 0.1,
scaleY: 0.1,
angle: 0,
radius: 3,
},
});
}
_move(ev.offsetX, ev.offsetY);
function _move(pointerX: number, pointerY: number) {
let x = pointerX - xOffset;
let y = pointerY - yOffset;
if (AW / AH < BW / BH) { //
x = x / (Math.max(AW, AH) / Math.max(BH / BW, 1));
y = y / (Math.max(AW, AH) / Math.max(BW / BH, 1));
} else { //
x = x / (Math.min(AW, AH) / Math.max(BH / BW, 1));
y = y / (Math.min(AW, AH) / Math.max(BW / BH, 1));
}
const scaleX = Math.abs(x - startX);
const scaleY = Math.abs(y - startY);
const layerIndex = layers.findIndex((l) => l.id === id);
const layer = layerIndex !== -1 ? layers[layerIndex] : null;
if (layer != null) {
layer.params.offsetX = (x + startX) - 1;
layer.params.offsetY = (y + startY) - 1;
layer.params.scaleX = scaleX;
layer.params.scaleY = scaleY;
layers[layerIndex] = layer;
}
}
function move(ev: PointerEvent) {
_move(ev.offsetX, ev.offsetY);
}
function up() {
canvasEl.value?.removeEventListener('pointermove', move);
canvasEl.value?.removeEventListener('pointerup', up);
canvasEl.value?.removeEventListener('pointercancel', up);
canvasEl.value?.releasePointerCapture(ev.pointerId);
penMode.value = null;
}
canvasEl.value.addEventListener('pointermove', move);
canvasEl.value.addEventListener('pointerup', up);
canvasEl.value.setPointerCapture(ev.pointerId);
}
</script> </script>
<style module> <style module>
@ -251,6 +377,18 @@ watch(enabled, () => {
font-size: 85%; font-size: 85%;
} }
.editControls {
position: absolute;
z-index: 100;
bottom: 8px;
left: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
}
.previewControls { .previewControls {
position: absolute; position: absolute;
z-index: 100; z-index: 100;
@ -283,9 +421,13 @@ watch(enabled, () => {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; /* iOS
height: 100%; width: stretch;
padding: 20px; height: stretch;
*/
width: calc(100% - 40px);
height: calc(100% - 40px);
margin: 20px;
box-sizing: border-box; box-sizing: border-box;
object-fit: contain; object-fit: contain;
} }

View File

@ -9,31 +9,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>Chart</template> <template #header>Chart</template>
<div :class="$style.chart"> <div :class="$style.chart">
<div class="selects"> <div class="selects">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0; flex: 1;"></MkSelect>
<optgroup v-if="shouldShowFederation" :label="i18n.ts.federation"> <MkSelect v-model="chartSpan" :items="chartSpanDef" style="margin: 0 0 0 10px;"></MkSelect>
<option value="federation">{{ i18n.ts._charts.federation }}</option>
<option value="ap-request">{{ i18n.ts._charts.apRequest }}</option>
</optgroup>
<optgroup :label="i18n.ts.users">
<option value="users">{{ i18n.ts._charts.usersIncDec }}</option>
<option value="users-total">{{ i18n.ts._charts.usersTotal }}</option>
<option value="active-users">{{ i18n.ts._charts.activeUsers }}</option>
</optgroup>
<optgroup :label="i18n.ts.notes">
<option value="notes">{{ i18n.ts._charts.notesIncDec }}</option>
<option value="local-notes">{{ i18n.ts._charts.localNotesIncDec }}</option>
<option v-if="shouldShowFederation" value="remote-notes">{{ i18n.ts._charts.remoteNotesIncDec }}</option>
<option value="notes-total">{{ i18n.ts._charts.notesTotal }}</option>
</optgroup>
<optgroup :label="i18n.ts.drive">
<option value="drive-files">{{ i18n.ts._charts.filesIncDec }}</option>
<option value="drive">{{ i18n.ts._charts.storageUsageIncDec }}</option>
</optgroup>
</MkSelect>
<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">
<option value="hour">{{ i18n.ts.perHour }}</option>
<option value="day">{{ i18n.ts.perDay }}</option>
</MkSelect>
</div> </div>
<div class="chart _panel"> <div class="chart _panel">
<MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart> <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="true"></MkChart>
@ -43,13 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkFoldableSection class="item"> <MkFoldableSection class="item">
<template #header>Active users heatmap</template> <template #header>Active users heatmap</template>
<MkSelect v-model="heatmapSrc" style="margin: 0 0 12px 0;"> <MkSelect v-model="heatmapSrc" :items="heatmapSrcDef" style="margin: 0 0 12px 0;"></MkSelect>
<option value="active-users">Active users</option>
<option value="notes">Notes</option>
<option v-if="shouldShowFederation" value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
<option v-if="shouldShowFederation" value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
<option v-if="shouldShowFederation" value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
</MkSelect>
<div class="_panel" :class="$style.heatmap"> <div class="_panel" :class="$style.heatmap">
<MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/> <MkHeatmap :src="heatmapSrc" :label="'Read & Write'"/>
</div> </div>
@ -84,10 +55,10 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref, computed, useTemplateRef } from 'vue'; import { onMounted, computed, useTemplateRef } from 'vue';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import type { HeatmapSource } from '@/components/MkHeatmap.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import type { MkSelectItem, ItemOption } from '@/components/MkSelect.vue';
import MkChart from '@/components/MkChart.vue'; import MkChart from '@/components/MkChart.vue';
import type { ChartSrc } from '@/components/MkChart.vue'; import type { ChartSrc } from '@/components/MkChart.vue';
import { useChartTooltip } from '@/composables/use-chart-tooltip.js'; import { useChartTooltip } from '@/composables/use-chart-tooltip.js';
@ -101,15 +72,96 @@ import MkFoldableSection from '@/components/MkFoldableSection.vue';
import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue'; import MkRetentionHeatmap from '@/components/MkRetentionHeatmap.vue';
import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue'; import MkRetentionLineChart from '@/components/MkRetentionLineChart.vue';
import { initChart } from '@/utility/init-chart.js'; import { initChart } from '@/utility/init-chart.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
initChart(); initChart();
const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator); const shouldShowFederation = computed(() => instance.federation !== 'none' || $i?.isModerator);
const chartLimit = 500; const chartLimit = 500;
const chartSpan = ref<'hour' | 'day'>('hour'); const {
const chartSrc = ref<ChartSrc>('active-users'); model: chartSpan,
const heatmapSrc = ref<HeatmapSource>('active-users'); def: chartSpanDef,
} = useMkSelect({
items: [
{ value: 'hour', label: i18n.ts.perHour },
{ value: 'day', label: i18n.ts.perDay },
],
initialValue: 'hour',
});
const {
model: chartSrc,
def: chartSrcDef,
} = useMkSelect({
items: computed<MkSelectItem<ChartSrc>[]>(() => {
const items: MkSelectItem<ChartSrc>[] = [];
if (shouldShowFederation.value) {
items.push({
type: 'group',
label: i18n.ts.federation,
items: [
{ value: 'federation', label: i18n.ts._charts.federation },
{ value: 'ap-request', label: i18n.ts._charts.apRequest },
],
});
}
items.push({
type: 'group',
label: i18n.ts.users,
items: [
{ value: 'users', label: i18n.ts._charts.usersIncDec },
{ value: 'users-total', label: i18n.ts._charts.usersTotal },
{ value: 'active-users', label: i18n.ts._charts.activeUsers },
],
});
const notesItems: ItemOption<ChartSrc>[] = [
{ value: 'notes', label: i18n.ts._charts.notesIncDec },
{ value: 'local-notes', label: i18n.ts._charts.localNotesIncDec },
];
if (shouldShowFederation.value) notesItems.push({ value: 'remote-notes', label: i18n.ts._charts.remoteNotesIncDec });
notesItems.push(
{ value: 'notes-total', label: i18n.ts._charts.notesTotal },
);
items.push({
type: 'group',
label: i18n.ts.notes,
items: notesItems,
});
items.push({
type: 'group',
label: i18n.ts.drive,
items: [
{ value: 'drive-files', label: i18n.ts._charts.filesIncDec },
{ value: 'drive', label: i18n.ts._charts.storageUsageIncDec },
],
});
return items;
}),
initialValue: 'active-users',
});
const {
model: heatmapSrc,
def: heatmapSrcDef,
} = useMkSelect({
items: computed(() => [
{ value: 'active-users' as const, label: 'Active Users' },
{ value: 'notes' as const, label: 'Notes' },
...(shouldShowFederation.value ? [
{ value: 'ap-requests-inbox-received' as const, label: 'AP Requests: inboxReceived' },
{ value: 'ap-requests-deliver-succeeded' as const, label: 'AP Requests: deliverSucceeded' },
{ value: 'ap-requests-deliver-failed' as const, label: 'AP Requests: deliverFailed' },
] : []),
]),
initialValue: 'active-users',
});
const subDoughnutEl = useTemplateRef('subDoughnutEl'); const subDoughnutEl = useTemplateRef('subDoughnutEl');
const pubDoughnutEl = useTemplateRef('pubDoughnutEl'); const pubDoughnutEl = useTemplateRef('pubDoughnutEl');

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="$style.root"> <div :class="$style.root">
<div :class="$style.control"> <div :class="$style.control">
<MkSelect v-model="order" :class="$style.order" :items="[{ label: i18n.ts._order.newest, value: 'newest' }, { label: i18n.ts._order.oldest, value: 'oldest' }]"> <MkSelect v-model="order" :class="$style.order" :items="orderDef">
<template #prefix><i class="ti ti-arrows-sort"></i></template> <template #prefix><i class="ti ti-arrows-sort"></i></template>
</MkSelect> </MkSelect>
<MkButton v-if="paginator.canSearch" v-tooltip="i18n.ts.search" iconOnly transparent rounded :active="searchOpened" @click="searchOpened = !searchOpened"><i class="ti ti-search"></i></MkButton> <MkButton v-if="paginator.canSearch" v-tooltip="i18n.ts.search" iconOnly transparent rounded :active="searchOpened" @click="searchOpened = !searchOpened"><i class="ti ti-search"></i></MkButton>
@ -45,6 +45,7 @@ import { i18n } from '@/i18n.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import { formatDateTimeString } from '@/utility/format-time-string.js'; import { formatDateTimeString } from '@/utility/format-time-string.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
paginator: T; paginator: T;
@ -58,7 +59,16 @@ const props = withDefaults(defineProps<{
const searchOpened = ref(false); const searchOpened = ref(false);
const filterOpened = ref(props.filterOpened); const filterOpened = ref(props.filterOpened);
const order = ref<'newest' | 'oldest'>('newest'); const {
model: order,
def: orderDef,
} = useMkSelect({
items: [
{ label: i18n.ts._order.newest, value: 'newest' },
{ label: i18n.ts._order.oldest, value: 'oldest' },
],
initialValue: 'newest',
});
const date = ref<number | null>(null); const date = ref<number | null>(null);
const q = ref<string | null>(null); const q = ref<string | null>(null);

View File

@ -4,14 +4,18 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="[$style.root, accented ? $style.accented : null]"></div> <div :class="[$style.root, accented ? $style.accented : null, revered ? $style.revered : null]"/>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
accented?: boolean; accented?: boolean;
revered?: boolean;
height?: number;
}>(), { }>(), {
accented: false, accented: false,
revered: false,
height: 200,
}); });
</script> </script>
@ -27,14 +31,17 @@ const props = withDefaults(defineProps<{
--dot-size: 2px; --dot-size: 2px;
--gap-size: 40px; --gap-size: 40px;
--offset: calc(var(--gap-size) / 2); --offset: calc(var(--gap-size) / 2);
--height: v-bind('props.height + "px"');
height: 200px; height: var(--height);
margin-bottom: -200px;
background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)); background-image: linear-gradient(transparent 60%, transparent 100%), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size)), radial-gradient(var(--c) var(--dot-size), transparent var(--dot-size));
background-position: 0 0, 0 0, var(--offset) var(--offset); background-position: 0 0, 0 0, var(--offset) var(--offset);
background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size); background-size: 100% 100%, var(--gap-size) var(--gap-size), var(--gap-size) var(--gap-size);
mask-image: linear-gradient(to bottom, black 0%, transparent 100%); mask-image: linear-gradient(to bottom, black 0%, transparent 100%);
pointer-events: none; pointer-events: none;
&.revered {
mask-image: linear-gradient(to top, black 0%, transparent 100%);
}
} }
</style> </style>

View File

@ -27,16 +27,16 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { host } from '@@/js/config.js'; import { host } from '@@/js/config.js';
import { useInterval } from '@@/js/use-interval.js';
import type { OpenOnRemoteOptions } from '@/utility/please-login.js'; import type { OpenOnRemoteOptions } from '@/utility/please-login.js';
import { sum } from '@/utility/array.js'; import { sum } from '@/utility/array.js';
import { pleaseLogin } from '@/utility/please-login.js'; import { pleaseLogin } from '@/utility/please-login.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useLowresTime } from '@/composables/use-lowres-time.js';
const props = defineProps<{ const props = defineProps<{
noteId: string; noteId: string;
@ -48,7 +48,21 @@ const props = defineProps<{
author?: Misskey.entities.UserLite; author?: Misskey.entities.UserLite;
}>(); }>();
const remaining = ref(-1); const now = useLowresTime();
const expiresAtTime = computed(() => props.expiresAt ? new Date(props.expiresAt).getTime() : null);
const remaining = computed(() => {
if (expiresAtTime.value == null) return -1;
return Math.floor(Math.max(expiresAtTime.value - now.value, 0) / 1000);
});
const remainingWatchStop = watch(remaining, (to) => {
if (to <= 0) {
showResult.value = true;
remainingWatchStop();
}
}, { immediate: true });
const total = computed(() => sum(props.choices.map(x => x.votes))); const total = computed(() => sum(props.choices.map(x => x.votes)));
const closed = computed(() => remaining.value === 0); const closed = computed(() => remaining.value === 0);
@ -71,22 +85,7 @@ const pleaseLoginContext = computed<OpenOnRemoteOptions>(() => ({
url: `https://${host}/notes/${props.noteId}`, url: `https://${host}/notes/${props.noteId}`,
})); }));
// const vote = async (id: number) => {
if (props.expiresAt) {
const tick = () => {
remaining.value = Math.floor(Math.max(new Date(props.expiresAt!).getTime() - Date.now(), 0) / 1000);
if (remaining.value === 0) {
showResult.value = true;
}
};
useInterval(tick, 3000, {
immediate: true,
afterMounted: false,
});
}
const vote = async (id) => {
if (props.readOnly || closed.value || isVoted.value) return; if (props.readOnly || closed.value || isVoted.value) return;
pleaseLogin({ openOnRemote: pleaseLoginContext.value }); pleaseLogin({ openOnRemote: pleaseLoginContext.value });

View File

@ -22,11 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch> <MkSwitch v-model="multiple">{{ i18n.ts._poll.canMultipleVote }}</MkSwitch>
<section> <section>
<div> <div>
<MkSelect v-model="expiration" small> <MkSelect v-model="expiration" :items="expirationDef" small>
<template #label>{{ i18n.ts._poll.expiration }}</template> <template #label>{{ i18n.ts._poll.expiration }}</template>
<option value="infinite">{{ i18n.ts._poll.infinite }}</option>
<option value="at">{{ i18n.ts._poll.at }}</option>
<option value="after">{{ i18n.ts._poll.after }}</option>
</MkSelect> </MkSelect>
<section v-if="expiration === 'at'"> <section v-if="expiration === 'at'">
<MkInput v-model="atDate" small type="date" class="input"> <MkInput v-model="atDate" small type="date" class="input">
@ -40,12 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="after" small type="number" :min="1" class="input"> <MkInput v-model="after" small type="number" :min="1" class="input">
<template #label>{{ i18n.ts._poll.duration }}</template> <template #label>{{ i18n.ts._poll.duration }}</template>
</MkInput> </MkInput>
<MkSelect v-model="unit" small> <MkSelect v-model="unit" :items="unitDef" small></MkSelect>
<option value="second">{{ i18n.ts._time.second }}</option>
<option value="minute">{{ i18n.ts._time.minute }}</option>
<option value="hour">{{ i18n.ts._time.hour }}</option>
<option value="day">{{ i18n.ts._time.day }}</option>
</MkSelect>
</section> </section>
</div> </div>
</section> </section>
@ -61,6 +53,7 @@ import MkButton from './MkButton.vue';
import { formatDateTimeString } from '@/utility/format-time-string.js'; import { formatDateTimeString } from '@/utility/format-time-string.js';
import { addTime } from '@/utility/time.js'; import { addTime } from '@/utility/time.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
export type PollEditorModelValue = { export type PollEditorModelValue = {
expiresAt: number | null; expiresAt: number | null;
@ -78,11 +71,32 @@ const emit = defineEmits<{
const choices = ref(props.modelValue.choices); const choices = ref(props.modelValue.choices);
const multiple = ref(props.modelValue.multiple); const multiple = ref(props.modelValue.multiple);
const expiration = ref('infinite'); const {
model: expiration,
def: expirationDef,
} = useMkSelect({
items: [
{ label: i18n.ts._poll.infinite, value: 'infinite' },
{ label: i18n.ts._poll.at, value: 'at' },
{ label: i18n.ts._poll.after, value: 'after' },
],
initialValue: 'infinite',
});
const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd')); const atDate = ref(formatDateTimeString(addTime(new Date(), 1, 'day'), 'yyyy-MM-dd'));
const atTime = ref('00:00'); const atTime = ref('00:00');
const after = ref(0); const after = ref(0);
const unit = ref('second'); const {
model: unit,
def: unitDef,
} = useMkSelect({
items: [
{ label: i18n.ts._time.second, value: 'second' },
{ label: i18n.ts._time.minute, value: 'minute' },
{ label: i18n.ts._time.hour, value: 'hour' },
{ label: i18n.ts._time.day, value: 'day' },
],
initialValue: 'second',
});
if (props.modelValue.expiresAt) { if (props.modelValue.expiresAt) {
expiration.value = 'at'; expiration.value = 'at';

View File

@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div :class="[$style.root]"> <div :class="[$style.root]">
<div :class="$style.items"> <div :class="$style.items">
<button class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-align-box-left-top"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'top' ? $style.active : null]" @click="() => { x = 'left'; y = 'top'; }"><i class="ti ti-arrow-up-left"></i></button>
<button class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-align-box-center-top"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'top' ? $style.active : null]" @click="() => { x = 'center'; y = 'top'; }"><i class="ti ti-arrow-up"></i></button>
<button class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-align-box-right-top"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'top' ? $style.active : null]" @click="() => { x = 'right'; y = 'top'; }"><i class="ti ti-arrow-up-right"></i></button>
<button class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-align-box-left-middle"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'center' ? $style.active : null]" @click="() => { x = 'left'; y = 'center'; }"><i class="ti ti-arrow-left"></i></button>
<button class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-align-box-center-middle"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'center' ? $style.active : null]" @click="() => { x = 'center'; y = 'center'; }"><i class="ti ti-focus-2"></i></button>
<button class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-align-box-right-middle"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'center' ? $style.active : null]" @click="() => { x = 'right'; y = 'center'; }"><i class="ti ti-arrow-right"></i></button>
<button class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-align-box-left-bottom"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'left' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'left'; y = 'bottom'; }"><i class="ti ti-arrow-down-left"></i></button>
<button class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-align-box-center-bottom"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'center' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'center'; y = 'bottom'; }"><i class="ti ti-arrow-down"></i></button>
<button class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-align-box-right-bottom"></i></button> <button v-panel class="_button" :class="[$style.item, x === 'right' && y === 'bottom' ? $style.active : null]" @click="() => { x = 'right'; y = 'bottom'; }"><i class="ti ti-arrow-down-right"></i></button>
</div> </div>
</div> </div>
</template> </template>

View File

@ -105,7 +105,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef } from 'vue'; import { inject, watch, nextTick, onMounted, defineAsyncComponent, provide, shallowRef, ref, computed, useTemplateRef, onUnmounted } from 'vue';
import * as mfm from 'mfm-js'; import * as mfm from 'mfm-js';
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import insertTextAtCursor from 'insert-text-at-cursor'; import insertTextAtCursor from 'insert-text-at-cursor';
@ -218,6 +218,10 @@ const uploader = useUploader({
multiple: true, multiple: true,
}); });
onUnmounted(() => {
uploader.dispose();
});
uploader.events.on('itemUploaded', ctx => { uploader.events.on('itemUploaded', ctx => {
files.value.push(ctx.item.uploaded!); files.value.push(ctx.item.uploaded!);
uploader.removeItem(ctx.item); uploader.removeItem(ctx.item);
@ -567,11 +571,11 @@ async function toggleReactionAcceptance() {
const select = await os.select({ const select = await os.select({
title: i18n.ts.reactionAcceptance, title: i18n.ts.reactionAcceptance,
items: [ items: [
{ value: null, text: i18n.ts.all }, { value: null, label: i18n.ts.all },
{ value: 'likeOnlyForRemote' as const, text: i18n.ts.likeOnlyForRemote }, { value: 'likeOnlyForRemote' as const, label: i18n.ts.likeOnlyForRemote },
{ value: 'nonSensitiveOnly' as const, text: i18n.ts.nonSensitiveOnly }, { value: 'nonSensitiveOnly' as const, label: i18n.ts.nonSensitiveOnly },
{ value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, text: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote }, { value: 'nonSensitiveOnlyForLocalLikeOnlyForRemote' as const, label: i18n.ts.nonSensitiveOnlyForLocalLikeOnlyForRemote },
{ value: 'likeOnly' as const, text: i18n.ts.likeOnly }, { value: 'likeOnly' as const, label: i18n.ts.likeOnly },
], ],
default: reactionAcceptance.value, default: reactionAcceptance.value,
}); });
@ -823,17 +827,15 @@ async function saveServerDraft(clearLocal = false) {
return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', { return await os.apiWithDialog(serverDraftId.value == null ? 'notes/drafts/create' : 'notes/drafts/update', {
...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }), ...(serverDraftId.value == null ? {} : { draftId: serverDraftId.value }),
text: text.value, text: text.value,
useCw: useCw.value, cw: useCw.value ? cw.value || null : null,
cw: cw.value,
visibility: visibility.value, visibility: visibility.value,
localOnly: localOnly.value, localOnly: localOnly.value,
hashtag: hashtags.value, hashtag: hashtags.value,
...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}), ...(files.value.length > 0 ? { fileIds: files.value.map(f => f.id) } : {}),
poll: poll.value, poll: poll.value,
...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}), ...(visibleUsers.value.length > 0 ? { visibleUserIds: visibleUsers.value.map(x => x.id) } : {}),
renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : undefined, renoteId: renoteTargetNote.value ? renoteTargetNote.value.id : quoteId.value ? quoteId.value : undefined,
replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined, replyId: replyTargetNote.value ? replyTargetNote.value.id : undefined,
quoteId: quoteId.value,
channelId: targetChannel.value ? targetChannel.value.id : undefined, channelId: targetChannel.value ? targetChannel.value.id : undefined,
reactionAcceptance: reactionAcceptance.value, reactionAcceptance: reactionAcceptance.value,
}).then(() => { }).then(() => {
@ -1302,6 +1304,7 @@ async function canClose() {
defineExpose({ defineExpose({
clear, clear,
abortUploader: () => uploader.abortAll(),
canClose, canClose,
}); });
</script> </script>

View File

@ -54,6 +54,7 @@ function onPosted() {
async function _close() { async function _close() {
const canClose = await form.value?.canClose(); const canClose = await form.value?.canClose();
if (!canClose) return; if (!canClose) return;
form.value?.abortUploader();
modal.value?.close(); modal.value?.close();
} }

View File

@ -90,7 +90,7 @@ function subscribe() {
publickey: encode(subscription.getKey('p256dh')), publickey: encode(subscription.getKey('p256dh')),
}); });
}, async err => { // When subscribe failed }, async err => { // When subscribe failed
// //
if (err?.name === 'NotAllowedError') { if (err?.name === 'NotAllowedError') {
console.info('User denied the notification permission request.'); console.info('User denied the notification permission request.');
return; return;
@ -114,14 +114,13 @@ async function unsubscribe() {
if ($i && accounts.length >= 2) { if ($i && accounts.length >= 2) {
apiWithDialog('sw/unregister', { apiWithDialog('sw/unregister', {
i: $i.token,
endpoint, endpoint,
}); }, $i.token);
} else { } else {
pushSubscription.value.unsubscribe(); pushSubscription.value.unsubscribe();
apiWithDialog('sw/unregister', { apiWithDialog('sw/unregister', {
endpoint, endpoint,
}); }, null);
pushSubscription.value = null; pushSubscription.value = null;
} }
} }
@ -134,7 +133,7 @@ function encode(buffer: ArrayBuffer | null) {
* Convert the URL safe base64 string to a Uint8Array * Convert the URL safe base64 string to a Uint8Array
* @param base64String base64 string * @param base64String base64 string
*/ */
function urlBase64ToUint8Array(base64String: string): Uint8Array { function urlBase64ToUint8Array(base64String: string): BufferSource {
const padding = '='.repeat((4 - base64String.length % 4) % 4); const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding) const base64 = (base64String + padding)
.replace(/-/g, '+') .replace(/-/g, '+')

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<MkA :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> <MkA :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }">
<template v-if="forModeration"> <template v-if="forModeration">
<i v-if="role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--MI_THEME-success)"></i> <i v-if="'isPublic' in role && role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--MI_THEME-success)"></i>
<i v-else class="ti ti-lock" :class="$style.icon" style="color: var(--MI_THEME-warn)"></i> <i v-else class="ti ti-lock" :class="$style.icon" style="color: var(--MI_THEME-warn)"></i>
</template> </template>
@ -23,7 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
</span> </span>
<span :class="$style.bodyName">{{ role.name }}</span> <span :class="$style.bodyName">{{ role.name }}</span>
<template v-if="detailed"> <template v-if="detailed && 'target' in role && 'usersCount' in role">
<span v-if="role.target === 'manual'" :class="$style.bodyUsers">{{ role.usersCount }} users</span> <span v-if="role.target === 'manual'" :class="$style.bodyUsers">{{ role.usersCount }} users</span>
<span v-else-if="role.target === 'conditional'" :class="$style.bodyUsers">? users</span> <span v-else-if="role.target === 'conditional'" :class="$style.bodyUsers">? users</span>
</template> </template>
@ -39,7 +39,7 @@ import * as Misskey from 'misskey-js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
role: Misskey.entities.Role; role: Misskey.entities.Role | Misskey.entities.IResponse['roles'][number];
forModeration: boolean; forModeration: boolean;
detailed?: boolean; detailed?: boolean;
}>(), { }>(), {

View File

@ -102,12 +102,12 @@ async function addRole() {
const items = roles.value const items = roles.value
.filter(r => r.isPublic) .filter(r => r.isPublic)
.filter(r => !selectedRoleIds.value.includes(r.id)) .filter(r => !selectedRoleIds.value.includes(r.id))
.map(r => ({ text: r.name, value: r })); .map(r => ({ label: r.name, value: r.id }));
const { canceled, result: role } = await os.select({ items }); const { canceled, result: roleId } = await os.select({ items });
if (canceled || role == null) return; if (canceled || roleId == null) return;
selectedRoleIds.value.push(role.id); selectedRoleIds.value.push(roleId);
} }
async function removeRole(roleId: string) { async function removeRole(roleId: string) {

View File

@ -40,46 +40,41 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<script lang="ts"> <script lang="ts">
type ItemOption = { export type OptionValue = string | number | null;
export type ItemOption<T extends OptionValue = OptionValue> = {
type?: 'option'; type?: 'option';
value: string | number | null; value: T;
label: string; label: string;
}; };
type ItemGroup = { export type ItemGroup<T extends OptionValue = OptionValue> = {
type: 'group'; type: 'group';
label: string; label?: string;
items: ItemOption[]; items: ItemOption<T>[];
}; };
export type MkSelectItem = ItemOption | ItemGroup; export type MkSelectItem<T extends OptionValue = OptionValue> = ItemOption<T> | ItemGroup<T>;
type ValuesOfItems<T> = T extends (infer U)[] export type GetMkSelectValueType<T extends MkSelectItem> = T extends ItemGroup
? U extends { type: 'group'; items: infer V } ? T['items'][number]['value']
? V extends (infer W)[] : T extends ItemOption
? W extends { value: infer X } ? T['value']
? X : never;
: never
: never export type GetMkSelectValueTypesFromDef<T extends MkSelectItem[]> = T[number] extends MkSelectItem
: U extends { value: infer Y } ? GetMkSelectValueType<T[number]>
? Y
: never
: never; : never;
</script> </script>
<script lang="ts" setup generic="T extends MkSelectItem[]"> <script lang="ts" setup generic="const ITEMS extends MkSelectItem[], MODELT extends OptionValue">
import { onMounted, nextTick, ref, watch, computed, toRefs, useSlots } from 'vue'; import { onMounted, nextTick, ref, watch, computed, toRefs } from 'vue';
import { useInterval } from '@@/js/use-interval.js'; import { useInterval } from '@@/js/use-interval.js';
import type { VNode, VNodeChild } from 'vue';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
// TODO: itemsslotoption(props.items)
// see: https://github.com/misskey-dev/misskey/issues/15558
//
const props = defineProps<{ const props = defineProps<{
modelValue: ValuesOfItems<T>; items: ITEMS;
required?: boolean; required?: boolean;
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
@ -88,16 +83,17 @@ const props = defineProps<{
inline?: boolean; inline?: boolean;
small?: boolean; small?: boolean;
large?: boolean; large?: boolean;
items?: T;
}>(); }>();
const emit = defineEmits<{ type ModelTChecked = MODELT & (
(ev: 'update:modelValue', value: ValuesOfItems<T>): void; MODELT extends GetMkSelectValueTypesFromDef<ITEMS>
}>(); ? unknown
: 'Error: The type of model does not match the type of items.'
);
const slots = useSlots(); const model = defineModel<ModelTChecked>({ required: true });
const { modelValue, autofocus } = toRefs(props); const { autofocus } = toRefs(props);
const focused = ref(false); const focused = ref(false);
const opening = ref(false); const opening = ref(false);
const currentValueText = ref<string | null>(null); const currentValueText = ref<string | null>(null);
@ -140,52 +136,26 @@ onMounted(() => {
}); });
}); });
watch([modelValue, () => props.items], () => { watch([model, () => props.items], () => {
if (props.items) { let found: ItemOption | null = null;
let found: ItemOption | null = null; for (const item of props.items) {
for (const item of props.items) { if (item.type === 'group') {
if (item.type === 'group') { for (const option of item.items) {
for (const option of item.items) { if (option.value === model.value) {
if (option.value === modelValue.value) { found = option;
found = option;
break;
}
}
} else {
if (item.value === modelValue.value) {
found = item;
break; break;
} }
} }
} else {
if (item.value === model.value) {
found = item;
break;
}
} }
if (found) {
currentValueText.value = found.label;
}
return;
} }
if (found) {
const scanOptions = (options: VNodeChild[]) => { currentValueText.value = found.label;
for (const vnode of options) { }
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
if (vnode.type === 'optgroup') {
const optgroup = vnode;
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
} else if (Array.isArray(vnode.children)) { //
const fragment = vnode;
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
} else if (vnode.props == null) { // v-if false
// nop?
} else {
const option = vnode;
if (option.props?.value === modelValue.value) {
currentValueText.value = option.children as string;
break;
}
}
}
};
scanOptions(slots.default!());
}, { immediate: true, deep: true }); }, { immediate: true, deep: true });
function show() { function show() {
@ -196,68 +166,32 @@ function show() {
const menu: MenuItem[] = []; const menu: MenuItem[] = [];
if (props.items) { for (const item of props.items) {
for (const item of props.items) { if (item.type === 'group') {
if (item.type === 'group') { if (item.label != null) {
menu.push({ menu.push({
type: 'label', type: 'label',
text: item.label, text: item.label,
}); });
for (const option of item.items) { }
menu.push({ for (const option of item.items) {
text: option.label,
active: computed(() => modelValue.value === option.value),
action: () => {
emit('update:modelValue', option.value);
},
});
}
} else {
menu.push({ menu.push({
text: item.label, text: option.label,
active: computed(() => modelValue.value === item.value), active: computed(() => model.value === option.value),
action: () => { action: () => {
emit('update:modelValue', item.value); model.value = option.value as ModelTChecked;
}, },
}); });
} }
} } else {
} else {
let options = slots.default!();
const pushOption = (option: VNode) => {
menu.push({ menu.push({
text: option.children as string, text: item.label,
active: computed(() => modelValue.value === option.props?.value), active: computed(() => model.value === item.value),
action: () => { action: () => {
emit('update:modelValue', option.props?.value); model.value = item.value as ModelTChecked;
}, },
}); });
}; }
const scanOptions = (options: VNodeChild[]) => {
for (const vnode of options) {
if (typeof vnode !== 'object' || vnode === null || Array.isArray(vnode)) continue;
if (vnode.type === 'optgroup') {
const optgroup = vnode;
menu.push({
type: 'label',
text: optgroup.props?.label,
});
if (Array.isArray(optgroup.children)) scanOptions(optgroup.children);
} else if (Array.isArray(vnode.children)) { //
const fragment = vnode;
if (Array.isArray(fragment.children)) scanOptions(fragment.children);
} else if (vnode.props == null) { // v-if false
// nop?
} else {
const option = vnode;
pushOption(option);
}
}
};
scanOptions(options);
} }
os.popupMenu(menu, container.value, { os.popupMenu(menu, container.value, {

View File

@ -10,7 +10,10 @@ SPDX-License-Identifier: AGPL-3.0-only
:key="item.id" :key="item.id"
v-panel v-panel
:class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]" :class="[$style.item, { [$style.itemWaiting]: item.preprocessing, [$style.itemCompleted]: item.uploaded, [$style.itemFailed]: item.uploadFailed }]"
:style="{ '--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%' }" :style="{
'--p': item.progress != null ? `${item.progress.value / item.progress.max * 100}%` : '0%',
'--pp': item.preprocessProgress != null ? `${item.preprocessProgress * 100}%` : '100%',
}"
@contextmenu.prevent.stop="onContextmenu(item, $event)" @contextmenu.prevent.stop="onContextmenu(item, $event)"
> >
<div :class="$style.itemInner"> <div :class="$style.itemInner">
@ -19,11 +22,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div> <div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ item.thumbnail })` }" @click="onThumbnailClick(item, $event)"></div>
<div :class="$style.itemBody"> <div :class="$style.itemBody">
<div><i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i><MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine></div> <div>
<i v-if="item.isSensitive" style="color: var(--MI_THEME-warn); margin-right: 0.5em;" class="ti ti-eye-exclamation"></i>
<MkCondensedLine :minScale="2 / 3">{{ item.name }}</MkCondensedLine>
</div>
<div :class="$style.itemInfo"> <div :class="$style.itemInfo">
<span>{{ item.file.type }}</span> <span>{{ item.file.type }}</span>
<span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span> <span v-if="item.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(item.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - item.compressedSize / item.file.size) * 100) }) }})</span>
<span v-else>{{ bytes(item.file.size) }}</span> <span v-else>{{ bytes(item.file.size) }}</span>
<span v-if="item.preprocessing">{{ i18n.ts.preprocessing }}<MkLoading inline em style="margin-left: 0.5em;"/></span>
</div> </div>
<div> <div>
</div> </div>
@ -97,7 +104,7 @@ function onThumbnailClick(item: UploaderItem, ev: MouseEvent) {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: var(--pp, 100%);
height: 100%; height: 100%;
background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c)); background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
background-size: 25px 25px; background-size: 25px 25px;

View File

@ -72,7 +72,7 @@ import { getStaticImageUrl } from '@/utility/media-proxy.js';
const props = defineProps<{ const props = defineProps<{
showing: boolean; showing: boolean;
q: string; q: string | Misskey.entities.UserDetailed;
source: HTMLElement; source: HTMLElement;
}>(); }>();
@ -99,10 +99,11 @@ async function fetchUser() {
user.value = props.q; user.value = props.q;
error.value = false; error.value = false;
} else { } else {
const query: Omit<Misskey.entities.UsersShowRequest, 'userIds'> = props.q.startsWith('@') ? const query: Misskey.entities.UsersShowRequest = props.q.startsWith('@') ?
Misskey.acct.parse(props.q.substring(1)) : Misskey.acct.parse(props.q.substring(1)) :
{ userId: props.q }; { userId: props.q };
// @ts-expect-error payload
misskeyApi('users/show', query).then(res => { misskeyApi('users/show', query).then(res => {
if (!props.showing) return; if (!props.showing) return;
user.value = res; user.value = res;

View File

@ -18,6 +18,18 @@ SPDX-License-Identifier: AGPL-3.0-only
></MkPositionSelector> ></MkPositionSelector>
</FormSlot> </FormSlot>
<MkRange
:modelValue="layer.align.margin ?? 0"
:min="0"
:max="0.25"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
@update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'text' }>).align.margin = v"
>
<template #label>{{ i18n.ts._watermarkEditor.margin }}</template>
</MkRange>
<MkRange <MkRange
v-model="layer.scale" v-model="layer.scale"
:min="0" :min="0"
@ -66,6 +78,18 @@ SPDX-License-Identifier: AGPL-3.0-only
></MkPositionSelector> ></MkPositionSelector>
</FormSlot> </FormSlot>
<MkRange
:modelValue="layer.align.margin ?? 0"
:min="0"
:max="0.25"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
@update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'image' }>).align.margin = v"
>
<template #label>{{ i18n.ts._watermarkEditor.margin }}</template>
</MkRange>
<MkRange <MkRange
v-model="layer.scale" v-model="layer.scale"
:min="0" :min="0"
@ -107,6 +131,55 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkSwitch> </MkSwitch>
</template> </template>
<template v-else-if="layer.type === 'qr'">
<MkInput v-model="layer.data" debounce>
<template #label>{{ i18n.ts._watermarkEditor.text }}</template>
<template #caption>{{ i18n.ts._watermarkEditor.leaveBlankToAccountUrl }}</template>
</MkInput>
<FormSlot>
<template #label>{{ i18n.ts._watermarkEditor.position }}</template>
<MkPositionSelector
v-model:x="layer.align.x"
v-model:y="layer.align.y"
></MkPositionSelector>
</FormSlot>
<MkRange
:modelValue="layer.align.margin ?? 0"
:min="0"
:max="0.25"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
@update:modelValue="(v) => (layer as Extract<WatermarkPreset['layers'][number], { type: 'qr' }>).align.margin = v"
>
<template #label>{{ i18n.ts._watermarkEditor.margin }}</template>
</MkRange>
<MkRange
v-model="layer.scale"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.scale }}</template>
</MkRange>
<MkRange
v-model="layer.opacity"
:min="0"
:max="1"
:step="0.01"
:textConverter="(v) => (v * 100).toFixed(1) + '%'"
continuousUpdate
>
<template #label>{{ i18n.ts._watermarkEditor.opacity }}</template>
</MkRange>
</template>
<template v-else-if="layer.type === 'stripe'"> <template v-else-if="layer.type === 'stripe'">
<MkRange <MkRange
v-model="layer.frequency" v-model="layer.frequency"

View File

@ -30,22 +30,12 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
<div :class="$style.controls"> <div :class="$style.controls">
<div class="_spacer _gaps"> <div class="_spacer _gaps">
<MkSelect v-model="type" :items="[{ label: i18n.ts._watermarkEditor.text, value: 'text' }, { label: i18n.ts._watermarkEditor.image, value: 'image' }, { label: i18n.ts._watermarkEditor.advanced, value: 'advanced' }]"> <div class="_gaps_s">
<template #label>{{ i18n.ts._watermarkEditor.type }}</template>
</MkSelect>
<div v-if="type === 'text' || type === 'image'">
<XLayer
v-for="(layer, i) in preset.layers"
:key="layer.id"
v-model:layer="preset.layers[i]"
></XLayer>
</div>
<div v-else-if="type === 'advanced'" class="_gaps_s">
<MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false"> <MkFolder v-for="(layer, i) in preset.layers" :key="layer.id" :defaultOpen="false" :canPage="false">
<template #label> <template #label>
<div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div> <div v-if="layer.type === 'text'">{{ i18n.ts._watermarkEditor.text }}</div>
<div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div> <div v-if="layer.type === 'image'">{{ i18n.ts._watermarkEditor.image }}</div>
<div v-if="layer.type === 'qr'">{{ i18n.ts._watermarkEditor.qr }}</div>
<div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div> <div v-if="layer.type === 'stripe'">{{ i18n.ts._watermarkEditor.stripe }}</div>
<div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div> <div v-if="layer.type === 'polkadot'">{{ i18n.ts._watermarkEditor.polkadot }}</div>
<div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div> <div v-if="layer.type === 'checker'">{{ i18n.ts._watermarkEditor.checker }}</div>
@ -86,6 +76,7 @@ import * as os from '@/os.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
import { ensureSignin } from '@/i.js'; import { ensureSignin } from '@/i.js';
import { genId } from '@/utility/id.js'; import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const $i = ensureSignin(); const $i = ensureSignin();
@ -94,7 +85,7 @@ function createTextLayer(): WatermarkPreset['layers'][number] {
id: genId(), id: genId(),
type: 'text', type: 'text',
text: `(c) @${$i.username}`, text: `(c) @${$i.username}`,
align: { x: 'right', y: 'bottom' }, align: { x: 'right', y: 'bottom', margin: 0 },
scale: 0.3, scale: 0.3,
angle: 0, angle: 0,
opacity: 0.75, opacity: 0.75,
@ -108,7 +99,7 @@ function createImageLayer(): WatermarkPreset['layers'][number] {
type: 'image', type: 'image',
imageId: null, imageId: null,
imageUrl: null, imageUrl: null,
align: { x: 'right', y: 'bottom' }, align: { x: 'right', y: 'bottom', margin: 0 },
scale: 0.3, scale: 0.3,
angle: 0, angle: 0,
opacity: 0.75, opacity: 0.75,
@ -117,6 +108,17 @@ function createImageLayer(): WatermarkPreset['layers'][number] {
}; };
} }
function createQrLayer(): WatermarkPreset['layers'][number] {
return {
id: genId(),
type: 'qr',
data: '',
align: { x: 'right', y: 'bottom', margin: 0 },
scale: 0.3,
opacity: 1,
};
}
function createStripeLayer(): WatermarkPreset['layers'][number] { function createStripeLayer(): WatermarkPreset['layers'][number] {
return { return {
id: genId(), id: genId(),
@ -164,7 +166,7 @@ const props = defineProps<{
const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? { const preset = reactive<WatermarkPreset>(deepClone(props.preset) ?? {
id: genId(), id: genId(),
name: '', name: '',
layers: [createTextLayer()], layers: [],
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -186,17 +188,6 @@ async function cancel() {
dialog.value?.close(); dialog.value?.close();
} }
const type = ref(preset.layers.length > 1 ? 'advanced' : preset.layers[0].type);
watch(type, () => {
if (type.value === 'text') {
preset.layers = [createTextLayer()];
} else if (type.value === 'image') {
preset.layers = [createImageLayer()];
} else if (type.value === 'advanced') {
// nop
}
});
watch(preset, async (newValue, oldValue) => { watch(preset, async (newValue, oldValue) => {
if (renderer != null) { if (renderer != null) {
renderer.setLayers(preset.layers); renderer.setLayers(preset.layers);
@ -326,6 +317,11 @@ function addLayer(ev: MouseEvent) {
action: () => { action: () => {
preset.layers.push(createImageLayer()); preset.layers.push(createImageLayer());
}, },
}, {
text: i18n.ts._watermarkEditor.qr,
action: () => {
preset.layers.push(createQrLayer());
},
}, { }, {
text: i18n.ts._watermarkEditor.stripe, text: i18n.ts._watermarkEditor.stripe,
action: () => { action: () => {

View File

@ -7,9 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.root"> <div :class="$style.root">
<template v-if="edit"> <template v-if="edit">
<header :class="$style.editHeader"> <header :class="$style.editHeader">
<MkSelect v-model="widgetAdderSelected" style="margin-bottom: var(--MI-margin)" data-cy-widget-select> <MkSelect v-model="widgetAdderSelected" :items="widgetAdderSelectedDef" style="margin-bottom: var(--MI-margin)" data-cy-widget-select>
<template #label>{{ i18n.ts.selectWidget }}</template> <template #label>{{ i18n.ts.selectWidget }}</template>
<option v-for="widget in _widgetDefs" :key="widget" :value="widget">{{ i18n.ts._widgets[widget] }}</option>
</MkSelect> </MkSelect>
<MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton> <MkButton inline primary data-cy-widget-add @click="addWidget"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton> <MkButton inline @click="emit('exit')">{{ i18n.ts.close }}</MkButton>
@ -59,6 +58,7 @@ import { widgets as widgetDefs, federationWidgets } from '@/widgets/index.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js'; import { instance } from '@/instance.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@ -89,7 +89,15 @@ const widgetRefs = {};
const configWidget = (id: string) => { const configWidget = (id: string) => {
widgetRefs[id].configure(); widgetRefs[id].configure();
}; };
const widgetAdderSelected = ref<string | null>(null);
const {
model: widgetAdderSelected,
def: widgetAdderSelectedDef,
} = useMkSelect({
items: computed(() => [{ label: i18n.ts.none, value: null }, ..._widgetDefs.value.map(x => ({ label: i18n.ts._widgets[x], value: x }))]),
initialValue: null,
});
const addWidget = () => { const addWidget = () => {
if (widgetAdderSelected.value == null) return; if (widgetAdderSelected.value == null) return;

View File

@ -4,31 +4,39 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<div :class="[$style.root, { [$style.inline]: inline }]"> <component
<a v-if="external" :class="$style.main" class="_button" :href="to" target="_blank"> :is="to ? 'div' : 'button'"
:class="[
$style.root,
{
[$style.inline]: inline,
'_button': !to,
},
]"
>
<component
:is="to ? (external ? 'a' : 'MkA') : 'div'"
:class="[$style.main, { [$style.active]: active }]"
class="_button"
v-bind="to ? (external ? { href: to, target: '_blank' } : { to, behavior }) : {}"
>
<span :class="$style.icon"><slot name="icon"></slot></span> <span :class="$style.icon"><slot name="icon"></slot></span>
<span :class="$style.text"><slot></slot></span> <div :class="$style.headerText">
<div>
<MkCondensedLine :minScale="2 / 3"><slot></slot></MkCondensedLine>
</div>
</div>
<span :class="$style.suffix"> <span :class="$style.suffix">
<span :class="$style.suffixText"><slot name="suffix"></slot></span> <span :class="$style.suffixText"><slot name="suffix"></slot></span>
<i class="ti ti-external-link"></i> <i :class="to && external ? 'ti ti-external-link' : 'ti ti-chevron-right'"></i>
</span> </span>
</a> </component>
<MkA v-else :class="[$style.main, { [$style.active]: active }]" class="_button" :to="to" :behavior="behavior"> </component>
<span :class="$style.icon"><slot name="icon"></slot></span>
<span :class="$style.text"><slot></slot></span>
<span :class="$style.suffix">
<span :class="$style.suffixText"><slot name="suffix"></slot></span>
<i class="ti ti-chevron-right"></i>
</span>
</MkA>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; defineProps<{
to?: string;
const props = defineProps<{
to: string;
active?: boolean; active?: boolean;
external?: boolean; external?: boolean;
behavior?: null | 'window' | 'browser'; behavior?: null | 'window' | 'browser';
@ -75,17 +83,18 @@ const props = defineProps<{
&:empty { &:empty {
display: none; display: none;
& + .text { & + .headerText {
padding-left: 4px; padding-left: 4px;
} }
} }
} }
.text { .headerText {
flex-shrink: 1; white-space: nowrap;
white-space: normal; text-overflow: ellipsis;
text-align: start;
overflow: hidden;
padding-right: 12px; padding-right: 12px;
text-align: center;
} }
.suffix { .suffix {

View File

@ -75,6 +75,7 @@ const common = {
place: '', place: '',
imageUrl: '', imageUrl: '',
dayOfWeek: 7, dayOfWeek: 7,
isSensitive: false,
}, },
}, },
parameters: { parameters: {

View File

@ -14,9 +14,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import isChromatic from 'chromatic/isChromatic'; import isChromatic from 'chromatic/isChromatic';
import { onMounted, onUnmounted, ref, computed } from 'vue'; import { computed } from 'vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { dateTimeFormat } from '@@/js/intl-const.js'; import { dateTimeFormat } from '@@/js/intl-const.js';
import { useLowresTime } from '@/composables/use-lowres-time.js';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
time: Date | string | number | null; time: Date | string | number | null;
@ -46,8 +47,10 @@ const _time = props.time == null ? NaN : getDateSafe(props.time).getTime();
const invalid = Number.isNaN(_time); const invalid = Number.isNaN(_time);
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid; const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
const actualNow = useLowresTime();
const now = computed(() => (props.origin ? props.origin.getTime() : actualNow.value));
// eslint-disable-next-line vue/no-setup-props-reactivity-loss // eslint-disable-next-line vue/no-setup-props-reactivity-loss
const now = ref(props.origin?.getTime() ?? Date.now());
const ago = computed(() => (now.value - _time) / 1000/*ms*/); const ago = computed(() => (now.value - _time) / 1000/*ms*/);
const relative = computed<string>(() => { const relative = computed<string>(() => {
@ -72,29 +75,6 @@ const relative = computed<string>(() => {
i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() }) i18n.tsx._timeIn.seconds({ n: (~~(-ago.value % 60)).toString() })
); );
}); });
let tickId: number;
let currentInterval: number;
function tick() {
now.value = Date.now();
const nextInterval = ago.value < 60 ? 10000 : ago.value < 3600 ? 60000 : 180000;
if (currentInterval !== nextInterval) {
if (tickId) window.clearInterval(tickId);
currentInterval = nextInterval;
tickId = window.setInterval(tick, nextInterval);
}
}
if (!invalid && props.origin === null && (props.mode === 'relative' || props.mode === 'detail')) {
onMounted(() => {
tick();
});
onUnmounted(() => {
if (tickId) window.clearInterval(tickId);
});
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -11,7 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkPageHeader v-else v-model:tab="tab" v-bind="pageHeaderProps"/> <MkPageHeader v-else v-model:tab="tab" v-bind="pageHeaderProps"/>
</template> </template>
<div :class="$style.body"> <div :class="$style.body">
<MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs"> <MkSwiper v-if="prefer.s.enableHorizontalSwipe && swipable && (props.tabs?.length ?? 1) > 1" v-model:tab="tab" :class="$style.swiper" :tabs="props.tabs ?? []">
<slot></slot> <slot></slot>
</MkSwiper> </MkSwiper>
<slot v-else></slot> <slot v-else></slot>
@ -45,7 +45,7 @@ const props = withDefaults(defineProps<PageHeaderProps & {
}); });
const pageHeaderProps = computed(() => { const pageHeaderProps = computed(() => {
const { reversed, ...rest } = props; const { reversed, tab, ...rest } = props;
return rest; return rest;
}); });
@ -75,10 +75,6 @@ defineExpose({
</script> </script>
<style lang="scss" module> <style lang="scss" module>
.root {
}
.body, .swiper { .body, .swiper {
min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px))); min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px)));
} }

View File

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref, readonly, computed } from 'vue';
const time = ref(Date.now());
export const TIME_UPDATE_INTERVAL = 10000; // 10秒
/**
* 使10
* tickを各コンポーネントで行うのではなく
*
* `useLowresTime`使
*/
export const lowresTime = readonly(time);
/**
* 使10
* tickを各コンポーネントで行うのではなく
*
*
*/
export function useLowresTime() {
// lowresTime自体はマウント前の時刻を返す可能性があるため、必ず現在時刻以降を返すことを保証する
const now = Date.now();
return computed(() => Math.max(time.value, now));
}
window.setInterval(() => {
time.value = Date.now();
}, TIME_UPDATE_INTERVAL);

View File

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref } from 'vue';
import type { Ref, MaybeRefOrGetter } from 'vue';
import type { MkSelectItem, OptionValue, GetMkSelectValueTypesFromDef } from '@/components/MkSelect.vue';
type UnwrapReadonlyItems<T> = T extends readonly (infer U)[] ? U[] : T;
/** 指定したオプション定義をもとに型を狭めたrefを生成するコンポーサブル */
export function useMkSelect<
const TItemsInput extends MaybeRefOrGetter<MkSelectItem[]>,
const TItems extends TItemsInput extends MaybeRefOrGetter<infer U> ? U : never,
TInitialValue extends OptionValue | void = void,
TItemsValue = GetMkSelectValueTypesFromDef<UnwrapReadonlyItems<TItems>>,
ModelType = TInitialValue extends void
? TItemsValue
: (TItemsValue | TInitialValue)
>(opts: {
items: TItemsInput;
initialValue?: (TInitialValue | (OptionValue extends TItemsValue ? OptionValue : TInitialValue)) & (
TItemsValue extends TInitialValue
? unknown
: { 'Error: Type of initialValue must include all types of items': TItemsValue }
);
}): {
def: TItemsInput;
model: Ref<ModelType>;
} {
const model = ref(opts.initialValue ?? null);
return {
def: opts.items,
model: model as Ref<ModelType>,
};
}

View File

@ -43,6 +43,12 @@ const IMAGE_EDITING_SUPPORTED_TYPES = [
'image/webp', 'image/webp',
]; ];
const VIDEO_COMPRESSION_SUPPORTED_TYPES = [ // TODO
'video/mp4',
'video/quicktime',
'video/x-matroska',
];
const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES; const WATERMARK_SUPPORTED_TYPES = IMAGE_EDITING_SUPPORTED_TYPES;
const IMAGE_PREPROCESS_NEEDED_TYPES = [ const IMAGE_PREPROCESS_NEEDED_TYPES = [
@ -51,6 +57,10 @@ const IMAGE_PREPROCESS_NEEDED_TYPES = [
...IMAGE_EDITING_SUPPORTED_TYPES, ...IMAGE_EDITING_SUPPORTED_TYPES,
]; ];
const VIDEO_PREPROCESS_NEEDED_TYPES = [
...VIDEO_COMPRESSION_SUPPORTED_TYPES,
];
const mimeTypeMap = { const mimeTypeMap = {
'image/webp': 'webp', 'image/webp': 'webp',
'image/jpeg': 'jpg', 'image/jpeg': 'jpg',
@ -64,6 +74,7 @@ export type UploaderItem = {
progress: { max: number; value: number } | null; progress: { max: number; value: number } | null;
thumbnail: string | null; thumbnail: string | null;
preprocessing: boolean; preprocessing: boolean;
preprocessProgress: number | null;
uploading: boolean; uploading: boolean;
uploaded: Misskey.entities.DriveFile | null; uploaded: Misskey.entities.DriveFile | null;
uploadFailed: boolean; uploadFailed: boolean;
@ -76,6 +87,7 @@ export type UploaderItem = {
isSensitive?: boolean; isSensitive?: boolean;
caption?: string | null; caption?: string | null;
abort?: (() => void) | null; abort?: (() => void) | null;
abortPreprocess?: (() => void) | null;
}; };
function getCompressionSettings(level: 0 | 1 | 2 | 3) { function getCompressionSettings(level: 0 | 1 | 2 | 3) {
@ -129,11 +141,12 @@ export function useUploader(options: {
progress: null, progress: null,
thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null, thumbnail: THUMBNAIL_SUPPORTED_TYPES.includes(file.type) ? window.URL.createObjectURL(file) : null,
preprocessing: false, preprocessing: false,
preprocessProgress: null,
uploading: false, uploading: false,
aborted: false, aborted: false,
uploaded: null, uploaded: null,
uploadFailed: false, uploadFailed: false,
compressionLevel: prefer.s.defaultImageCompressionLevel, compressionLevel: IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultImageCompressionLevel : VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(file.type) ? prefer.s.defaultVideoCompressionLevel : 0,
watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null, watermarkPresetId: uploaderFeatures.value.watermark && $i.policies.watermarkAvailable ? prefer.s.defaultWatermarkPresetId : null,
file: markRaw(file), file: markRaw(file),
}); });
@ -318,7 +331,7 @@ export function useUploader(options: {
} }
if ( if (
IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) && (IMAGE_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type) || VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(item.file.type)) &&
!item.preprocessing && !item.preprocessing &&
!item.uploading && !item.uploading &&
!item.uploaded !item.uploaded
@ -391,6 +404,19 @@ export function useUploader(options: {
removeItem(item); removeItem(item);
}, },
}); });
} else if (item.preprocessing && item.abortPreprocess != null) {
menu.push({
type: 'divider',
}, {
icon: 'ti ti-player-stop',
text: i18n.ts.abort,
danger: true,
action: () => {
if (item.abortPreprocess != null) {
item.abortPreprocess();
}
},
});
} else if (item.uploading) { } else if (item.uploading) {
menu.push({ menu.push({
type: 'divider', type: 'divider',
@ -474,6 +500,10 @@ export function useUploader(options: {
continue; continue;
} }
if (item.abortPreprocess != null) {
item.abortPreprocess();
}
if (item.abort != null) { if (item.abort != null) {
item.abort(); item.abort();
} }
@ -484,18 +514,30 @@ export function useUploader(options: {
async function preprocess(item: UploaderItem): Promise<void> { async function preprocess(item: UploaderItem): Promise<void> {
item.preprocessing = true; item.preprocessing = true;
item.preprocessProgress = null;
try { if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) {
if (IMAGE_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) { try {
await preprocessForImage(item); await preprocessForImage(item);
} } catch (err) {
} catch (err) { console.error('Failed to preprocess image', err);
console.error('Failed to preprocess image', err);
// nop // nop
}
}
if (VIDEO_PREPROCESS_NEEDED_TYPES.includes(item.file.type)) {
try {
await preprocessForVideo(item);
} catch (err) {
console.error('Failed to preprocess video', err);
// nop
}
} }
item.preprocessing = false; item.preprocessing = false;
item.preprocessProgress = null;
} }
async function preprocessForImage(item: UploaderItem): Promise<void> { async function preprocessForImage(item: UploaderItem): Promise<void> {
@ -564,10 +606,74 @@ export function useUploader(options: {
item.preprocessedFile = markRaw(preprocessedFile); item.preprocessedFile = markRaw(preprocessedFile);
} }
onUnmounted(() => { async function preprocessForVideo(item: UploaderItem): Promise<void> {
let preprocessedFile: Blob | File = item.file;
const needsCompress = item.compressionLevel !== 0 && VIDEO_COMPRESSION_SUPPORTED_TYPES.includes(preprocessedFile.type);
if (needsCompress) {
const mediabunny = await import('mediabunny');
const source = new mediabunny.BlobSource(preprocessedFile);
const input = new mediabunny.Input({
source,
formats: mediabunny.ALL_FORMATS,
});
const output = new mediabunny.Output({
target: new mediabunny.BufferTarget(),
format: new mediabunny.Mp4OutputFormat(),
});
const currentConversion = await mediabunny.Conversion.init({
input,
output,
video: {
//width: 320, // Height will be deduced automatically to retain aspect ratio
bitrate: item.compressionLevel === 1 ? mediabunny.QUALITY_VERY_HIGH : item.compressionLevel === 2 ? mediabunny.QUALITY_MEDIUM : mediabunny.QUALITY_VERY_LOW,
},
audio: {
bitrate: item.compressionLevel === 1 ? mediabunny.QUALITY_VERY_HIGH : item.compressionLevel === 2 ? mediabunny.QUALITY_MEDIUM : mediabunny.QUALITY_VERY_LOW,
},
});
currentConversion.onProgress = newProgress => item.preprocessProgress = newProgress;
item.abortPreprocess = () => {
item.abortPreprocess = null;
currentConversion.cancel();
item.preprocessing = false;
item.preprocessProgress = null;
};
await currentConversion.execute();
item.abortPreprocess = null;
preprocessedFile = new Blob([output.target.buffer!], { type: output.format.mimeType });
item.compressedSize = output.target.buffer!.byteLength;
item.uploadName = `${item.name}.mp4`;
} else {
item.compressedSize = null;
item.uploadName = item.name;
}
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
item.thumbnail = THUMBNAIL_SUPPORTED_TYPES.includes(preprocessedFile.type) ? window.URL.createObjectURL(preprocessedFile) : null;
item.preprocessedFile = markRaw(preprocessedFile);
}
function dispose() {
for (const item of items.value) { for (const item of items.value) {
if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail); if (item.thumbnail != null) URL.revokeObjectURL(item.thumbnail);
} }
abortAll();
}
onUnmounted(() => {
dispose();
}); });
return { return {
@ -575,6 +681,7 @@ export function useUploader(options: {
addFiles, addFiles,
removeItem, removeItem,
abortAll, abortAll,
dispose,
upload, upload,
getMenu, getMenu,
uploading: computed(() => items.value.some(item => item.uploading)), uploading: computed(() => items.value.some(item => item.uploading)),

View File

@ -24,7 +24,7 @@ export const globalEvents = new EventEmitter<Events>();
export function useGlobalEvent<T extends keyof Events>( export function useGlobalEvent<T extends keyof Events>(
event: T, event: T,
callback: Events[T], callback: EventEmitter.EventListener<Events, T>,
): void { ): void {
globalEvents.on(event, callback); globalEvents.on(event, callback);
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@ -94,7 +94,7 @@ export class Pizzax<T extends StateDef> {
private mergeState<X>(value: X, def: X): X { private mergeState<X>(value: X, def: X): X {
if (this.isPureObject(value) && this.isPureObject(def)) { if (this.isPureObject(value) && this.isPureObject(def)) {
const merged = deepMerge(value, def); const merged = deepMerge<Record<PropertyKey, unknown>>(value, def);
if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged); if (_DEV_) console.log('Merging state. Incoming: ', value, ' Default: ', def, ' Result: ', merged);

View File

@ -66,6 +66,12 @@ export const navbarItemDef = reactive({
lookup(); lookup();
}, },
}, },
qr: {
title: i18n.ts.qr,
icon: 'ti ti-qrcode',
show: computed(() => $i != null),
to: '/qr',
},
lists: { lists: {
title: i18n.ts.lists, title: i18n.ts.lists,
icon: 'ti ti-list', icon: 'ti ti-list',
@ -111,7 +117,7 @@ export const navbarItemDef = reactive({
to: '/channels', to: '/channels',
}, },
chat: { chat: {
title: i18n.ts.chat, title: i18n.ts.directMessage_short,
icon: 'ti ti-messages', icon: 'ti ti-messages',
to: '/chat', to: '/chat',
show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'), show: computed(() => $i != null && $i.policies.chatAvailability !== 'unavailable'),

View File

@ -14,6 +14,7 @@ import type { Form, GetFormResultType } from '@/utility/form.js';
import type { MenuItem } from '@/types/menu.js'; import type { MenuItem } from '@/types/menu.js';
import type { PostFormProps } from '@/types/post-form.js'; import type { PostFormProps } from '@/types/post-form.js';
import type { UploaderFeatures } from '@/composables/use-uploader.js'; import type { UploaderFeatures } from '@/composables/use-uploader.js';
import type { MkSelectItem, OptionValue } from '@/components/MkSelect.vue';
import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue'; import type MkRoleSelectDialog_TypeReferenceOnly from '@/components/MkRoleSelectDialog.vue';
import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue'; import type MkEmojiPickerDialog_TypeReferenceOnly from '@/components/MkEmojiPickerDialog.vue';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
@ -35,9 +36,9 @@ import { focusParent } from '@/utility/focus.js';
export const openingWindowsCount = ref(0); export const openingWindowsCount = ref(0);
export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>; export type ApiWithDialogCustomErrors = Record<string, { title?: string; text: string; }>;
export const apiWithDialog = (<E extends keyof Misskey.Endpoints, P extends Misskey.Endpoints[E]['req'] = Misskey.Endpoints[E]['req']>( export const apiWithDialog = (<E extends keyof Misskey.Endpoints>(
endpoint: E, endpoint: E,
data: P, data: Misskey.Endpoints[E]['req'],
token?: string | null | undefined, token?: string | null | undefined,
customErrors?: ApiWithDialogCustomErrors, customErrors?: ApiWithDialogCustomErrors,
) => { ) => {
@ -502,50 +503,15 @@ export function authenticateDialog(): Promise<{
}); });
} }
type SelectItem<C> = { export function select<C extends OptionValue, D extends C | null = null>(props: {
value: C;
text: string;
};
// default が指定されていたら result は null になり得ないことを保証する overload function
export function select<C = unknown>(props: {
title?: string; title?: string;
text?: string; text?: string;
default: string; default?: D;
items: (SelectItem<C> | { items: (MkSelectItem<C> | undefined)[];
sectionTitle: string;
items: SelectItem<C>[];
} | undefined)[];
}): Promise<{ }): Promise<{
canceled: true; result: undefined; canceled: true; result: undefined;
} | { } | {
canceled: false; result: C; canceled: false; result: Exclude<D, undefined> extends null ? C | null : C;
}>;
export function select<C = unknown>(props: {
title?: string;
text?: string;
default?: string | null;
items: (SelectItem<C> | {
sectionTitle: string;
items: SelectItem<C>[];
} | undefined)[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}>;
export function select<C = unknown>(props: {
title?: string;
text?: string;
default?: string | null;
items: (SelectItem<C> | {
sectionTitle: string;
items: SelectItem<C>[];
} | undefined)[];
}): Promise<{
canceled: true; result: undefined;
} | {
canceled: false; result: C | null;
}> { }> {
return new Promise(resolve => { return new Promise(resolve => {
const { dispose } = popup(MkDialog, { const { dispose } = popup(MkDialog, {

View File

@ -11,12 +11,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off"> <MkInput v-model="q" class="" :placeholder="i18n.ts.search" autocapitalize="off">
<template #prefix><i class="ti ti-search"></i></template> <template #prefix><i class="ti ti-search"></i></template>
</MkInput> </MkInput>
<!-- たくさんあると邪魔
<div class="tags">
<span class="tag _button" v-for="tag in customEmojiTags" :class="{ active: selectedTags.has(tag) }" @click="toggleTag(tag)">{{ tag }}</span>
</div>
-->
</div> </div>
<MkFoldableSection v-if="searchEmojis"> <MkFoldableSection v-if="searchEmojis">
@ -42,51 +36,33 @@ import XEmoji from './emojis.emoji.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkFoldableSection from '@/components/MkFoldableSection.vue'; import MkFoldableSection from '@/components/MkFoldableSection.vue';
import { customEmojis, customEmojiCategories, getCustomEmojiTags } from '@/custom-emojis.js'; import { customEmojis, customEmojiCategories } from '@/custom-emojis.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
const customEmojiTags = getCustomEmojiTags();
const q = ref(''); const q = ref('');
const searchEmojis = ref<Misskey.entities.EmojiSimple[] | null>(null); const searchEmojis = ref<Misskey.entities.EmojiSimple[] | null>(null);
const selectedTags = ref(new Set());
function search() { function search() {
if ((q.value === '' || q.value == null) && selectedTags.value.size === 0) { if (q.value === '' || q.value == null) {
searchEmojis.value = null; searchEmojis.value = null;
return; return;
} }
if (selectedTags.value.size === 0) { const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g);
const queryarry = q.value.match(/\:([a-z0-9_]*)\:/g);
if (queryarry) { if (queryarry) {
searchEmojis.value = customEmojis.value.filter(emoji => searchEmojis.value = customEmojis.value.filter(emoji =>
queryarry.includes(`:${emoji.name}:`), queryarry.includes(`:${emoji.name}:`),
); );
} else {
searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value));
}
} else { } else {
searchEmojis.value = customEmojis.value.filter(emoji => (emoji.name.includes(q.value) || emoji.aliases.includes(q.value)) && [...selectedTags.value].every(t => emoji.aliases.includes(t))); searchEmojis.value = customEmojis.value.filter(emoji => emoji.name.includes(q.value) || emoji.aliases.includes(q.value));
}
}
function toggleTag(tag) {
if (selectedTags.value.has(tag)) {
selectedTags.value.delete(tag);
} else {
selectedTags.value.add(tag);
} }
} }
watch(q, () => { watch(q, () => {
search(); search();
}); });
watch(selectedTags, () => {
search();
}, { deep: true });
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -11,56 +11,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
</MkInput> </MkInput>
<FormSplit style="margin-top: var(--MI-margin);"> <FormSplit style="margin-top: var(--MI-margin);">
<MkSelect v-model="state"> <MkSelect v-model="state" :items="stateDef">
<template #label>{{ i18n.ts.state }}</template> <template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="federating">{{ i18n.ts.federating }}</option>
<option value="subscribing">{{ i18n.ts.subscribing }}</option>
<option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="silenced">{{ i18n.ts.silence }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="notResponding">{{ i18n.ts.notResponding }}</option>
</MkSelect> </MkSelect>
<MkSelect <MkSelect v-model="sort" :items="sortDef">
v-model="sort" :items="[{
label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`,
value: '+pubSub',
}, {
label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`,
value: '-pubSub',
}, {
label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`,
value: '+notes',
}, {
label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`,
value: '-notes',
}, {
label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`,
value: '+users',
}, {
label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`,
value: '-users',
}, {
label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`,
value: '+following',
}, {
label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`,
value: '-following',
}, {
label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`,
value: '+followers',
}, {
label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`,
value: '-followers',
}, {
label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`,
value: '+firstRetrievedAt',
}, {
label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`,
value: '-firstRetrievedAt',
}] as const"
>
<template #label>{{ i18n.ts.sort }}</template> <template #label>{{ i18n.ts.sort }}</template>
</MkSelect> </MkSelect>
</FormSplit> </FormSplit>
@ -85,11 +39,46 @@ import MkPagination from '@/components/MkPagination.vue';
import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue'; import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const host = ref(''); const host = ref('');
const state = ref('federating'); const {
const sort = ref<NonNullable<Misskey.entities.FederationInstancesRequest['sort']>>('+pubSub'); model: state,
def: stateDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'all' },
{ label: i18n.ts.federating, value: 'federating' },
{ label: i18n.ts.subscribing, value: 'subscribing' },
{ label: i18n.ts.publishing, value: 'publishing' },
{ label: i18n.ts.suspended, value: 'suspended' },
{ label: i18n.ts.silence, value: 'silenced' },
{ label: i18n.ts.blocked, value: 'blocked' },
{ label: i18n.ts.notResponding, value: 'notResponding' },
],
initialValue: 'federating',
});
const {
model: sort,
def: sortDef,
} = useMkSelect({
items: [
{ label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' },
{ label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' },
{ label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' },
{ label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' },
{ label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' },
{ label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' },
{ label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' },
{ label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' },
{ label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' },
{ label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' },
{ label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' },
{ label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' },
],
initialValue: '+pubSub',
});
const paginator = markRaw(new Paginator('federation/instances', { const paginator = markRaw(new Paginator('federation/instances', {
limit: 10, limit: 10,
offsetMode: true, offsetMode: true,

View File

@ -153,17 +153,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'announcements'" class="_gaps"> <div v-else-if="tab === 'announcements'" class="_gaps">
<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton> <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
<MkSelect v-model="announcementsStatus"> <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef">
<template #label>{{ i18n.ts.filter }}</template> <template #label>{{ i18n.ts.filter }}</template>
<option value="active">{{ i18n.ts.active }}</option>
<option value="archived">{{ i18n.ts.archived }}</option>
</MkSelect> </MkSelect>
<MkPagination :paginator="announcementsPaginator"> <MkPagination :paginator="announcementsPaginator">
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <div class="_gaps_s">
<div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)"> <div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)">
<span style="margin-right: 0.5em;"> <span v-if="'icon' in announcement" style="margin-right: 0.5em;">
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i> <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
@ -184,8 +182,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-else-if="tab === 'chart'" class="_gaps_m"> <div v-else-if="tab === 'chart'" class="_gaps_m">
<div class="cmhjzshm"> <div class="cmhjzshm">
<div class="selects"> <div class="selects">
<MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> <MkSelect v-model="chartSrc" :items="chartSrcDef" style="margin: 0 10px 0 0; flex: 1;">
<option value="per-user-notes">{{ i18n.ts.notes }}</option>
</MkSelect> </MkSelect>
</div> </div>
<div class="charts"> <div class="charts">
@ -229,10 +226,12 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { acct } from '@/filters/user.js'; import { acct } from '@/filters/user.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js'; import { ensureSignin, iAmAdmin, iAmModerator } from '@/i.js';
import MkRolePreview from '@/components/MkRolePreview.vue'; import MkRolePreview from '@/components/MkRolePreview.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
import type { ChartSrc } from '@/components/MkChart.vue';
const $i = ensureSignin(); const $i = ensureSignin();
@ -246,7 +245,15 @@ const props = withDefaults(defineProps<{
const result = await _fetch_(); const result = await _fetch_();
const tab = ref(props.initialTab); const tab = ref(props.initialTab);
const chartSrc = ref('per-user-notes'); const {
model: chartSrc,
def: chartSrcDef,
} = useMkSelect({
items: [
{ label: i18n.ts.notes, value: 'per-user-notes' },
],
initialValue: 'per-user-notes',
});
const user = ref(result.user); const user = ref(result.user);
const info = ref(result.info); const info = ref(result.info);
const ips = ref(result.ips); const ips = ref(result.ips);
@ -263,7 +270,16 @@ const filesPaginator = markRaw(new Paginator('admin/drive/files', {
})), })),
})); }));
const announcementsStatus = ref<'active' | 'archived'>('active'); const {
model: announcementsStatus,
def: announcementsStatusDef,
} = useMkSelect({
items: [
{ label: i18n.ts.active, value: 'active' },
{ label: i18n.ts.archived, value: 'archived' },
],
initialValue: 'active',
});
const announcementsPaginator = markRaw(new Paginator('admin/announcements/list', { const announcementsPaginator = markRaw(new Paginator('admin/announcements/list', {
limit: 10, limit: 10,
@ -427,22 +443,22 @@ async function assignRole() {
const { canceled, result: roleId } = await os.select({ const { canceled, result: roleId } = await os.select({
title: i18n.ts._role.chooseRoleToAssign, title: i18n.ts._role.chooseRoleToAssign,
items: roles.map(r => ({ text: r.name, value: r.id })), items: roles.map(r => ({ label: r.name, value: r.id })),
}); });
if (canceled) return; if (canceled || roleId == null) return;
const { canceled: canceled2, result: period } = await os.select({ const { canceled: canceled2, result: period } = await os.select({
title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name, title: i18n.ts.period + ': ' + roles.find(r => r.id === roleId)!.name,
items: [{ items: [{
value: 'indefinitely', text: i18n.ts.indefinitely, value: 'indefinitely', label: i18n.ts.indefinitely,
}, { }, {
value: 'oneHour', text: i18n.ts.oneHour, value: 'oneHour', label: i18n.ts.oneHour,
}, { }, {
value: 'oneDay', text: i18n.ts.oneDay, value: 'oneDay', label: i18n.ts.oneDay,
}, { }, {
value: 'oneWeek', text: i18n.ts.oneWeek, value: 'oneWeek', label: i18n.ts.oneWeek,
}, { }, {
value: 'oneMonth', text: i18n.ts.oneMonth, value: 'oneMonth', label: i18n.ts.oneMonth,
}], }],
default: 'indefinitely', default: 'indefinitely',
}); });

View File

@ -6,26 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps"> <div class="_gaps">
<div :class="$style.header"> <div :class="$style.header">
<MkSelect v-model="type" :class="$style.typeSelect"> <MkSelect v-model="type" :items="typeDef" :class="$style.typeSelect">
<option value="isLocal">{{ i18n.ts._role._condition.isLocal }}</option>
<option value="isRemote">{{ i18n.ts._role._condition.isRemote }}</option>
<option value="isSuspended">{{ i18n.ts._role._condition.isSuspended }}</option>
<option value="isLocked">{{ i18n.ts._role._condition.isLocked }}</option>
<option value="isBot">{{ i18n.ts._role._condition.isBot }}</option>
<option value="isCat">{{ i18n.ts._role._condition.isCat }}</option>
<option value="isExplorable">{{ i18n.ts._role._condition.isExplorable }}</option>
<option value="roleAssignedTo">{{ i18n.ts._role._condition.roleAssignedTo }}</option>
<option value="createdLessThan">{{ i18n.ts._role._condition.createdLessThan }}</option>
<option value="createdMoreThan">{{ i18n.ts._role._condition.createdMoreThan }}</option>
<option value="followersLessThanOrEq">{{ i18n.ts._role._condition.followersLessThanOrEq }}</option>
<option value="followersMoreThanOrEq">{{ i18n.ts._role._condition.followersMoreThanOrEq }}</option>
<option value="followingLessThanOrEq">{{ i18n.ts._role._condition.followingLessThanOrEq }}</option>
<option value="followingMoreThanOrEq">{{ i18n.ts._role._condition.followingMoreThanOrEq }}</option>
<option value="notesLessThanOrEq">{{ i18n.ts._role._condition.notesLessThanOrEq }}</option>
<option value="notesMoreThanOrEq">{{ i18n.ts._role._condition.notesMoreThanOrEq }}</option>
<option value="and">{{ i18n.ts._role._condition.and }}</option>
<option value="or">{{ i18n.ts._role._condition.or }}</option>
<option value="not">{{ i18n.ts._role._condition.not }}</option>
</MkSelect> </MkSelect>
<button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle"> <button v-if="draggable" class="drag-handle _button" :class="$style.dragHandle">
<i class="ti ti-menu-2"></i> <i class="ti ti-menu-2"></i>
@ -58,8 +39,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number"> <MkInput v-else-if="['followersLessThanOrEq', 'followersMoreThanOrEq', 'followingLessThanOrEq', 'followingMoreThanOrEq', 'notesLessThanOrEq', 'notesMoreThanOrEq'].includes(type)" v-model="v.value" type="number">
</MkInput> </MkInput>
<MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId"> <MkSelect v-else-if="type === 'roleAssignedTo'" v-model="v.roleId" :items="assignedToDef">
<option v-for="role in roles.filter(r => r.target === 'manual')" :key="role.id" :value="role.id">{{ role.name }}</option>
</MkSelect> </MkSelect>
</div> </div>
</template> </template>
@ -69,6 +49,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { genId } from '@/utility/id.js'; import { genId } from '@/utility/id.js';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import type { GetMkSelectValueTypesFromDef, MkSelectItem } from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { deepClone } from '@/utility/clone.js'; import { deepClone } from '@/utility/clone.js';
@ -99,7 +80,29 @@ watch(v, () => {
emit('update:modelValue', v.value); emit('update:modelValue', v.value);
}, { deep: true }); }, { deep: true });
const type = computed({ const typeDef = [
{ label: i18n.ts._role._condition.isLocal, value: 'isLocal' },
{ label: i18n.ts._role._condition.isRemote, value: 'isRemote' },
{ label: i18n.ts._role._condition.isSuspended, value: 'isSuspended' },
{ label: i18n.ts._role._condition.isLocked, value: 'isLocked' },
{ label: i18n.ts._role._condition.isBot, value: 'isBot' },
{ label: i18n.ts._role._condition.isCat, value: 'isCat' },
{ label: i18n.ts._role._condition.isExplorable, value: 'isExplorable' },
{ label: i18n.ts._role._condition.roleAssignedTo, value: 'roleAssignedTo' },
{ label: i18n.ts._role._condition.createdLessThan, value: 'createdLessThan' },
{ label: i18n.ts._role._condition.createdMoreThan, value: 'createdMoreThan' },
{ label: i18n.ts._role._condition.followersLessThanOrEq, value: 'followersLessThanOrEq' },
{ label: i18n.ts._role._condition.followersMoreThanOrEq, value: 'followersMoreThanOrEq' },
{ label: i18n.ts._role._condition.followingLessThanOrEq, value: 'followingLessThanOrEq' },
{ label: i18n.ts._role._condition.followingMoreThanOrEq, value: 'followingMoreThanOrEq' },
{ label: i18n.ts._role._condition.notesLessThanOrEq, value: 'notesLessThanOrEq' },
{ label: i18n.ts._role._condition.notesMoreThanOrEq, value: 'notesMoreThanOrEq' },
{ label: i18n.ts._role._condition.and, value: 'and' },
{ label: i18n.ts._role._condition.or, value: 'or' },
{ label: i18n.ts._role._condition.not, value: 'not' },
] as const satisfies MkSelectItem[];
const type = computed<GetMkSelectValueTypesFromDef<typeof typeDef>>({
get: () => v.value.type, get: () => v.value.type,
set: (t) => { set: (t) => {
if (t === 'and') v.value.values = []; if (t === 'and') v.value.values = [];
@ -118,6 +121,8 @@ const type = computed({
}, },
}); });
const assignedToDef = computed(() => roles.filter(r => r.target === 'manual').map(r => ({ label: r.name, value: r.id })) satisfies MkSelectItem[]);
function addValue() { function addValue() {
v.value.values.push({ id: genId(), type: 'isRemote' }); v.value.values.push({ id: genId(), type: 'isRemote' });
} }

View File

@ -22,27 +22,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInput v-model="title"> <MkInput v-model="title">
<template #label>{{ i18n.ts.title }}</template> <template #label>{{ i18n.ts.title }}</template>
</MkInput> </MkInput>
<MkSelect v-model="method"> <MkSelect v-model="method" :items="methodDef">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
<option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
<option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
<template #caption> <template #caption>
{{ methodCaption }} {{ methodCaption }}
</template> </template>
</MkSelect> </MkSelect>
<div> <div>
<MkSelect v-if="method === 'email'" v-model="userId"> <MkSelect v-if="method === 'email'" v-model="userId" :items="userIdDef">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
<option v-for="user in moderators" :key="user.id" :value="user.id">
{{ user.name ? `${user.name}(${user.username})` : user.username }}
</option>
</MkSelect> </MkSelect>
<div v-else-if="method === 'webhook'" :class="$style.systemWebhook"> <div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
<MkSelect v-model="systemWebhookId" style="flex: 1"> <MkSelect v-model="systemWebhookId" :items="systemWebhookIdDef" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
<option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
{{ webhook.name }}
</option>
</MkSelect> </MkSelect>
<MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked"> <MkButton rounded :class="$style.systemWebhookEditButton" @click="onEditSystemWebhookClicked">
<span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/> <span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
@ -79,14 +71,13 @@ import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import MkInput from '@/components/MkInput.vue'; import MkInput from '@/components/MkInput.vue';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js'; import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import MkDivider from '@/components/MkDivider.vue'; import MkDivider from '@/components/MkDivider.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
type NotificationRecipientMethod = 'email' | 'webhook';
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'submitted'): void; (ev: 'submitted'): void;
(ev: 'canceled'): void; (ev: 'canceled'): void;
@ -105,9 +96,28 @@ const dialogEl = useTemplateRef('dialogEl');
const loading = ref<number>(0); const loading = ref<number>(0);
const title = ref<string>(''); const title = ref<string>('');
const method = ref<NotificationRecipientMethod>('email'); const {
const userId = ref<string | null>(null); model: method,
const systemWebhookId = ref<string | null>(null); def: methodDef,
} = useMkSelect({
items: [
{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' },
{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' },
],
initialValue: 'email',
});
const {
model: userId,
def: userIdDef,
} = useMkSelect({
items: computed(() => moderators.value.map(u => ({ label: u.name ? `${u.name}(${u.username})` : u.username, value: u.id as string | null }))),
});
const {
model: systemWebhookId,
def: systemWebhookIdDef,
} = useMkSelect({
items: computed(() => systemWebhooks.value.map(w => ({ label: w.name, value: w.id }))),
});
const isActive = ref<boolean>(true); const isActive = ref<boolean>(true);
const moderators = ref<entities.User[]>([]); const moderators = ref<entities.User[]>([]);

View File

@ -13,11 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton> </MkButton>
</div> </div>
<div :class="$style.subMenus" class="_gaps_s"> <div :class="$style.subMenus" class="_gaps_s">
<MkSelect v-model="filterMethod" style="flex: 1"> <MkSelect v-model="filterMethod" :items="filterMethodDef" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
<option :value="null">-</option>
<option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
<option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
</MkSelect> </MkSelect>
<MkInput v-model="filterText" type="search" style="flex: 1"> <MkInput v-model="filterText" type="search" style="flex: 1">
<template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template> <template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
@ -51,10 +48,21 @@ import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import MkDivider from '@/components/MkDivider.vue'; import MkDivider from '@/components/MkDivider.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]); const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
const filterMethod = ref<string | null>(null); const {
model: filterMethod,
def: filterMethodDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: null },
{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.mail, value: 'email' },
{ label: i18n.ts._abuseReport._notificationRecipient._recipientType.webhook, value: 'webhook' },
],
initialValue: null,
});
const filterText = ref<string>(''); const filterText = ref<string>('');
const filteredRecipients = computed(() => { const filteredRecipients = computed(() => {

View File

@ -16,23 +16,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkTip> </MkTip>
<div :class="$style.inputs" class="_gaps"> <div :class="$style.inputs" class="_gaps">
<MkSelect v-model="state" style="margin: 0; flex: 1;"> <MkSelect v-model="state" :items="stateDef" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.state }}</template> <template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="unresolved">{{ i18n.ts.unresolved }}</option>
<option value="resolved">{{ i18n.ts.resolved }}</option>
</MkSelect> </MkSelect>
<MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> <MkSelect v-model="targetUserOrigin" :items="targetUserOriginDef" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.reporteeOrigin }}</template> <template #label>{{ i18n.ts.reporteeOrigin }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect> </MkSelect>
<MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> <MkSelect v-model="reporterOrigin" :items="reporterOriginDef" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.reporterOrigin }}</template> <template #label>{{ i18n.ts.reporterOrigin }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect> </MkSelect>
</div> </div>
@ -64,13 +55,44 @@ import MkPagination from '@/components/MkPagination.vue';
import XAbuseReport from '@/components/MkAbuseReport.vue'; import XAbuseReport from '@/components/MkAbuseReport.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import { store } from '@/store.js'; import { store } from '@/store.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const state = ref('unresolved'); const {
const reporterOrigin = ref('combined'); model: state,
const targetUserOrigin = ref('combined'); def: stateDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'all' },
{ label: i18n.ts.unresolved, value: 'unresolved' },
{ label: i18n.ts.resolved, value: 'resolved' },
],
initialValue: 'unresolved',
});
const {
model: reporterOrigin,
def: reporterOriginDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'combined' },
{ label: i18n.ts.local, value: 'local' },
{ label: i18n.ts.remote, value: 'remote' },
],
initialValue: 'combined',
});
const {
model: targetUserOrigin,
def: targetUserOriginDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'combined' },
{ label: i18n.ts.local, value: 'local' },
{ label: i18n.ts.remote, value: 'remote' },
],
initialValue: 'combined',
});
const searchUsername = ref(''); const searchUsername = ref('');
const searchHost = ref(''); const searchHost = ref('');

View File

@ -6,27 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs"> <PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_spacer" style="--MI_SPACER-w: 900px;">
<MkSelect v-model="filterType" :class="$style.input" @update:modelValue="filterItems"> <MkSelect v-model="filterType" :items="filterTypeDef" :class="$style.input" @update:modelValue="filterItems">
<template #label>{{ i18n.ts.state }}</template> <template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="expired">{{ i18n.ts.expired }}</option>
</MkSelect> </MkSelect>
<div> <div>
<div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad"> <div v-for="ad in ads" class="_panel _gaps_m" :class="$style.ad">
<MkAd v-if="ad.url" :key="ad.id" :specify="ad"/> <MkAd v-if="ad.url" :key="ad.id" :specify="ad"/>
<MkInput v-model="ad.url" type="url"> <MkInput v-model="ad.url" type="url">
<template #label>URL</template> <template #label>URL</template>
</MkInput> </MkInput>
<MkInput v-model="ad.imageUrl" type="url"> <MkInput v-model="ad.imageUrl" type="url">
<template #label>{{ i18n.ts.imageUrl }}</template> <template #label>{{ i18n.ts.imageUrl }}</template>
</MkInput> </MkInput>
<MkRadios v-model="ad.place"> <MkRadios v-model="ad.place">
<template #label>Form</template> <template #label>Form</template>
<option value="square">square</option> <option value="square">square</option>
<option value="horizontal">horizontal</option> <option value="horizontal">horizontal</option>
<option value="horizontal-big">horizontal-big</option> <option value="horizontal-big">horizontal-big</option>
</MkRadios> </MkRadios>
<!-- <!--
<div style="margin: 32px 0;"> <div style="margin: 32px 0;">
{{ i18n.ts.priority }} {{ i18n.ts.priority }}
@ -35,6 +37,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio>
</div> </div>
--> -->
<FormSplit> <FormSplit>
<MkInput v-model="ad.ratio" type="number"> <MkInput v-model="ad.ratio" type="number">
<template #label>{{ i18n.ts.ratio }}</template> <template #label>{{ i18n.ts.ratio }}</template>
@ -46,6 +49,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.expiration }}</template> <template #label>{{ i18n.ts.expiration }}</template>
</MkInput> </MkInput>
</FormSplit> </FormSplit>
<MkSwitch v-model="ad.isSensitive">
<template #label>{{ i18n.ts.sensitive }}</template>
</MkSwitch>
<MkFolder> <MkFolder>
<template #label>{{ i18n.ts.advancedSettings }}</template> <template #label>{{ i18n.ts.advancedSettings }}</template>
<span> <span>
@ -59,9 +67,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</div> </div>
</span> </span>
</MkFolder> </MkFolder>
<MkTextarea v-model="ad.memo"> <MkTextarea v-model="ad.memo">
<template #label>{{ i18n.ts.memo }}</template> <template #label>{{ i18n.ts.memo }}</template>
</MkTextarea> </MkTextarea>
<div class="_buttons"> <div class="_buttons">
<MkButton inline primary style="margin-right: 12px;" @click="save(ad)"> <MkButton inline primary style="margin-right: 12px;" @click="save(ad)">
<i <i
@ -73,6 +83,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkButton> </MkButton>
</div> </div>
</div> </div>
<MkButton @click="more()"> <MkButton @click="more()">
<i class="ti ti-reload"></i>{{ i18n.ts.more }} <i class="ti ti-reload"></i>{{ i18n.ts.more }}
</MkButton> </MkButton>
@ -91,10 +102,12 @@ import MkRadios from '@/components/MkRadios.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js'; import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const ads = ref<Misskey.entities.Ad[]>([]); const ads = ref<Misskey.entities.Ad[]>([]);
@ -102,7 +115,17 @@ const ads = ref<Misskey.entities.Ad[]>([]);
const localTime = new Date(); const localTime = new Date();
const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000; const localTimeDiff = localTime.getTimezoneOffset() * 60 * 1000;
const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday]; const daysOfWeek: string[] = [i18n.ts._weekday.sunday, i18n.ts._weekday.monday, i18n.ts._weekday.tuesday, i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, i18n.ts._weekday.saturday];
const filterType = ref('all'); const {
model: filterType,
def: filterTypeDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'all' },
{ label: i18n.ts.publishing, value: 'publishing' },
{ label: i18n.ts.expired, value: 'expired' },
],
initialValue: 'all',
});
let publishing: boolean | null = null; let publishing: boolean | null = null;
misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => { misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => {
@ -121,7 +144,7 @@ misskeyApi('admin/ad/list', { publishing: publishing }).then(adsResponse => {
} }
}); });
const filterItems = (v) => { const filterItems = (v: typeof filterType.value) => {
if (v === 'publishing') { if (v === 'publishing') {
publishing = true; publishing = true;
} else if (v === 'expired') { } else if (v === 'expired') {
@ -134,7 +157,7 @@ const filterItems = (v) => {
}; };
// (index) // (index)
function toggleDayOfWeek(ad, index) { function toggleDayOfWeek(ad: Misskey.entities.Ad, index: number) {
ad.dayOfWeek ^= 1 << index; ad.dayOfWeek ^= 1 << index;
} }
@ -150,10 +173,11 @@ function add() {
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
startsAt: new Date().toISOString(), startsAt: new Date().toISOString(),
dayOfWeek: 0, dayOfWeek: 0,
isSensitive: false,
}); });
} }
function remove(ad) { function remove(ad: Misskey.entities.Ad) {
os.confirm({ os.confirm({
type: 'warning', type: 'warning',
text: i18n.tsx.removeAreYouSure({ x: ad.url }), text: i18n.tsx.removeAreYouSure({ x: ad.url }),
@ -169,7 +193,7 @@ function remove(ad) {
}); });
} }
function save(ad) { function save(ad: Misskey.entities.Ad) {
if (ad.id === '') { if (ad.id === '') {
misskeyApi('admin/ad/create', { misskeyApi('admin/ad/create', {
...ad, ...ad,

View File

@ -10,10 +10,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo>
<MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> <MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo>
<MkSelect v-model="announcementsStatus"> <MkSelect v-model="announcementsStatus" :items="announcementsStatusDef">
<template #label>{{ i18n.ts.filter }}</template> <template #label>{{ i18n.ts.filter }}</template>
<option value="active">{{ i18n.ts.active }}</option>
<option value="archived">{{ i18n.ts.archived }}</option>
</MkSelect> </MkSelect>
<MkLoading v-if="loading"/> <MkLoading v-if="loading"/>
@ -98,8 +96,18 @@ import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
import MkTextarea from '@/components/MkTextarea.vue'; import MkTextarea from '@/components/MkTextarea.vue';
import { genId } from '@/utility/id.js'; import { genId } from '@/utility/id.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
const announcementsStatus = ref<'active' | 'archived'>('active'); const {
model: announcementsStatus,
def: announcementsStatusDef,
} = useMkSelect({
items: [
{ label: i18n.ts.active, value: 'active' },
{ label: i18n.ts.archived, value: 'archived' },
],
initialValue: 'active',
});
const loading = ref(true); const loading = ref(true);
const loadingMore = ref(false); const loadingMore = ref(false);

View File

@ -56,20 +56,24 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkInput> </MkInput>
<MkSelect <MkSelect
v-model="model.sensitive" v-model="model.sensitive"
:items="[
{ label: '-', value: null },
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]"
> >
<template #label>sensitive</template> <template #label>sensitive</template>
<option :value="null">-</option>
<option :value="true">true</option>
<option :value="false">false</option>
</MkSelect> </MkSelect>
<MkSelect <MkSelect
v-model="model.localOnly" v-model="model.localOnly"
:items="[
{ label: '-', value: null },
{ label: 'true', value: 'true' },
{ label: 'false', value: 'false' },
]"
> >
<template #label>localOnly</template> <template #label>localOnly</template>
<option :value="null">-</option>
<option :value="true">true</option>
<option :value="false">false</option>
</MkSelect> </MkSelect>
<MkInput <MkInput
v-model="model.updatedAtFrom" v-model="model.updatedAtFrom"

View File

@ -12,11 +12,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template> <template #caption>{{ i18n.ts._customEmojisManager._local._register.uploadSettingDescription }}</template>
<div class="_gaps"> <div class="_gaps">
<MkSelect v-model="selectedFolderId"> <MkSelect v-model="selectedFolderId" :items="selectedFolderIdDef">
<template #label>{{ i18n.ts.uploadFolder }}</template> <template #label>{{ i18n.ts.uploadFolder }}</template>
<option v-for="folder in uploadFolders" :key="folder.id" :value="folder.id">
{{ folder.name }}
</option>
</MkSelect> </MkSelect>
<MkSwitch v-model="directoryToCategory"> <MkSwitch v-model="directoryToCategory">
@ -63,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<script setup lang="ts"> <script setup lang="ts">
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as Misskey from 'misskey-js'; import * as Misskey from 'misskey-js';
import { onMounted, ref, useCssModule } from 'vue'; import { computed, onMounted, ref, useCssModule } from 'vue';
import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js'; import type { RequestLogItem } from '@/pages/admin/custom-emojis-manager.impl.js';
import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js'; import type { GridCellValidationEvent, GridCellValueChangeEvent, GridEvent } from '@/components/grid/grid-event.js';
import type { DroppedFile } from '@/utility/file-drop.js'; import type { DroppedFile } from '@/utility/file-drop.js';
@ -87,6 +84,7 @@ import { chooseDriveFile, chooseFileFromPcAndUpload } from '@/utility/drive.js';
import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js'; import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue'; import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js'; import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
@ -229,7 +227,13 @@ function setupGrid(): GridSetting {
const uploadFolders = ref<FolderItem[]>([]); const uploadFolders = ref<FolderItem[]>([]);
const gridItems = ref<GridItem[]>([]); const gridItems = ref<GridItem[]>([]);
const selectedFolderId = ref(prefer.s.uploadFolder); const {
model: selectedFolderId,
def: selectedFolderIdDef,
} = useMkSelect({
items: computed(() => uploadFolders.value.map(folder => ({ label: folder.name, value: folder.id || '' }))),
initialValue: prefer.s.uploadFolder,
});
const directoryToCategory = ref<boolean>(false); const directoryToCategory = ref<boolean>(false);
const registerButtonDisabled = ref<boolean>(false); const registerButtonDisabled = ref<boolean>(false);
const requestLogs = ref<RequestLogItem[]>([]); const requestLogs = ref<RequestLogItem[]>([]);
@ -303,8 +307,8 @@ async function onFileSelectClicked() {
const driveFiles = await chooseFileFromPcAndUpload({ const driveFiles = await chooseFileFromPcAndUpload({
multiple: true, multiple: true,
folderId: selectedFolderId.value, folderId: selectedFolderId.value,
// // //
nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''), // nameConverter: (file) => file.name.replace(/\.[a-zA-Z0-9]+$/, ''),
}); });
gridItems.value.push(...driveFiles.map(fromDriveFile)); gridItems.value.push(...driveFiles.map(fromDriveFile));

View File

@ -26,10 +26,10 @@ const chartEl = useTemplateRef('chartEl');
const { handler: externalTooltipHandler } = useChartTooltip(); const { handler: externalTooltipHandler } = useChartTooltip();
let chartInstance: Chart; let chartInstance: Chart | null = null;
function setData(values) { function setData(values) {
if (chartInstance == null) return; if (chartInstance == null || chartInstance.data.labels == null) return;
for (const value of values) { for (const value of values) {
chartInstance.data.labels.push(''); chartInstance.data.labels.push('');
chartInstance.data.datasets[0].data.push(value); chartInstance.data.datasets[0].data.push(value);
@ -42,7 +42,7 @@ function setData(values) {
} }
function pushData(value) { function pushData(value) {
if (chartInstance == null) return; if (chartInstance == null || chartInstance.data.labels == null) return;
chartInstance.data.labels.push(''); chartInstance.data.labels.push('');
chartInstance.data.datasets[0].data.push(value); chartInstance.data.datasets[0].data.push(value);
if (chartInstance.data.datasets[0].data.length > 200) { if (chartInstance.data.datasets[0].data.length > 200) {
@ -69,6 +69,8 @@ const color =
onMounted(() => { onMounted(() => {
const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; const vLineColor = store.s.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)';
if (chartEl.value == null) return;
chartInstance = new Chart(chartEl.value, { chartInstance = new Chart(chartEl.value, {
type: 'line', type: 'line',
data: { data: {

View File

@ -13,31 +13,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
</MkInput> </MkInput>
<FormSplit style="margin-top: var(--MI-margin);"> <FormSplit style="margin-top: var(--MI-margin);">
<MkSelect v-model="state"> <MkSelect v-model="state" :items="stateDef">
<template #label>{{ i18n.ts.state }}</template> <template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="federating">{{ i18n.ts.federating }}</option>
<option value="subscribing">{{ i18n.ts.subscribing }}</option>
<option value="publishing">{{ i18n.ts.publishing }}</option>
<option value="suspended">{{ i18n.ts.suspended }}</option>
<option value="blocked">{{ i18n.ts.blocked }}</option>
<option value="silenced">{{ i18n.ts.silence }}</option>
<option value="notResponding">{{ i18n.ts.notResponding }}</option>
</MkSelect> </MkSelect>
<MkSelect v-model="sort"> <MkSelect v-model="sort" :items="sortDef">
<template #label>{{ i18n.ts.sort }}</template> <template #label>{{ i18n.ts.sort }}</template>
<option value="+pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-pubSub">{{ i18n.ts.pubSub }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+notes">{{ i18n.ts.notes }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-notes">{{ i18n.ts.notes }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+users">{{ i18n.ts.users }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-users">{{ i18n.ts.users }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+following">{{ i18n.ts.following }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-following">{{ i18n.ts.following }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+followers">{{ i18n.ts.followers }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-followers">{{ i18n.ts.followers }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="+firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="-firstRetrievedAt">{{ i18n.ts.registeredAt }} ({{ i18n.ts.ascendingOrder }})</option>
</MkSelect> </MkSelect>
</FormSplit> </FormSplit>
</div> </div>
@ -64,11 +44,46 @@ import MkInstanceCardMini from '@/components/MkInstanceCardMini.vue';
import FormSplit from '@/components/form/split.vue'; import FormSplit from '@/components/form/split.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const host = ref(''); const host = ref('');
const state = ref('federating'); const {
const sort = ref('+pubSub'); model: state,
def: stateDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'all' },
{ label: i18n.ts.federating, value: 'federating' },
{ label: i18n.ts.subscribing, value: 'subscribing' },
{ label: i18n.ts.publishing, value: 'publishing' },
{ label: i18n.ts.suspended, value: 'suspended' },
{ label: i18n.ts.blocked, value: 'blocked' },
{ label: i18n.ts.silence, value: 'silenced' },
{ label: i18n.ts.notResponding, value: 'notResponding' },
],
initialValue: 'federating',
});
const {
model: sort,
def: sortDef,
} = useMkSelect({
items: [
{ label: `${i18n.ts.pubSub} (${i18n.ts.descendingOrder})`, value: '+pubSub' },
{ label: `${i18n.ts.pubSub} (${i18n.ts.ascendingOrder})`, value: '-pubSub' },
{ label: `${i18n.ts.notes} (${i18n.ts.descendingOrder})`, value: '+notes' },
{ label: `${i18n.ts.notes} (${i18n.ts.ascendingOrder})`, value: '-notes' },
{ label: `${i18n.ts.users} (${i18n.ts.descendingOrder})`, value: '+users' },
{ label: `${i18n.ts.users} (${i18n.ts.ascendingOrder})`, value: '-users' },
{ label: `${i18n.ts.following} (${i18n.ts.descendingOrder})`, value: '+following' },
{ label: `${i18n.ts.following} (${i18n.ts.ascendingOrder})`, value: '-following' },
{ label: `${i18n.ts.followers} (${i18n.ts.descendingOrder})`, value: '+followers' },
{ label: `${i18n.ts.followers} (${i18n.ts.ascendingOrder})`, value: '-followers' },
{ label: `${i18n.ts.registeredAt} (${i18n.ts.descendingOrder})`, value: '+firstRetrievedAt' },
{ label: `${i18n.ts.registeredAt} (${i18n.ts.ascendingOrder})`, value: '-firstRetrievedAt' },
],
initialValue: '+pubSub',
});
const paginator = markRaw(new Paginator('federation/instances', { const paginator = markRaw(new Paginator('federation/instances', {
limit: 10, limit: 10,
offsetMode: true, offsetMode: true,

View File

@ -8,11 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_spacer" style="--MI_SPACER-w: 900px;">
<div class="_gaps"> <div class="_gaps">
<div class="inputs" style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;"> <div class="inputs" style="display: flex; gap: var(--MI-margin); flex-wrap: wrap;">
<MkSelect v-model="origin" style="margin: 0; flex: 1;"> <MkSelect v-model="origin" :items="originDef" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.instance }}</template> <template #label>{{ i18n.ts.instance }}</template>
<option value="combined">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.local }}</option>
<option value="remote">{{ i18n.ts.remote }}</option>
</MkSelect> </MkSelect>
<MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams?.value?.origin === 'local'"> <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="paginator.computedParams?.value?.origin === 'local'">
<template #label>{{ i18n.ts.host }}</template> <template #label>{{ i18n.ts.host }}</template>
@ -42,9 +39,20 @@ import * as os from '@/os.js';
import { lookupFile } from '@/utility/admin-lookup.js'; import { lookupFile } from '@/utility/admin-lookup.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const origin = ref<NonNullable<Misskey.entities.AdminDriveFilesRequest['origin']>>('local'); const {
model: origin,
def: originDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'combined' },
{ label: i18n.ts.local, value: 'local' },
{ label: i18n.ts.remote, value: 'remote' },
],
initialValue: 'local',
});
const type = ref<string | null>(null); const type = ref<string | null>(null);
const searchHost = ref(''); const searchHost = ref('');
const userId = ref(''); const userId = ref('');

View File

@ -26,19 +26,11 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkFolder> </MkFolder>
<div :class="$style.inputs"> <div :class="$style.inputs">
<MkSelect v-model="type" :class="$style.input"> <MkSelect v-model="type" :items="typeDef" :class="$style.input">
<template #label>{{ i18n.ts.state }}</template> <template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="unused">{{ i18n.ts.unused }}</option>
<option value="used">{{ i18n.ts.used }}</option>
<option value="expired">{{ i18n.ts.expired }}</option>
</MkSelect> </MkSelect>
<MkSelect v-model="sort" :class="$style.input"> <MkSelect v-model="sort" :items="sortDef" :class="$style.input">
<template #label>{{ i18n.ts.sort }}</template> <template #label>{{ i18n.ts.sort }}</template>
<option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
</MkSelect> </MkSelect>
</div> </div>
<MkPagination :paginator="paginator"> <MkPagination :paginator="paginator">
@ -67,10 +59,33 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkPagination from '@/components/MkPagination.vue'; import MkPagination from '@/components/MkPagination.vue';
import MkInviteCode from '@/components/MkInviteCode.vue'; import MkInviteCode from '@/components/MkInviteCode.vue';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const type = ref<NonNullable<Misskey.entities.AdminInviteListRequest['type']>>('all'); const {
const sort = ref<NonNullable<Misskey.entities.AdminInviteListRequest['sort']>>('+createdAt'); model: type,
def: typeDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: 'all' },
{ label: i18n.ts.unused, value: 'unused' },
{ label: i18n.ts.used, value: 'used' },
{ label: i18n.ts.expired, value: 'expired' },
],
initialValue: 'all',
});
const {
model: sort,
def: sortDef,
} = useMkSelect({
items: [
{ label: `${i18n.ts.createdAt} (${i18n.ts.ascendingOrder})`, value: '+createdAt' },
{ label: `${i18n.ts.createdAt} (${i18n.ts.descendingOrder})`, value: '-createdAt' },
{ label: `${i18n.ts.usedAt} (${i18n.ts.ascendingOrder})`, value: '+usedAt' },
{ label: `${i18n.ts.usedAt} (${i18n.ts.descendingOrder})`, value: '-usedAt' },
],
initialValue: '+createdAt',
});
const paginator = markRaw(new Paginator('admin/invite/list', { const paginator = markRaw(new Paginator('admin/invite/list', {
limit: 10, limit: 10,

View File

@ -210,6 +210,7 @@ async function fetchCurrentQueue() {
} }
async function fetchJobs() { async function fetchJobs() {
if (tab.value === '-') return;
jobsFetching.value = true; jobsFetching.value = true;
const state = jobState.value; const state = jobState.value;
jobs.value = await misskeyApi('admin/queue/jobs', { jobs.value = await misskeyApi('admin/queue/jobs', {
@ -307,6 +308,7 @@ async function removeJobs() {
} }
async function refreshJob(jobId: string) { async function refreshJob(jobId: string) {
if (tab.value === '-') return;
const newJob = await misskeyApi('admin/queue/show-job', { queue: tab.value, jobId }); const newJob = await misskeyApi('admin/queue/show-job', { queue: tab.value, jobId });
const index = jobs.value.findIndex((job) => job.id === jobId); const index = jobs.value.findIndex((job) => job.id === jobId);
if (index !== -1) { if (index !== -1) {

View File

@ -25,18 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</SearchMarker> </SearchMarker>
<SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']"> <SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']">
<MkSelect <MkSelect v-model="ugcVisibilityForVisitor" :items="ugcVisibilityForVisitorDef" @update:modelValue="onChange_ugcVisibilityForVisitor">
v-model="ugcVisibilityForVisitor" :items="[{
value: 'all',
label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all,
}, {
value: 'local',
label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly + ' (' + i18n.ts.recommended + ')',
}, {
value: 'none',
label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none,
}] as const" @update:modelValue="onChange_ugcVisibilityForVisitor"
>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template> <template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template>
<template #caption> <template #caption>
<div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div> <div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div>
@ -176,6 +165,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js'; import { fetchInstance } from '@/instance.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import FormLink from '@/components/form/link.vue'; import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue'; import MkFolder from '@/components/MkFolder.vue';
@ -185,7 +175,17 @@ const meta = await misskeyApi('admin/meta');
const enableRegistration = ref(!meta.disableRegistration); const enableRegistration = ref(!meta.disableRegistration);
const emailRequiredForSignup = ref(meta.emailRequiredForSignup); const emailRequiredForSignup = ref(meta.emailRequiredForSignup);
const ugcVisibilityForVisitor = ref(meta.ugcVisibilityForVisitor); const {
model: ugcVisibilityForVisitor,
def: ugcVisibilityForVisitorDef,
} = useMkSelect({
items: [
{ label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all, value: 'all' },
{ label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly, value: 'local' },
{ label: i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none, value: 'none' },
],
initialValue: meta.ugcVisibilityForVisitor,
});
const sensitiveWords = ref(meta.sensitiveWords.join('\n')); const sensitiveWords = ref(meta.sensitiveWords.join('\n'));
const prohibitedWords = ref(meta.prohibitedWords.join('\n')); const prohibitedWords = ref(meta.prohibitedWords.join('\n'));
const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n')); const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n'));
@ -221,7 +221,7 @@ function onChange_emailRequiredForSignup(value: boolean) {
}); });
} }
function onChange_ugcVisibilityForVisitor(value: Misskey.entities.AdminUpdateMetaRequest['ugcVisibilityForVisitor']) { function onChange_ugcVisibilityForVisitor(value: typeof ugcVisibilityForVisitor.value) {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
ugcVisibilityForVisitor: value, ugcVisibilityForVisitor: value,
}).then(() => { }).then(() => {

View File

@ -8,10 +8,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_spacer" style="--MI_SPACER-w: 900px;"> <div class="_spacer" style="--MI_SPACER-w: 900px;">
<div class="_gaps"> <div class="_gaps">
<MkPaginationControl :paginator="paginator" canFilter> <MkPaginationControl :paginator="paginator" canFilter>
<MkSelect v-model="type" style="margin: 0; flex: 1;"> <MkSelect v-model="type" :items="typeDef" style="margin: 0; flex: 1;">
<template #label>{{ i18n.ts.type }}</template> <template #label>{{ i18n.ts.type }}</template>
<option :value="null">{{ i18n.ts.all }}</option>
<option v-for="t in Misskey.moderationLogTypes" :key="t" :value="t">{{ i18n.ts._moderationLogTypes[t] ?? t }}</option>
</MkSelect> </MkSelect>
<MkInput v-model="moderatorId" style="margin: 0; flex: 1;"> <MkInput v-model="moderatorId" style="margin: 0; flex: 1;">
@ -54,12 +52,22 @@ import MkTl from '@/components/MkTl.vue';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js'; import { definePage } from '@/page.js';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import { useMkSelect } from '@/composables/use-mkselect.js';
import MkPullToRefresh from '@/components/MkPullToRefresh.vue'; import MkPullToRefresh from '@/components/MkPullToRefresh.vue';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import MkPaginationControl from '@/components/MkPaginationControl.vue'; import MkPaginationControl from '@/components/MkPaginationControl.vue';
import { Paginator } from '@/utility/paginator.js'; import { Paginator } from '@/utility/paginator.js';
const type = ref<string | null>(null); const {
model: type,
def: typeDef,
} = useMkSelect({
items: [
{ label: i18n.ts.all, value: null },
...Misskey.moderationLogTypes.map(t => ({ label: i18n.ts._moderationLogTypes[t] ?? t, value: t })),
],
initialValue: null,
});
const moderatorId = ref(''); const moderatorId = ref('');
const paginator = markRaw(new Paginator('admin/show-moderation-logs', { const paginator = markRaw(new Paginator('admin/show-moderation-logs', {

View File

@ -26,7 +26,7 @@ initChart();
const chartEl = useTemplateRef('chartEl'); const chartEl = useTemplateRef('chartEl');
const now = new Date(); const now = new Date();
let chartInstance: Chart = null; let chartInstance: Chart | null = null;
const chartLimit = 7; const chartLimit = 7;
const fetching = ref(true); const fetching = ref(true);

View File

@ -23,9 +23,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="item _panel sub"> <div class="item _panel sub">
<div class="icon"><i class="ti ti-world-download"></i></div> <div class="icon"><i class="ti ti-world-download"></i></div>
<div class="body"> <div class="body">
<div class="value"> <div v-if="federationSubActive != null" class="value">
{{ number(federationSubActive) }} {{ number(federationSubActive) }}
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff> <MkNumberDiff v-if="federationSubActiveDiff != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"></MkNumberDiff>
</div> </div>
<div class="label">Sub</div> <div class="label">Sub</div>
</div> </div>
@ -33,9 +33,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="item _panel pub"> <div class="item _panel pub">
<div class="icon"><i class="ti ti-world-upload"></i></div> <div class="icon"><i class="ti ti-world-upload"></i></div>
<div class="body"> <div class="body">
<div class="value"> <div v-if="federationPubActive != null" class="value">
{{ number(federationPubActive) }} {{ number(federationPubActive) }}
<MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff> <MkNumberDiff v-if="federationPubActiveDiff != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"></MkNumberDiff>
</div> </div>
<div class="label">Pub</div> <div class="label">Pub</div>
</div> </div>

View File

@ -5,23 +5,30 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_panel" :class="$style.root"> <div class="_panel" :class="$style.root">
<MkSelect v-model="src" style="margin: 0 0 12px 0;" small> <MkSelect v-model="src" :items="srcDef" style="margin: 0 0 12px 0;" small>
<option value="active-users">Active users</option>
<option value="notes">Notes</option>
<option value="ap-requests-inbox-received">AP Requests: inboxReceived</option>
<option value="ap-requests-deliver-succeeded">AP Requests: deliverSucceeded</option>
<option value="ap-requests-deliver-failed">AP Requests: deliverFailed</option>
</MkSelect> </MkSelect>
<MkHeatmap :src="src"/> <MkHeatmap :src="src"/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue';
import MkHeatmap from '@/components/MkHeatmap.vue'; import MkHeatmap from '@/components/MkHeatmap.vue';
import MkSelect from '@/components/MkSelect.vue'; import MkSelect from '@/components/MkSelect.vue';
import { useMkSelect } from '@/composables/use-mkselect.js';
const src = ref('active-users'); const {
model: src,
def: srcDef,
} = useMkSelect({
items: [
{ label: 'Active users', value: 'active-users' },
{ label: 'Notes', value: 'notes' },
{ label: 'AP Requests: inboxReceived', value: 'ap-requests-inbox-received' },
{ label: 'AP Requests: deliverSucceeded', value: 'ap-requests-deliver-succeeded' },
{ label: 'AP Requests: deliverFailed', value: 'ap-requests-deliver-failed' },
],
initialValue: 'active-users',
});
</script> </script>
<style lang="scss" module> <style lang="scss" module>

Some files were not shown because too many files have changed in this diff Show More