Merge branch 'develop' into sw-file

This commit is contained in:
tamaina 2025-08-08 19:53:54 +09:00 committed by GitHub
commit 2464b552e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
155 changed files with 8842 additions and 3643 deletions

View File

@ -50,6 +50,7 @@ jobs:
"packages/backend/test"
"packages/frontend-shared/@types"
"packages/frontend-shared/js"
"packages/frontend-builder"
"packages/frontend/.storybook"
"packages/frontend/@types"
"packages/frontend/lib"

View File

@ -9,6 +9,7 @@ on:
- packages/backend/**
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/frontend-embed/**
- packages/icons-subsetter/**
- packages/sw/**
@ -22,6 +23,7 @@ on:
- packages/backend/**
- packages/frontend/**
- packages/frontend-shared/**
- packages/frontend-builder/**
- packages/frontend-embed/**
- packages/icons-subsetter/**
- packages/sw/**
@ -56,6 +58,7 @@ jobs:
- backend
- frontend
- frontend-shared
- frontend-builder
- frontend-embed
- icons-subsetter
- sw

View File

@ -13,14 +13,27 @@
- 増加量を抑えるには、最大処理継続時間をデフォルトより短くしてください。
- サーバーの初期設定が完了するまでは連合がオンにならないようになりました
- 日本語における公開範囲名称の「ダイレクト」が「指名」に改称されました
- mfm.jsをアップデートしました
- Enhance: Unicode 15.1 および 16.0 に収録されている絵文字に対応
- Enhance: acctに `.` が入っているユーザーのメンションに対応
- Fix: Unicode絵文字に隣接する異体字セレクタ`U+FE0F`)が絵文字として認識される問題を修正
### Client
- Feat: AiScriptが1.0に更新されました
- プラグインは1.0に対応したものが必要です
- Playはそのまま動作しますが、新規に作られるプリセットは1.0になります
- 以前のバージョンから無効化されていた note_view_interruptor が有効になりました
- Feat: セーフモード
- プラグイン・テーマ・カスタムCSSの使用でクライアントの起動に問題が発生した際に、これらを無効にして起動できます
- 以下の方法でセーフモードを起動できます
- `g` キーを連打する
- URLに`?safemode=true`を付ける
- PWAのショートカットで Safemode を選択して起動する
- Feat: ページのタブバーを下部に表示できるように
- Enhance: コントロールパネルを検索できるように
- Enhance: トルコ語 (tr-TR) に対応
- Enhance: 言語別のスクリプトバンドルを生成するように
- Fix: 投稿フォームでファイルのアップロードが中止または失敗した際のハンドリングを修正
- Fix: 一部の設定検索結果が存在しないパスになる問題を修正
(Cherry-picked from https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1171)
- Fix: テーマエディタが動作しない問題を修正

View File

@ -618,3 +618,23 @@ color: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
color: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
```
## 考え方
### DRYに囚われるな
必要なのは一般化ではなく抽象化と考えます。
盲信せず、誤った・不必要な共通化は避け、それが自然だと感じる場合は重複させる勇気を持ちましょう。
### Misskeyを複雑にしない実装
それがいくら複雑であっても、Misskey固有のコンテキストと関心が分離されている(もしくは事実上分離されていると見做すことができる)実装であれば、それはMisskeyのコードベースに対する複雑性に影響を与えないと考えます。
例えるなら、VueやAiScriptといったMisskeyが使用しているライブラリの内部実装がいくら複雑だったとしても、「それを使用しているからMisskeyの実装は複雑である」ということにはならないのと同じです。
Misskeyのドメイン知識から関心が分離されているということは、Misskeyの実装について考える時にそれらの内部実装を考慮する必要が無く、認知負荷を増やさないからです。
また重要な点は、その実装が、Misskeyリポジトリの外部にあるか・内部にあるかということや、Misskeyがメンテナンスするものか・第三者がメンテナンスするものかといったことは複雑性を考える上ではほとんど無視できるという点です。
もちろんその実装がMisskeyリポジトリにあり、Misskeyがメンテナンスしなければならないものは、保守のコストはかかります。
しかし、Misskeyの本質的な設計・実装という観点で見たときは、その実装は実質的に外部ライブラリのように振る舞います。
換言すれば「たまたまMisskeyの開発者と同じ人たちがメンテナンスしているし、たまたまMisskeyのリポジトリ内に置いてあるだけの外部ライブラリ」です。
そのため、実装をなるべくMisskeyのドメイン知識から独立したものにすれば、Misskeyのコードベースの複雑性を上げることなく機能実装を行うことができ、お得であると言えます。
もちろんそれにこだわって、些細な実装でもそのように分離してしまうとかえって認知負荷が増えたり、実装量が増えてメリットをデメリットが上回る場合もあるので、ケースバイケースではあります。

View File

@ -23,6 +23,7 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
COPY --link ["packages/frontend-shared/package.json", "./packages/frontend-shared/"]
COPY --link ["packages/frontend/package.json", "./packages/frontend/"]
COPY --link ["packages/frontend-embed/package.json", "./packages/frontend-embed/"]
COPY --link ["packages/frontend-builder/package.json", "./packages/frontend-builder/"]
COPY --link ["packages/icons-subsetter/package.json", "./packages/icons-subsetter/"]
COPY --link ["packages/sw/package.json", "./packages/sw/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]

View File

@ -68,7 +68,7 @@ receiveFollowRequest: "تلقيت طلب متابعة"
followRequestAccepted: "قُبل طلب المتابعة"
mention: "أشر الى"
mentions: "الإشارات"
directNotes: "الملاحظات المباشرة"
directNotes: "رسالة خاصة"
importAndExport: "إستورد / صدر"
import: "استيراد"
export: "تصدير"

View File

@ -1370,9 +1370,13 @@ defaultImageCompressionLevel: "Nivell de comprensió de la imatge per defecte"
defaultImageCompressionLevel_description: "Baixa, conserva la qualitat de la imatge però la mida de l'arxiu és més gran. <br>Alta, redueix la mida de l'arxiu però també la qualitat de la imatge."
inMinutes: "Minut(s)"
inDays: "Di(a)(es)"
safeModeEnabled: "Mode segur activat"
pluginsAreDisabledBecauseSafeMode: "Els afegits no estan activats perquè el mode segur està activat."
customCssIsDisabledBecauseSafeMode: "El CSS personalitzat no s'aplica perquè el mode segur es troba activat."
themeIsDefaultBecauseSafeMode: "El tema predeterminat es farà servir mentre el mode segur estigui activat. Una vegada es desactivi el mode segur es restablirà el tema escollit."
_order:
newest: "Més recent"
oldest: "Cronològic"
oldest: "Antigues primer"
_chat:
noMessagesYet: "Encara no tens missatges "
newMessage: "Missatge nou"
@ -1634,6 +1638,10 @@ _serverSettings:
fanoutTimelineDbFallback: "Carregar de la base de dades"
fanoutTimelineDbFallbackDescription: "Quan s'activa, la línia de temps fa servir la base de dades per consultes adicionals si la línia de temps no es troba a la memòria cau. Si és desactiva la càrrega del servidor és veure reduïda, però també és reduirà el nombre de línies de temps que és poden obtenir."
reactionsBufferingDescription: "Quan s'activa aquesta opció millora bastant el rendiment en recuperar les línies de temps reduint la càrrega de la base. Com a contrapunt, augmentarà l'ús de memòria de Redís. Desactiva aquesta opció en cas de tenir un servidor amb poca memòria o si tens problemes d'inestabilitat."
remoteNotesCleaning: "Neteja automàtica de notes remotes"
remoteNotesCleaning_description: "Quan activis aquesta opció, periòdicament es netejaran les notes remotes que no es consultin, això evitarà que la base de dades se"
remoteNotesCleaningMaxProcessingDuration: "D'oració màxima del temps de funcionament del procés de neteja"
remoteNotesCleaningExpiryDaysForEachNotes: "Duració mínima de conservació de les notes"
inquiryUrl: "URL de consulta "
inquiryUrlDescription: "Escriu adreça URL per al formulari de consulta per al mantenidor del servidor o una pàgina web amb el contacte d'informació."
openRegistration: "Registres oberts"
@ -1652,6 +1660,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "L'abast de la publicació del contingut generat per l'usuari"
userGeneratedContentsVisibilityForVisitor_description: "Això ajuda a evitar problemes com que continguts remots inadequats que no hagin estat moderats correctament es publiquin a internet mitjançant el teu servidor."
userGeneratedContentsVisibilityForVisitor_description2: "La publicació incondicional de tots els continguts del servidor a internet, incloent-hi els continguts remots rebuts pel servidor, comporta riscos. Això és extremadament important per els espectadors que desconeixen el caràcter descentralitzat dels continguts, ja que poden percebre erroneament els continguts remots com contingut generat per el propi servidor."
restartServerSetupWizardConfirm_title: "Vols tornar a executar l'assistent de configuració inicial del servidor?"
restartServerSetupWizardConfirm_text: "Algunes configuracions actuals seran restablertes."
_userGeneratedContentsVisibilityForVisitor:
all: "Tot obert al públic "
localOnly: "Només es publiquen els continguts locals, el contingut remot es manté privat"
@ -3062,6 +3072,7 @@ _bootErrors:
otherOption1: "Esborrar la configuració i la memòria cau del client"
otherOption2: "Iniciar client senzill"
otherOption3: "Iniciar l'eina de reparació "
otherOption4: "Iniciar Misskey en mode segur"
_search:
searchScopeAll: "Tot"
searchScopeLocal: "Local"
@ -3098,6 +3109,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "Quan es connecta amb una xarxa de servidors distribuïts (Fedivers), els continguts poden intercanviar-se amb altres servidors i entre ells."
doYouConnectToFediverse_description2: "La connexió amb el Fedivers també es coneix com a \"federació\"."
youCanConfigureMoreFederationSettingsLater: "Les configuracions avançades, com especificar els servidors amb els quals es pot federar, es poden fer més tard."
remoteContentsCleaning: "Neteja automàtica del contingut rebut"
remoteContentsCleaning_description: "Quan es comença a federar es rep un munt de contingut, quan s'activa la neteja automàtica el contingut antic que no es consulta serà eliminat del servidor, el que permet estalviar espai d'emmagatzematge."
adminInfo: "Informació de l'administrador "
adminInfo_description: "Estableix la informació de l'administrador que es farà servir per rebre consultes."
adminInfo_mustBeFilled: "Aquesta informació ha de ser omplerta si el servidor té els registres oberts o la federació es troba activada."

View File

@ -2004,7 +2004,7 @@ _deck:
list: "Seznamy"
channel: "Kanály"
mentions: "Zmínění"
direct: "Přímý"
direct: "Přímé poznámky"
roleTimeline: "Časová osa role"
_dialog:
charactersExceeded: "Překročili jste maximální počet znaků! V současné době je na hodnotě {current} z {max}."

View File

@ -353,6 +353,7 @@ _visibility:
home: "Κεντρικό"
homeDescription: "Δημοσίευση στο κεντρικό χρονολόγιο μόνο"
followers: "Ακολουθούν"
specified: "Απευθείας σημειώματα"
_profile:
name: "Όνομα"
username: "Όνομα μέλους"
@ -395,6 +396,7 @@ _deck:
antenna: "Αντένες"
list: "Λίστα"
mentions: "Επισημάνσεις"
direct: "Απευθείας σημειώματα"
_webhookSettings:
name: "Όνομα"
_moderationLogTypes:

View File

@ -81,7 +81,7 @@ import: "Import"
export: "Export"
files: "Files"
download: "Download"
driveFileDeleteConfirm: "Do you want to remove the file \"{name}\"? Some content using this file will also be removed."
driveFileDeleteConfirm: "Are you sure you want to delete \"{name}\"? All notes with this file attached will also be deleted."
unfollowConfirm: "Are you sure you want to unfollow {name}?"
exportRequested: "You've requested an export. This may take a while. It will be added to your Drive once completed."
importRequested: "You've requested an import. This may take a while."
@ -1370,6 +1370,10 @@ defaultImageCompressionLevel: "Default image compression level"
defaultImageCompressionLevel_description: "Lower level preserves image quality but increases file size.<br>Higher level reduce file size, but reduce image quality."
inMinutes: "Minute(s)"
inDays: "Day(s)"
safeModeEnabled: "Safe mode is enabled"
pluginsAreDisabledBecauseSafeMode: "All plugins are disabled because safe mode is enabled."
customCssIsDisabledBecauseSafeMode: "Custom CSS is not applied because safe mode is enabled."
themeIsDefaultBecauseSafeMode: "While safe mode is active, the default theme is used. Disabling safe mode will revert these changes."
_order:
newest: "Newest First"
oldest: "Oldest First"
@ -1402,7 +1406,7 @@ _chat:
muteThisRoom: "Mute room"
deleteRoom: "Delete room"
chatNotAvailableForThisAccountOrServer: "Chat is not enabled on this server or for this account."
chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this instance or this account. You cannot write new messages or create/join chat rooms."
chatIsReadOnlyForThisAccountOrServer: "Chat is read-only on this server or this account. You cannot write new messages or create/join chat rooms."
chatNotAvailableInOtherAccount: "The chat function is disabled for the other user."
cannotChatWithTheUser: "Cannot start a chat with this user"
cannotChatWithTheUser_description: "Chat is either unavailable or the other party has not enabled chat."
@ -1500,7 +1504,7 @@ _abuseUserReport:
resolveTutorial: "If the report's content is legitimate, select \"Accept\" to mark it as resolved.\nIf the report's content is illegitimate, select \"Reject\" to ignore it."
_delivery:
status: "Delivery status"
stop: "Suspended"
stop: "Suspend"
resume: "Delivery resume"
_type:
none: "Publishing"
@ -1634,6 +1638,10 @@ _serverSettings:
fanoutTimelineDbFallback: "Fallback to database"
fanoutTimelineDbFallbackDescription: "When enabled, the timeline will fall back to the database for additional queries if the timeline is not cached. Disabling it further reduces the server load by eliminating the fallback process, but limits the range of timelines that can be retrieved."
reactionsBufferingDescription: "When enabled, performance during reaction creation will be greatly improved, reducing the load on the database. However, Redis memory usage will increase."
remoteNotesCleaning: "Automatic cleanup of remote notes"
remoteNotesCleaning_description: "When enabled, unused and outdated remote notes will be periodically cleaned up to prevent database bloat."
remoteNotesCleaningMaxProcessingDuration: "Maximum cleanup processing time"
remoteNotesCleaningExpiryDaysForEachNotes: "Minimum days to retain notes"
inquiryUrl: "Inquiry URL"
inquiryUrlDescription: "Specify a URL for the inquiry form to the server maintainer or a web page for the contact information."
openRegistration: "Make the account creation open"
@ -1652,6 +1660,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "Visibility of user-generated content to guests"
userGeneratedContentsVisibilityForVisitor_description: "This is useful for preventing problems caused by inappropriate remote content that is not well moderated from being unintentionally published on the Internet via your own server."
userGeneratedContentsVisibilityForVisitor_description2: "Unconditionally publishing all content on the server to the Internet, including remote content received by the server is risky. This is especially important for guests who are unaware of the distributed nature of the content, as they may mistakenly believe that even remote content is content created by users on the server."
restartServerSetupWizardConfirm_title: "Restart server setup wizard?"
restartServerSetupWizardConfirm_text: "Some current settings will be reset."
_userGeneratedContentsVisibilityForVisitor:
all: "Everything is public"
localOnly: "Only local content is published, remote content is kept private"
@ -2332,7 +2342,7 @@ _permissions:
"read:admin:index-stats": "View database index stats"
"read:admin:table-stats": "View database table stats"
"read:admin:user-ips": "View user IP addresses"
"read:admin:meta": "View instance metadata"
"read:admin:meta": "View server metadata"
"write:admin:reset-password": "Reset user password"
"write:admin:resolve-abuse-user-report": "Resolve user report"
"write:admin:send-email": "Send email"
@ -2343,7 +2353,7 @@ _permissions:
"write:admin:unset-user-avatar": "Remove user avatar"
"write:admin:unset-user-banner": "Remove user banner"
"write:admin:unsuspend-user": "Unsuspend user"
"write:admin:meta": "Manage instance metadata"
"write:admin:meta": "Manage server metadata"
"write:admin:user-note": "Manage moderation note"
"write:admin:roles": "Manage roles"
"read:admin:roles": "View roles"
@ -2775,7 +2785,7 @@ _moderationLogTypes:
resetPassword: "Password reset"
suspendRemoteInstance: "Remote instance suspended"
unsuspendRemoteInstance: "Remote instance unsuspended"
updateRemoteInstanceNote: "Moderation note updated for remote instance."
updateRemoteInstanceNote: "Updated moderation note for remote servers"
markSensitiveDriveFile: "File marked as sensitive"
unmarkSensitiveDriveFile: "File unmarked as sensitive"
resolveAbuseReport: "Report resolved"
@ -3062,6 +3072,7 @@ _bootErrors:
otherOption1: "Delete client settings and cache"
otherOption2: "Start the simple client"
otherOption3: "Launch the repair tool"
otherOption4: "Launch Misskey in safe mode"
_search:
searchScopeAll: "All"
searchScopeLocal: "Local"
@ -3098,6 +3109,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "When connected to a network of distributed servers (Fediverse) content can be exchanged with other servers."
doYouConnectToFediverse_description2: "Connecting with the Fediverse is also called \"federation\""
youCanConfigureMoreFederationSettingsLater: "Advanced settings such as specifying federated servers can be configured later."
remoteContentsCleaning: "Automatic cleanup of received contents"
remoteContentsCleaning_description: "Federation may result in a continuous inflow of content. Enabling automatic cleanup will remove outdated and unreferenced content from the server to save storage."
adminInfo: "Administrator information"
adminInfo_description: "Sets the administrator information used to receive inquiries."
adminInfo_mustBeFilled: "Must be entered if public server or federation is on."

View File

@ -1370,6 +1370,10 @@ defaultImageCompressionLevel: "Nivel de compresión de la imagen por defecto"
defaultImageCompressionLevel_description: "Baja, conserva la calidad de la imagen pero la medida del archivo es más grande. <br>Alta, reduce la medida del archivo pero también la calidad de la imagen."
inMinutes: "Minutos"
inDays: "Días"
safeModeEnabled: "El modo seguro está activado"
pluginsAreDisabledBecauseSafeMode: "El modo seguro está activado, por lo que todos los plugins están desactivados."
customCssIsDisabledBecauseSafeMode: "El modo seguro está activado, por lo que no se aplica el CSS personalizado."
themeIsDefaultBecauseSafeMode: "Mientras el modo seguro esté activado, se utilizará el tema predeterminado. Cuando se desactive el modo seguro, se volverá al tema original."
_order:
newest: "Los más recientes primero"
oldest: "Los más antiguos primero"
@ -1634,6 +1638,10 @@ _serverSettings:
fanoutTimelineDbFallback: "Cargar desde la base de datos"
fanoutTimelineDbFallbackDescription: "Cuando esta opción está habilitada, la carga de peticiones adicionales de la línea de tiempo se hará desde la base de datos cuando éstas no se encuentren en la caché. Al deshabilitar esta opción se reduce la carga del servidor, pero limita el número de líneas de tiempo que pueden obtenerse."
reactionsBufferingDescription: "Cuando se activa, el rendimiento durante la creación de reacciones mejorará considerablemente, reduciendo la carga de la base de datos. Sin embargo, aumentará el uso de memoria de Redis."
remoteNotesCleaning: "Limpieza automática de notas (publicaciones) remotas"
remoteNotesCleaning_description: "Al habilitar esta opción, se limpiarán periódicamente las entradas remotas antiguas que no se consultan, lo que evitará que la base de datos se sature."
remoteNotesCleaningMaxProcessingDuration: "Tiempo máximo de funcionamiento continuo del proceso de limpieza"
remoteNotesCleaningExpiryDaysForEachNotes: "Días mínimos para conservar las notas"
inquiryUrl: "URL de consulta "
inquiryUrlDescription: "Especifica una URL para el formulario de consulta al responsable del servidor o una página web para la información de contacto."
openRegistration: "Registros Abiertos"
@ -1652,6 +1660,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "Visibilidad de contenido generado por un usuario a invitados"
userGeneratedContentsVisibilityForVisitor_description: "Esto es útil para evitar problemas causados por contenidos remotos inapropiados que no estén bien moderados y que se publiquen involuntariamente en Internet a través de su propio servidor."
userGeneratedContentsVisibilityForVisitor_description2: "Publicar incondicionalmente todo el contenido del servidor en Internet, incluido el contenido remoto recibido por el servidor, es arriesgado. Esto es especialmente importante para los invitados que desconocen la naturaleza distribuida del contenido, ya que pueden creer erróneamente que incluso el contenido remoto es contenido creado por usuarios en el servidor."
restartServerSetupWizardConfirm_title: "¿Reiniciar el asistente de configuración del servidor?"
restartServerSetupWizardConfirm_text: "Algunas configuraciones actuales se restablecerán"
_userGeneratedContentsVisibilityForVisitor:
all: "Todo es público."
localOnly: "Sólo se publica el contenido local, el remoto se mantiene privado"
@ -3062,6 +3072,7 @@ _bootErrors:
otherOption1: "Borra la configuración y la memoria caché del cliente"
otherOption2: "Iniciar el cliente simple"
otherOption3: "Iniciar la herramienta de reparación"
otherOption4: "Iniciar Misskey en modo seguro"
_search:
searchScopeAll: "Todo"
searchScopeLocal: "Local"
@ -3098,6 +3109,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "Cuando se conecta a una red de servidores distribuidos (Fediverso), el contenido puede intercambiarse con otros servidores."
doYouConnectToFediverse_description2: "Conectarse con el Fediverso también se conoce como \"federación\"."
youCanConfigureMoreFederationSettingsLater: "Los ajustes avanzados, como la especificación de servidores federados, pueden configurarse más adelante."
remoteContentsCleaning: "Limpieza automática de los contenidos recibidos"
remoteContentsCleaning_description: "La federación puede dar lugar a un flujo continuo de contenido. Al habilitar la limpieza automática, se eliminará del servidor el contenido obsoleto y sin referencias para ahorrar espacio de almacenamiento."
adminInfo: "Información del administrador"
adminInfo_description: "Establece la información del administrador para recibir consultas."
adminInfo_mustBeFilled: "Esta información debe ser introducida en el caso de registros abiertos o la federación esté activada."

View File

@ -73,7 +73,7 @@ export default function generateDTS() {
ts.NodeFlags.Const,
),
),
ts.factory.createInterfaceDeclaration(
ts.factory.createTypeAliasDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],
ts.factory.createIdentifier('ParameterizedString'),
[
@ -84,20 +84,22 @@ export default function generateDTS() {
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
),
],
undefined,
[
ts.factory.createPropertySignature(
undefined,
ts.factory.createComputedPropertyName(
ts.factory.createIdentifier('kParameters'),
),
undefined,
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('T'),
ts.factory.createIntersectionTypeNode([
ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword),
ts.factory.createTypeLiteralNode([
ts.factory.createPropertySignature(
undefined,
ts.factory.createComputedPropertyName(
ts.factory.createIdentifier('kParameters'),
),
undefined,
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier('T'),
undefined,
),
),
),
],
])
]),
),
ts.factory.createInterfaceDeclaration(
[ts.factory.createToken(ts.SyntaxKind.ExportKeyword)],

8
locales/index.d.ts vendored
View File

@ -2,9 +2,9 @@
// This file is generated by locales/generateDTS.js
// Do not edit this file directly.
declare const kParameters: unique symbol;
export interface ParameterizedString<T extends string = string> {
export type ParameterizedString<T extends string = string> = string & {
[kParameters]: T;
}
};
export interface ILocale {
[_: string]: string | ParameterizedString | ILocale;
}
@ -5871,6 +5871,10 @@ export interface Locale extends ILocale {
*
*/
"showAvailableReactionsFirstInNote": string;
/**
*
*/
"showPageTabBarBottom": string;
"_chat": {
/**
*

View File

@ -36,6 +36,7 @@ const languages = [
'ru-RU',
'sk-SK',
'th-TH',
'tr-TR',
'ug-CN',
'uk-UA',
'vi-VN',

View File

@ -1469,6 +1469,7 @@ _settings:
contentsUpdateFrequency_description2: "リアルタイムモードがオンのときは、この設定に関わらずリアルタイムでコンテンツが更新されます。"
showUrlPreview: "URLプレビューを表示する"
showAvailableReactionsFirstInNote: "利用できるリアクションを先頭に表示"
showPageTabBarBottom: "ページのタブバーを下部に表示"
_chat:
showSenderName: "送信者の名前を表示"

View File

@ -1333,6 +1333,10 @@ hideAllTips: "「ヒントとコツ」は全部表示せんでええ"
defaultImageCompressionLevel_description: "低くすると画質は保てるんやけど、ファイルサイズが増えるで。<br>高くするとファイルサイズは減らせるんやけど、画質が落ちるで。"
inMinutes: "分"
inDays: "日"
safeModeEnabled: "セーフモードがオンになってるで"
pluginsAreDisabledBecauseSafeMode: "セーフモードがオンやから、プラグインは全部無効化されてるで。"
customCssIsDisabledBecauseSafeMode: "セーフモードがオンやから、カスタムCSSは適用されてへんで。"
themeIsDefaultBecauseSafeMode: "セーフモードがオンの間はデフォルトのテーマを使うで。セーフモードをオフにれば元に戻るで。"
_chat:
noMessagesYet: "まだメッセージはあらへんで"
individualChat_description: "特定のユーザーと一対一でチャットができるで。"
@ -1345,8 +1349,59 @@ _chat:
members: "メンバーはん"
home: "ホーム"
send: "送信"
deleteRoom: "ルームをほかす"
chatNotAvailableForThisAccountOrServer: "このサーバー、もしくはこのアカウントでチャットが有効にされてへんで。"
chatIsReadOnlyForThisAccountOrServer: "このサーバー、もしくはこのアカウントでチャットが読み取り専用になっとるわ。新しく書き込んだり、チャットルームを作ったり参加したりはできへんで。"
chatNotAvailableInOtherAccount: "相手のアカウントでチャット機能が使えんくなっとるみたいやわ。"
cannotChatWithTheUser: "このユーザーとのチャットを開始できへんみたいやわ"
cannotChatWithTheUser_description: "チャットが使えん状態になっとるか、相手がチャットを開放してへんみたいやわ。"
youAreNotAMemberOfThisRoomButInvited: "あんたはこのルームの参加者ちゃうけど、招待が届いとるで。参加するんやったら、招待を承認してな。"
doYouAcceptInvitation: "招待を承認してもええんか?"
chatWithThisUser: "チャットしよか"
thisUserAllowsChatOnlyFromFollowers: "このユーザーはフォロワーからのチャットしか受け付けとらんみたいやわ。"
thisUserAllowsChatOnlyFromFollowing: "このユーザーは、このユーザーがフォローしとるユーザーからのチャットしか受け付けとらんみたいやわ。"
thisUserAllowsChatOnlyFromMutualFollowing: "このユーザーは相互フォローのユーザーからのチャットしか受け付けとらんみたいやわ。"
thisUserNotAllowedChatAnyone: "このユーザーは誰からのチャットも受け付けとらんみたいやわ。"
chatAllowedUsers: "チャットしてもええ相手"
chatAllowedUsers_note: "自分からチャットメッセージを送った相手やったらこの設定に関わらずチャットできるで。"
_chatAllowedUsers:
followers: "自分のフォロワーだけ"
following: "自分がフォローしとるユーザーだけ"
mutual: "相互フォローのユーザーだけ"
none: "誰もかもあかん"
_emojiPalette:
enableSyncBetweenDevicesForPalettes: "パレットのデバイス間同期をつけとく"
paletteForMain: "メインで使うパレット"
paletteForReaction: "リアクションで使うパレット"
_settings:
driveBanner: "ドライブの管理と設定、使用量の確認、ファイルをアップロードするときの設定ができるで。"
pluginBanner: "プラグインを使うとクライアントの機能を拡張できるねん。プラグインのインストール、個別の設定と管理ができるで。"
notificationsBanner: "サーバーから受け取る通知の種類とか範囲、プッシュ通知の設定ができるで。"
webhook: "Webhook"
serviceConnectionBanner: "外部のアプリ・サービスと連携するのに使うとるアクセストークンとかWebhookの管理と設定ができるで。"
accountDataBanner: "アカウントデータのアーカイブをエクスポート/インポートして管理できるで。"
muteAndBlockBanner: "見せんでええコンテンツの設定とか、特定のユーザーからのアクションを制限する設定と管理ができるで。"
accessibilityBanner: "クライアントの視覚や動作に関わるパーソナライズをして、よりええ感じに使えるように設定できるで。"
privacyBanner: "コンテンツの公開範囲、見つけやすさ、フォローの承認制とかアカウントのプライバシーに関わる設定ができるで。"
securityBanner: "パスワード、ログイン方法、認証アプリ、パスキーとかアカウントのセキュリティに関わる設定ができるで。"
preferencesBanner: "好みに応じた、クライアントの全体的な動作の設定ができるで。"
appearanceBanner: "好みに応じた、クライアントの見た目・表示方法に関わる設定ができるで。"
soundsBanner: "クライアントで流すサウンドの設定ができるで。"
makeEveryTextElementsSelectable: "全部のテキスト要素を選択できるようにする"
makeEveryTextElementsSelectable_description: "これをつけると、一部のシチュエーションでユーザビリティが低下するかもしれん。"
enablePullToRefresh_description: "マウスやったら、ホイールを押し込みながらドラッグしてな。"
realtimeMode_description: "サーバーと接続を確立して、リアルタイムでコンテンツを更新するで。通信量とバッテリーの消費が多くなるかもしれへん。"
contentsUpdateFrequency_description: "高いほどリアルタイムにコンテンツが更新されるんやけど、そのぶんパフォーマンスが低くなるし、通信量とバッテリーの消費も増えるねん。"
contentsUpdateFrequency_description2: "リアルタイムモードをつけてるんやったら、この設定がどうであれリアルタイムでコンテンツが更新されるで。"
_preferencesProfile:
profileNameDescription: "このデバイスはなんて呼んだらええんや?"
_preferencesBackup:
noBackupsFoundTitle: "バックアップが見つからへんね"
noBackupsFoundDescription: "自動で作られたバックアップは見つからんかったけど、バックアップファイルを手動で保存してるんやったら、それをインポートして復元できるで。"
selectBackupToRestore: "復元するバックアップを選んでや"
youNeedToNameYourProfileToEnableAutoBackup: "自動バックアップを有効するんやったらプロファイル名の設定が必要やな。"
autoPreferencesBackupIsNotEnabledForThisDevice: "このデバイスで設定の自動バックアップは有効になってへんで。"
backupFound: "設定のバックアップがあるみたいやわ"
_accountSettings:
requireSigninToViewContents: "ログインしてもらってからコンテンツ見てもらう"
requireSigninToViewContentsDescription1: "あなたが作成した全部のノートとかのコンテンツを見れるようにするのにログインがいるようにするで。クローラーにいろいろ収集されるんを防げるかもしれん。"
@ -1357,6 +1412,7 @@ _accountSettings:
makeNotesHiddenBefore: "昔のノートを見れんようにする"
makeNotesHiddenBeforeDescription: "この機能が有効になってる間は、設定された日時より前、それか設定された時間が経ったノートがフォロワーのみ見れるようになるで。無効に戻すと、ノートの公開状態も戻るで。"
mayNotEffectForFederatedNotes: "リモートサーバーに連合されたノートには効果が及ばんかもしれん。"
mayNotEffectSomeSituations: "これらの制限は簡易的なものやで。リモートサーバーでの閲覧とかモデレーション時とか、一部のシチュエーションでは適用されへんかもしれん。"
notesHavePassedSpecifiedPeriod: "決めた時間が経ったノート"
notesOlderThanSpecifiedDateAndTime: "決めた日時より前のノート"
_abuseUserReport:
@ -1375,6 +1431,7 @@ _delivery:
manuallySuspended: "手動停止中"
goneSuspended: "サーバー削除のため停止中"
autoSuspendedForNotResponding: "サーバー応答せえへんから停止中"
softwareSuspended: "配信停止中のソフトウェアやから停止中"
_bubbleGame:
howToPlay: "遊び方"
hold: "ホールド"
@ -1501,11 +1558,21 @@ _serverSettings:
fanoutTimelineDbFallback: "データベースにフォールバックする"
fanoutTimelineDbFallbackDescription: "有効にしたら、タイムラインがキャッシュん中に入ってないときにDBにもっかい問い合わせるフォールバック処理ってのをやっとくで。切ったらフォールバック処理をやらんからサーバーはもっと軽くなんねんけど、タイムラインの取得範囲がちょっと減るで。"
reactionsBufferingDescription: "有効にしたら、リアクション作るときのパフォーマンスがすっごい上がって、データベースへの負荷が減るで。代わりに、Redisのメモリ使用は増えるで。"
remoteNotesCleaning_description: "つけると、参照されてへん古いリモートの投稿を定期的にクリーンアップしてデータベースの肥大化を抑えてくれるで。"
inquiryUrl: "問い合わせ先URL"
inquiryUrlDescription: "サーバー運営者へのお問い合わせフォームのURLや、運営者の連絡先等が記載されたWebページのURLを指定するで。"
openRegistration: "アカウントの作成をオープンにする"
openRegistrationWarning: "登録を解放するのはリスクが伴うで。サーバーをいっつも監視して、なんか起きたらすぐに対応できるんやったら、オンにしてもええと思う。"
thisSettingWillAutomaticallyOffWhenModeratorsInactive: "一定期間モデレーターがおらんかったら、スパムを防ぐためにこの設定は勝手に切られるで。"
deliverSuspendedSoftwareDescription: "脆弱性とかの理由で、サーバーのソフトウェアの名前とバージョンの範囲を決めて配信を止められるで。このバージョン情報はサーバーが提供したものやから、信頼性は保証されへん。バージョン指定には semver の範囲指定が使えるねんけど、>= 2024.3.1と指定すると 2024.3.1-custom.0 みたいなカスタムバージョンが含まれへんから、>= 2024.3.1-0 みたいに prerelease を指定するとええかもしれへんな。"
singleUserMode_description: "このサーバーを使うとるんが自分だけなんやったら、このモードを有効にすると動作がええ感じになるで。"
signToActivityPubGet_description: "通常はつけといてな。連合の通信に関わる問題があるんやったら、無効にすると改善するかもしれへんけど、逆にサーバーによっては通信ができんくなることがあるで。"
proxyRemoteFiles_description: "つけると、リモートのファイルをプロキシして提供するで。画像のサムネイル生成とかユーザーのプライバシー保護にええな。"
allowExternalApRedirect_description: "つけると、他のサーバーがうちのサーバーを通して第三者のコンテンツを照会できるようになるんやけど、コンテンツのなりすましが発生するかもしれへん。"
userGeneratedContentsVisibilityForVisitor_description: "モデレーションが行き届きにくい不適切なリモートコンテンツとかが、うちのサーバー経由で図らずもインターネットに公開されてまうことによるトラブルを防止できたりするで。"
userGeneratedContentsVisibilityForVisitor_description2: "サーバーで受け取ったリモートのコンテンツを含め、サーバー内の全部のコンテンツを何でもかんでもインターネットに公開するのはリスクを伴うねん。特に、分散型の特性を知らん閲覧者にとっては、リモートのコンテンツやったとしてもサーバー内で作られたコンテンツやと誤認してまうかもしれへんから、注意が必要やな。"
restartServerSetupWizardConfirm_title: "サーバーの初期設定ウィザードをやり直すん?"
restartServerSetupWizardConfirm_text: "現在の一部の設定はリセットされるで。"
_accountMigration:
moveFrom: "別のアカウントからこのアカウントに引っ越す"
moveFromSub: "別のアカウントへエイリアスを作る"
@ -1802,6 +1869,7 @@ _role:
descriptionOfIsExplorable: "オンにしたらロールの面子一覧が「みつける」で公開されるし、ロールのタイムラインが使えるようになるで。"
displayOrder: "表示順"
descriptionOfDisplayOrder: "数がでかいほど、UI上で先に表示されるで。"
preserveAssignmentOnMoveAccount_description: "つけると、このロールがのっかったアカウントが引っ越したときに、引っ越し先アカウントにもこのロールがのっかるようになるで。"
canEditMembersByModerator: "モデレーターがメンバーいじるのを許す"
descriptionOfCanEditMembersByModerator: "オンにすると、管理者だけやなくてモデレーターもこのロールにユーザーを入れたり抜いたりできるで。オフにすると管理者だけしかやれへんくなるで。"
priority: "優先度"
@ -1842,6 +1910,8 @@ _role:
canImportFollowing: "フォローのインポートを許す"
canImportMuting: "ミュートのインポートを許す"
canImportUserLists: "リストのインポートを許す"
uploadableFileTypes_caption: "MIMEタイプを指定してや。改行で区切って複数指定もできるし、アスタリスク(*)でワイルドカード指定もできるで。(例: image/*)"
uploadableFileTypes_caption2: "ファイルによっては種別がわからんこともあるで。そないなファイルを許可するんやったら {x} を指定に追加してな。"
_condition:
roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー"
@ -2041,7 +2111,7 @@ _theme:
navIndicator: "サイドバーのインジケーター"
link: "リンク"
hashtag: "ハッシュタグ"
mention: "メンション"
mention: "あんた宛て"
mentionMe: "うち宛てのメンション"
renote: "Renote"
modalBg: "モーダルの背景"
@ -2310,6 +2380,8 @@ _visibility:
disableFederation: "連合なし"
disableFederationDescription: "他サーバーへは送らんとくわ"
_postForm:
quitInspiteOfThereAreUnuploadedFilesConfirm: "アップロードされてへんファイルがあるんやけど、ほかしてフォームを閉じてもええんか?"
uploaderTip: "ファイルはまだアップロードされてへんで。ファイルのメニューから、リネームとか画像のクロップ、ウォーターマークをのっける、圧縮するかどうかなんかを設定できるで。ファイルはノートを投稿するときに自動でアップロードされるで。"
replyPlaceholder: "このノートに返信..."
quotePlaceholder: "このノートを引用..."
channelPlaceholder: "チャンネルに投稿..."
@ -2461,6 +2533,7 @@ _notification:
newNote: "さらの投稿"
unreadAntennaNote: "アンテナ {name}"
roleAssigned: "ロールが付与されたで"
chatRoomInvitationReceived: "チャットルームへ招待されたで"
emptyPushNotificationMessage: "プッシュ通知の更新をしといたで"
achievementEarned: "実績を獲得しとるで"
testNotification: "通知テスト"
@ -2480,7 +2553,7 @@ _notification:
all: "すべて"
note: "あんたらの新規投稿"
follow: "フォロー"
mention: "メンション"
mention: "あんた宛て"
reply: "リプライ"
renote: "リノート"
quote: "引用"
@ -2680,6 +2753,10 @@ _dataSaver:
_avatar:
title: "アイコンの絵"
description: "アイコン画像のアニメが止まるで。普通の画像よりもデータ量がでかいから、もっと通信量を節約できるねん。"
_urlPreviewThumbnail:
description: "URLプレビューのサムネイル画像が読み込まれへんくなるで。"
_disableUrlPreview:
description: "URLプレビュー機能を切るで。サムネイル画像だけと違って、リンク先の情報の読み込み自体を削減できるで。"
_code:
title: "コードハイライトは表示せんでええ"
description: "MFMとかでコードハイライト記法が使われてるとき、タップするまで読み込まれへんくなるで。コードハイライトではハイライトする言語ごとにその決めてるファイルを読む必要はあんねんな。けどな、それは自動で読み込まれなくなるから、通信量を少なくできることができるねん。"
@ -2737,6 +2814,7 @@ _offlineScreen:
_urlPreviewSetting:
title: "URLプレビューの設定"
enable: "URLプレビューを有効にする"
allowRedirectDescription: "入力されたURLがリダイレクトされるとき、そのリダイレクト先をたどってプレビューを表示するかどうかを設定できるで。無効にするとサーバーリソースを節約できるんやけど、リダイレクト先の内容は表示されへんくなるで。"
timeout: "プレビュー取得時のタイムアウト(ms)"
timeoutDescription: "プレビュー取得の所要時間がこの値を超えた場合、プレビューは生成されへんで。"
maximumContentLength: "Content-Lengthの最大値(byte)"
@ -2881,8 +2959,57 @@ _search:
searchScopeAll: "みんな"
searchScopeLocal: "ローカル"
searchScopeUser: "ユーザー指定"
pleaseEnterServerHost: "サーバーのホストはどないするん?"
pleaseSelectUser: "ユーザーを選んでや"
_serverSetupWizard:
installCompleted: "Misskeyのインストールが終わったで"
firstCreateAccount: "最初は、管理者アカウントを作成しよか。"
accountCreated: "管理者アカウントができたで!"
youCanEasilyConfigureOptimalServerSettingsWithThisWizard: "このウィザードで簡単にええ感じのサーバーの設定ができるで。"
settingsYouMakeHereCanBeChangedLater: "ここでの設定は、あとからでも変えられるで。"
howWillYouUseMisskey: "Misskeyをどんな感じに使うん"
_use:
single_youCanCreateMultipleAccounts: "お一人様サーバーとして運用するとしても、アカウントは必要に応じて複数作れるで。"
openServerAdvice: "不特定多数の利用者を受け入れるには相応のリスクがあるで。トラブルに対処できるよう、ちゃんとしたモデレーション体制で運営しいや。"
openServerAntiSpamAdvice: "うちのサーバーがスパムの踏み台にならへんように、reCAPTCHAとかのアンチボット機能を使う、みたいなセキュリティ対策もしっかり考えてな。"
howManyUsersDoYouExpect: "どれくらいの人数を考えとるん?"
largeScaleServerAdvice: "大規模なサーバーやったら、ロードバランシングとかデータベースのレプリケーションみたいな、高度なインフラストラクチャーの知識が必要になるかもしれへんわ。"
doYouConnectToFediverse: "Fediverseと接続するんやっけ"
doYouConnectToFediverse_description1: "分散型サーバーでできたネットワーク(Fediverse)に繋げると、他のサーバーと相互にコンテンツのやり取りができるようになるで。"
doYouConnectToFediverse_description2: "Fediverseと接続することは「連合」とも呼ばれるな。"
youCanConfigureMoreFederationSettingsLater: "連合してもええサーバーの指定とか、高度な設定も後でできるで。"
remoteContentsCleaning_description: "連合すると、ぎょうさんコンテンツを受け取り続けることになるねん。自動クリーニングをつけると、参照されてない古いコンテンツを自動でサーバーからほかして、ストレージを節約できるで。"
adminInfo_description: "問い合わせを受け付けるのに使う管理者情報を設定しよか。"
adminInfo_mustBeFilled: "オープンサーバー、もしくは連合を入れとるんやったら必ず入力せなあかんで。"
followingSettingsAreRecommended: "こういう設定がええかもな"
settingsCompleted: "設定が終わったで!"
settingsCompleted_description: "お疲れさん。準備ができたから、さっそくサーバーを使い始められるで。"
settingsCompleted_description2: "細かいサーバー設定は、「コントロールパネル」を見てみてな。"
_donationRequest:
text1: "Misskeyは有志で開発されとる無料のソフトウェアやで。"
text2: "今後も開発を続けられるように、よかったらぜひカンパをお願いするわ。"
text3: "支援者向け特典もあるで!"
_uploader:
abortConfirm: "アップロードされてへんファイルがあるんやけど、やめてもええんか?"
doneConfirm: "アップロードされてへんファイルがあるんやけど、完了してもええんか?"
maxFileSizeIsX: "アップロードできるファイルサイズは{x}までやで。"
tip: "ファイルはまだアップロードされてへんで。このダイアログで、アップロードする前に確認・リネーム・圧縮・クロッピングとかをできるで。準備が出来たら、「アップロード」ボタンを押してアップロードしてな。"
_clientPerformanceIssueTip:
makeSureDisabledAdBlocker: "アドブロッカーを切ってみてや"
makeSureDisabledAdBlocker_description: "アドブロッカーはパフォーマンスに影響があるかもしれへん。OSの機能とかブラウザの機能・アドオンとかでアドブロッカーが有効になってないか確認してや。"
makeSureDisabledCustomCss: "カスタムCSSを無効にしてみてや"
makeSureDisabledCustomCss_description: "スタイルを上書きするとパフォーマンスに影響があるかもしれへん。カスタムCSSとか、スタイルを上書きする拡張機能が有効になってないか確認してや。"
makeSureDisabledAddons: "拡張機能を無効にしてみてや"
makeSureDisabledAddons_description: "なんかの拡張機能がクライアントの動作にちょっかいをかけてパフォーマンスに影響を与えてるかもしれへん。ブラウザの拡張機能を無効にして良くなるか確認してや。"
_clip:
tip: "クリップは、ノートをまとめられる機能やで。"
_userLists:
tip: "好きなユーザーを含むリストを作れるねん。作ったリストはタイムラインとして表示できるで。"
_watermarkEditor:
tip: "画像にクレジット情報とかのウォーターマークをのっけられるで。"
quitWithoutSaveConfirm: "保存せずに終わってもええんか?"
driveFileTypeWarn: "このファイルは対応しとらへん"
driveFileTypeWarnDescription: "画像ファイルを選んでや"
opacity: "不透明度"
scale: "大きさ"
text: "テキスト"
@ -2894,5 +3021,8 @@ _watermarkEditor:
_imageEffector:
discardChangesConfirm: "変更をせんで終わるか?"
_drafts:
cannotCreateDraftAnymore: "下書きはこれ以上は作れへんな。"
cannotCreateDraft: "この内容で下書きは作れへんな。"
delete: "下書きをほかす"
deleteAreYouSure: "下書きをほかしてもええか?"
noDrafts: "下書きはあらへん"

View File

@ -44,6 +44,7 @@ showMore: "ಇನ್ನಷ್ಟು ನೋಡು"
youGotNewFollower: "ಹಿಂಬಾಲಿಸಿದರು"
receiveFollowRequest: "ಹಿಂಬಾಲನೆ ವಿನಂತಿ ಬಂದಿದೆ"
followRequestAccepted: "ಹಿಂಬಾಲನೆ ವಿನಂತಿ ಸ್ವೀಕರಿಸಲಾಯಿತು"
mention: "ಹೆಸರಿಸಿದ"
mentions: "ಹೆಸರಿಸಿದ"
directNotes: "ನೇರ ಟಿಪ್ಪಣಿಗಳು"
importAndExport: "ಆಮದು/ರಫ್ತು"
@ -65,6 +66,9 @@ replies: "ಉತ್ತರಿಸು"
_email:
_follow:
title: "ಹಿಂಬಾಲಿಸಿದರು"
_theme:
keys:
mention: "ಹೆಸರಿಸಿದ"
_sfx:
notification: "ಅಧಿಸೂಚನೆಗಳು"
_widgets:
@ -73,11 +77,14 @@ _widgets:
timeline: "ಸಮಯಸಾಲು"
_cw:
show: "ಇನ್ನಷ್ಟು ನೋಡು"
_visibility:
specified: "ನೇರ ಟಿಪ್ಪಣಿಗಳು"
_profile:
username: "ಬಳಕೆಹೆಸರು"
_notification:
youWereFollowed: "ಹಿಂಬಾಲಿಸಿದರು"
_types:
mention: "ಹೆಸರಿಸಿದ"
login: "ಪ್ರವೇಶ"
_actions:
reply: "ಉತ್ತರಿಸು"
@ -86,3 +93,4 @@ _deck:
notifications: "ಅಧಿಸೂಚನೆಗಳು"
tl: "ಸಮಯಸಾಲು"
mentions: "ಹೆಸರಿಸಿದ"
direct: "ನೇರ ಟಿಪ್ಪಣಿಗಳು"

View File

@ -745,7 +745,7 @@ _menuDisplay:
_theme:
description: "설멩"
keys:
mention: "멘션"
mention: "받언 멘션"
renote: "리노트"
_sfx:
note: "새 노트"
@ -775,6 +775,7 @@ _cw:
_visibility:
home: "덜머리"
followers: "팔로워"
specified: "쪽지 서기"
_postForm:
_placeholders:
e: "옇다 서 주이소"
@ -809,7 +810,7 @@ _notification:
newNote: "새 걸"
_types:
follow: "팔로잉"
mention: "멘션"
mention: "받언 멘션"
renote: "리노트"
quote: "따오기"
reaction: "반엉"
@ -824,6 +825,7 @@ _deck:
antenna: "안테나"
list: "리스트"
mentions: "받언 멘션"
direct: "쪽지 서기"
_webhookSettings:
name: "이럼"
_abuseReport:

View File

@ -1370,6 +1370,10 @@ defaultImageCompressionLevel: "기본 이미지 압축 정도"
defaultImageCompressionLevel_description: "낮추면 화질을 유지합니다만 파일 크기는 증가합니다. <br>높이면 파일 크기를 줄일 수 있습니다만 화질은 저하됩니다."
inMinutes: "분"
inDays: "일"
safeModeEnabled: "세이프 모드가 활성화돼있습니다"
pluginsAreDisabledBecauseSafeMode: "세이프 모드가 활성화돼있기에 플러그인은 전부 비활성화됩니다."
customCssIsDisabledBecauseSafeMode: "세이프 모드가 활성화돼있기에 커스텀 CSS는 적용되지 않습니다."
themeIsDefaultBecauseSafeMode: "세이프 모드가 활성화돼있는 동안에는 기본 테마가 사용됩니다. 세이프 모드를 끄면 원래대로 돌아옵니다."
_order:
newest: "최신 순"
oldest: "오래된 순"
@ -1634,6 +1638,10 @@ _serverSettings:
fanoutTimelineDbFallback: "데이터베이스를 예비로 사용하기"
fanoutTimelineDbFallbackDescription: "활성화하면 타임라인의 캐시되어 있지 않은 부분에 대해 DB에 질의하여 정보를 가져옵니다. 비활성화하면 이를 실행하지 않음으로써 서버의 부하를 줄일 수 있지만, 타임라인에서 가져올 수 있는 게시물 범위가 한정됩니다."
reactionsBufferingDescription: "활성화 한 경우, 리액션 작성 퍼포먼스가 대폭 향상되어 DB의 부하를 줄일 수 있으나, Redis의 메모리 사용량이 많아집니다."
remoteNotesCleaning: "리모트 서버 노트 자동 정리 "
remoteNotesCleaning_description: "더 이상 사용되지 않는 오래된 리모트 노트를 정기적으로 정리하여, 데이터 베이스의 사용량을 절약할 수 있습니다."
remoteNotesCleaningMaxProcessingDuration: "리모트 노트 자동 정리 최대 실행 시간"
remoteNotesCleaningExpiryDaysForEachNotes: "리모트 노트 저장 최소 일수"
inquiryUrl: "문의처 URL"
inquiryUrlDescription: "서버 운영자에게 보내는 문의 양식의 URL이나 운영자의 연락처 등이 적힌 웹 페이지의 URL을 설정합니다."
openRegistration: "회원 가입을 활성화 하기"
@ -1652,6 +1660,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "비이용자에 대한 유저 작성 콘텐츠의 공개 범위"
userGeneratedContentsVisibilityForVisitor_description: "조정을 하기 힘든 부적절한 리모트 콘텐츠 등이 자신의 서버 경유로 의도치 않게 인터넷에 공개되는 문제의 방지 등에 도움을 줍니다."
userGeneratedContentsVisibilityForVisitor_description2: "서버에서 받은 리모트 콘텐츠를 포함해 서버 내의 모든 콘텐츠를 무조건 인터넷에 공개하는 것에는 위험이 따릅니다. 특히, 분산형 특성에 대해 모르는 열람자에게는 리모트 콘텐츠여도 서버 내에서 작성된 콘텐츠라고 잘못 인식할 수 있기에 주의가 필요합니다."
restartServerSetupWizardConfirm_title: "서버의 초기 설정 위자드를 재시도하시겠습니까?"
restartServerSetupWizardConfirm_text: "현재 일부 설정은 리셋됩니다."
_userGeneratedContentsVisibilityForVisitor:
all: "모두 공개"
localOnly: "로컬 콘텐츠만 공개하고 리모트 콘텐츠는 비공개"
@ -3062,6 +3072,7 @@ _bootErrors:
otherOption1: "클라이언트 설정 및 캐시 삭제"
otherOption2: "간편 클라이언트 실행"
otherOption3: "복구 툴 실행"
otherOption4: "Misskey를 세이프 모드로 열기"
_search:
searchScopeAll: "전체"
searchScopeLocal: "로컬"
@ -3098,6 +3109,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "분산형 서버로 구성된 네트워크(Fediverse)에 접속하면 다른 서버와 서로 콘텐츠의 주고받기를 할 수 있습니다."
doYouConnectToFediverse_description2: "Fediverse에 접속하는 것을 '연합'이라고도 부릅니다."
youCanConfigureMoreFederationSettingsLater: "나중에 연합 가능한 서버의 지정 등 고급 설정을 할 수 있습니다."
remoteContentsCleaning: "리모트 콘텐츠 자동 정리"
remoteContentsCleaning_description: "연합 중인 서버가 있는 경우, 리모트 서버에서 대단히 많은 콘텐츠를 받아오게 됩니다. 자동 정리 기능을 활성화하면, 오래되고 서버에서 더 이상 조회되지 않는 콘텐츠를 자동으로 서버에서 삭제하여, 스토리지를 절약할 수 있습니다."
adminInfo: "관리자 정보"
adminInfo_description: "문의 접수를 위해 사용되는 관리자 정보를 설정합니다."
adminInfo_mustBeFilled: "오픈 서버 혹은 연합이 켜져 있는 경우 반드시 입력해야 합니다."

View File

@ -433,6 +433,7 @@ _cw:
_visibility:
home: "ໜ້າຫຼັກ"
followers: "ຜູ້ຕິດຕາມ"
specified: "ໂພສ Direct note"
_profile:
name: "ຊື່"
username: "ຊື່ຜູ້ໃຊ້"
@ -470,6 +471,7 @@ _deck:
list: "ລາຍການ"
channel: "ຊ່ອງ"
mentions: "ກ່າວເຖິງເຈົ້າ"
direct: "ໂພສ Direct note"
_webhookSettings:
name: "ຊື່"
_abuseReport:

View File

@ -1019,6 +1019,7 @@ _cw:
_visibility:
home: "Startpagina"
followers: "Volgers"
specified: "Directe notities"
_profile:
name: "Naam"
username: "Gebruikersnaam"
@ -1061,6 +1062,7 @@ _deck:
list: "Lijsten"
channel: "Kanalen"
mentions: "Vermeldingen"
direct: "Directe notities"
_webhookSettings:
name: "Naam"
active: "Ingeschakeld"

View File

@ -1302,6 +1302,7 @@ _cw:
_visibility:
home: "Acasă"
followers: "Urmăritori"
specified: "Note directe"
_postForm:
replyPlaceholder: "Răspunde la această notă..."
quotePlaceholder: "Citează aceasta nota..."
@ -1356,6 +1357,7 @@ _deck:
list: "Liste"
channel: "Canale"
mentions: "Mențiuni"
direct: "Note directe"
roleTimeline: "Cronologia rolului"
_webhookSettings:
name: "Nume"

View File

@ -646,6 +646,7 @@ _poll:
_visibility:
home: "Hem"
followers: "Följare"
specified: "Direktnoter"
_profile:
name: "Namn"
username: "Användarnamn"
@ -692,6 +693,7 @@ _deck:
list: "Listor"
channel: "kanal"
mentions: "Omnämningar"
direct: "Direktnoter"
_webhookSettings:
name: "Namn"
active: "Aktiverad"

View File

@ -776,7 +776,7 @@ highlightSensitiveMedia: "ไฮไลท์สื่อที่มีเนื
verificationEmailSent: "ได้ส่งอีเมลยืนยันแล้ว กรุณาเข้าลิงก์ที่ระบุในอีเมลเพื่อทำการตั้งค่าให้เสร็จสิ้น"
notSet: "ไม่ได้ตั้งค่า"
emailVerified: "อีเมลได้รับการยืนยันแล้ว"
noteFavoritesCount: "จำนวนโน้ตที่ชื่นชอบ"
noteFavoritesCount: "จำนวนโน้ตโปรด"
pageLikesCount: "จำนวนเพจที่ถูกใจ"
pageLikedCount: "จำนวนการกดถูกใจเพจที่ได้รับแล้ว"
contact: "ติดต่อ"
@ -1433,7 +1433,7 @@ _settings:
api: "API"
webhook: "Webhook"
serviceConnection: "การเชื่อมต่อกับบริการ"
serviceConnectionBanner: "สามารถจัดการและตั้งค่า Access Token และ Webhook เพื่อเชื่อมต่อกับแอปหรือบริการภายนอกได้"
serviceConnectionBanner: "สามารถจัดการและตั้งค่าโทเค็นการเข้าถึงและ Webhook เพื่อเชื่อมต่อกับแอปหรือบริการภายนอกได้"
accountData: "ข้อมูลบัญชี"
accountDataBanner: "สามารถจัดการข้อมูลบัญชีได้โดยส่งออกหรือนำเข้าไฟล์เก็บถาวร"
muteAndBlockBanner: "สามารถตั้งค่าการซ่อนเนื้อหา และจำกัดการกระทำจากผู้ใช้เฉพาะรายได้"
@ -1634,6 +1634,10 @@ _serverSettings:
fanoutTimelineDbFallback: "ฟอลแบ๊กกลับฐานข้อมูล"
fanoutTimelineDbFallbackDescription: "เมื่อเปิดใช้งาน หากไม่ได้แคชไทม์ไลน์ ไทม์ไลน์จะฟอลแบ๊กไปยังฐานข้อมูลสำหรับการ query เพิ่มเติม การปิดใช้งานจะช่วยลดภาระของเซิร์ฟเวอร์ด้วยการกำจัดกระบวนฟอลแบ๊ก แต่มันก็จะจำกัดช่วงเวลาไทม์ไลน์ที่สามารถดึงข้อมูลได้"
reactionsBufferingDescription: "เมื่อเปิดใช้งานฟังก์ชันนี้ก็จะช่วยลด latency ในการสร้างปฏิกิริยา แต่อาจจะส่งผลให้ memory footprint ของ Redis เพิ่มขึ้นนะ"
remoteNotesCleaning: "การล้างข้อมูลโพสต์จากระยะไกลโดยอัตโนมัติ"
remoteNotesCleaning_description: "เมื่อเปิดใช้งาน จะทำการล้างโพสต์จากระยะไกลเก่าที่ไม่ถูกอ้างอิง เป็นระยะ เพื่อลดการขยายตัวของฐานข้อมูล"
remoteNotesCleaningMaxProcessingDuration: "ระยะเวลาสูงสุดของการประมวลผลการล้างข้อมูล"
remoteNotesCleaningExpiryDaysForEachNotes: "จำนวนวันที่ต้องเก็บโน้ตไว้อย่างน้อย"
inquiryUrl: "URL สำหรับการติดต่อสอบถาม"
inquiryUrlDescription: "ระบุ URL ของหน้าเว็บที่มีแบบฟอร์มสำหรับติดต่อผู้ดูแลเซิร์ฟเวอร์ หรือข้อมูลการติดต่อของผู้ดูแลเซิร์ฟเวอร์"
openRegistration: "เปิดให้สร้างบัญชีได้"
@ -1652,6 +1656,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "ขอบเขตการเปิดเผยเนื้อหาที่ผู้ใช้สร้างต่อบุคคลที่ไม่ได้เข้าร่วม (แขก)"
userGeneratedContentsVisibilityForVisitor_description: "ช่วยป้องกันปัญหาที่อาจเกิดขึ้นจากเนื้อหาระยะไกลที่ไม่เหมาะสม ซึ่งอาจถูกเผยแพร่ออกสู่อินเทอร์เน็ตโดยไม่ตั้งใจผ่านเซิร์ฟเวอร์ของตนเอง โดยเฉพาะในกรณีที่การดูแลควบคุมไม่ทั่วถึง"
userGeneratedContentsVisibilityForVisitor_description2: "การเปิดเผยเนื้อหาทั้งหมดในเซิร์ฟเวอร์รวมทั้งเนื้อหาที่รับมาจากระยะไกลสู่สาธารณะบนอินเทอร์เน็ตโดยไม่มีข้อจำกัดใดๆ มีความเสี่ยงโดยเฉพาะอย่างยิ่งสำหรับผู้ชมที่ไม่เข้าใจลักษณะของระบบแบบกระจาย อาจทำให้เกิดความเข้าใจผิดคิดว่าเนื้อหาที่มาจากระยะไกลนั้นเป็นเนื้อหาที่สร้างขึ้นภายในเซิร์ฟเวอร์นี้ จึงควรใช้ความระมัดระวังอย่างมาก"
restartServerSetupWizardConfirm_title: "ต้องการเริ่มวิซาร์ดการตั้งค่าเซิร์ฟเวอร์ใหม่หรือไม่?"
restartServerSetupWizardConfirm_text: "การตั้งค่าบางส่วนในปัจจุบันจะถูกรีเซ็ต"
_userGeneratedContentsVisibilityForVisitor:
all: "ทั้งหมดสาธารณะ"
localOnly: "เผยแพร่เป็นสาธารณะเฉพาะเนื้อหาท้องถิ่น เนื้อหาระยะไกลให้เป็นส่วนตัว"
@ -3098,6 +3104,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "หากเชื่อมต่อกับเครือข่ายที่ประกอบด้วยเซิร์ฟเวอร์แบบกระจาย (Fediverse) จะสามารถแลกเปลี่ยนเนื้อหากับเซิร์ฟเวอร์อื่นๆ ได้"
doYouConnectToFediverse_description2: "การเชื่อมต่อกับ Fediverse เรียกว่า “สหพันธ์”"
youCanConfigureMoreFederationSettingsLater: "หลังจากนี้ยังสามารถตั้งค่าแบบขั้นสูง เช่น การกำหนดเซิร์ฟเวอร์ที่อนุญาตให้สหพันธ์ต่อกันได้เพิ่มเติม"
remoteContentsCleaning: "การล้างข้อมูลเนื้อหาที่ได้รับโดยอัตโนมัติ"
remoteContentsCleaning_description: "เมื่อมีการเชื่อมโยงสหพันธ์ จะได้รับเนื้อหาเป็นจำนวนมากอย่างต่อเนื่อง เมื่อเปิดใช้งานการล้างข้อมูลอัตโนมัติ จะทำการลบเนื้อหาเก่าที่ไม่ถูกอ้างอิง ไปจากเซิร์ฟเวอร์โดยอัตโนมัติ เพื่อประหยัดพื้นที่จัดเก็บข้อมูล"
adminInfo: "ข้อมูลผู้ดูแลระบ"
adminInfo_description: "ตั้งค่าข้อมูลผู้ดูแลระบบที่จะใช้รับคำถามและติดต่อ"
adminInfo_mustBeFilled: "หากเปิดใช้เซิร์ฟเวอร์สาธารณะ หรือเปิดใช้งานสหพันธ์ จะต้องกรอกข้อมูลนี้"

File diff suppressed because it is too large Load Diff

View File

@ -903,7 +903,7 @@ _theme:
header: "Sarlavha"
navBg: "Yon panel foni"
navFg: "Yon panel matni"
mention: "Murojat"
mention: "Eslatib o'tish"
renote: "Qayta qayd etish"
divider: "Ajratrmoq"
fgHighlighted: "Belgilangan matn"
@ -1045,7 +1045,7 @@ _notification:
_types:
all: "Barchasi"
follow: "Obuna bolish"
mention: "Murojat"
mention: "Eslatib o'tish"
renote: "Qayta qayd etish"
quote: "Iqtibos keltirish"
reaction: "Reaktsiyalar"

View File

@ -1143,7 +1143,7 @@ channelArchiveConfirmTitle: "要将 {name} 归档吗?"
channelArchiveConfirmDescription: "归档后,在频道列表与搜索结果中不会显示,也无法发布新的贴文。"
thisChannelArchived: "该频道已被归档。"
displayOfNote: "显示帖子"
initialAccountSetting: "初始设"
initialAccountSetting: "初始设"
youFollowing: "正在关注"
preventAiLearning: "拒绝接受生成式 AI 的学习"
preventAiLearningDescription: "要求文章生成 AI 或图像生成 AI 不能够以发布的帖子和图像等内容作为学习对象。这是通过在 HTML 响应中包含 noai 标志来实现的,这不能完全阻止 AI 学习你的发布内容,并不是所有 AI 都会遵守这类请求。"
@ -1370,6 +1370,10 @@ defaultImageCompressionLevel: "默认图像压缩等级"
defaultImageCompressionLevel_description: "较低的等级可以保持画质,但会增加文件大小。<br>较高的等级可以减少文件大小,但相对应的画质将会降低。"
inMinutes: "分"
inDays: "日"
safeModeEnabled: "已启用安全模式"
pluginsAreDisabledBecauseSafeMode: "因启用了安全模式,所有插件均已被禁用。"
customCssIsDisabledBecauseSafeMode: "因启用了安全模式,无法应用自定义 CSS。"
themeIsDefaultBecauseSafeMode: "启用安全模式时将使用默认主题。关闭安全模式后将还原。"
_order:
newest: "从新到旧"
oldest: "从旧到新"
@ -1538,7 +1542,7 @@ _announcement:
silenceDescription: "开启后,此条公告将不会发送通知,也不强制用户阅读。"
_initialAccountSetting:
accountCreated: "账户创建完成了!"
letsStartAccountSetup: "来进行帐户的初始设置吧。"
letsStartAccountSetup: "马上来进行账户的初始设定吧。"
letsFillYourProfile: "首先,来设定你的个人档案吧!"
profileSetting: "个人资料设置"
privacySetting: "隐私设置"
@ -1550,7 +1554,7 @@ _initialAccountSetting:
haveFun: "希望 {name} 在这里玩得开心!"
youCanContinueTutorial: "您可以继续了解 {name}(Misskey) 的使用教程,也可以在此停止教程并立即开始使用它。\n"
startTutorial: "开始教学"
skipAreYouSure: "要跳过初始设吗?"
skipAreYouSure: "要跳过初始设吗?"
laterAreYouSure: "要稍后再进行初始设定吗?"
_initialTutorial:
launchTutorial: "观看教学"
@ -1634,6 +1638,10 @@ _serverSettings:
fanoutTimelineDbFallback: "回退到数据库"
fanoutTimelineDbFallbackDescription: "当启用时,若时间线未被缓存,则将额外查询数据库。禁用该功能可通过不执行回退处理进一步减少服务器负载,但会限制可检索的时间线范围。"
reactionsBufferingDescription: "开启时可显著提高发送回应时的性能,及减轻数据库负荷。但 Redis 的内存用量会相应增加。"
remoteNotesCleaning: "自动清理远程投稿"
remoteNotesCleaning_description: "启用后,将自动清理已无法找到的旧的远程投稿,可减缓数据库的增长。"
remoteNotesCleaningMaxProcessingDuration: "最长清理持续时间"
remoteNotesCleaningExpiryDaysForEachNotes: "最短帖子保留期限"
inquiryUrl: "联络地址"
inquiryUrlDescription: "用来指定诸如向服务运营商咨询的论坛地址,或记载了运营商联系方式之类的网页地址。"
openRegistration: "开放注册"
@ -1652,6 +1660,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "用户生成内容对非用户的可见性"
userGeneratedContentsVisibilityForVisitor_description: "对于防止难以审核的不适当的远程内容等,通过自己的服务器无意中在互联网上公开等问题很有用。"
userGeneratedContentsVisibilityForVisitor_description2: "包含服务器接收到的远程内容在内,无条件将服务器上的所有内容公开在互联网上存在风险。特别是对去中心化的特性不是很了解的访问者有可能将远程服务器上的内容误认为是在此服务器内生成的,需要特别留意。"
restartServerSetupWizardConfirm_title: "要重新开始服务器初始设定向导吗?"
restartServerSetupWizardConfirm_text: "现有的部分设定将重置。"
_userGeneratedContentsVisibilityForVisitor:
all: "全部公开"
localOnly: "仅公开本地内容,隐藏远程内容"
@ -3062,6 +3072,7 @@ _bootErrors:
otherOption1: "清除客户端设定与缓存"
otherOption2: "使用简易客户端"
otherOption3: "启动修复工具"
otherOption4: "以安全模式启动 Misskey"
_search:
searchScopeAll: "全部"
searchScopeLocal: "本地"
@ -3098,6 +3109,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "若加入由分散性服务器所构成的网络Fediverse将能与其它服务器交换内容。"
doYouConnectToFediverse_description2: "加入 Fediverse 在这里被称为「联合」。"
youCanConfigureMoreFederationSettingsLater: "可在之后进行如哪些服务器可以进行联合等高级设置。"
remoteContentsCleaning: "自动清理传入内容"
remoteContentsCleaning_description: "加入联合后,服务器将持续接收大量内容。打开自动清理后,将自动删除无法找到的旧内容,可节省存储空间。"
adminInfo: "管理员信息"
adminInfo_description: "设置用于接受询问的管理员信息。"
adminInfo_mustBeFilled: "开放服务器或开启了联合的情况下必须输入。"

View File

@ -1370,6 +1370,10 @@ defaultImageCompressionLevel: "預設的影像壓縮程度"
defaultImageCompressionLevel_description: "低的話可以保留畫質,但是會增加檔案的大小。<br>高的話可以減少檔案大小,但是會降低畫質。"
inMinutes: "分鐘"
inDays: "日"
safeModeEnabled: "啟用安全模式"
pluginsAreDisabledBecauseSafeMode: "由於啟用安全模式,所有的外掛都被停用。"
customCssIsDisabledBecauseSafeMode: "由於啟用安全模式,所有的客製 CSS 都被停用。"
themeIsDefaultBecauseSafeMode: "啟用安全模式時將使用預設主題,關閉安全模式時將恢復預設主題。"
_order:
newest: "最新的在前"
oldest: "最舊的在前"
@ -1634,6 +1638,10 @@ _serverSettings:
fanoutTimelineDbFallback: "資料庫的回退"
fanoutTimelineDbFallbackDescription: "若啟用,在時間軸沒有快取的情況下將執行回退處理以額外查詢資料庫。若停用,可以透過不執行回退處理來進一步減少伺服器的負荷,但會限制可取得的時間軸範圍。"
reactionsBufferingDescription: "啟用時,可以顯著提高建立反應時的效能並減少資料庫的負載。 但是Redis 記憶體使用量會增加。"
remoteNotesCleaning: "自動清除遠端發佈內容"
remoteNotesCleaning_description: "啟用後,系統會定期清理未被參照的舊遠端貼文,以抑制資料庫的膨脹。"
remoteNotesCleaningMaxProcessingDuration: "清理作業的最長持續時間"
remoteNotesCleaningExpiryDaysForEachNotes: "貼文最短保留天數"
inquiryUrl: "聯絡表單網址"
inquiryUrlDescription: "指定伺服器運營者的聯絡表單網址,或包含運營者聯絡資訊網頁的網址。"
openRegistration: "允許建立帳戶"
@ -1652,6 +1660,8 @@ _serverSettings:
userGeneratedContentsVisibilityForVisitor: "使用者建立的內容對訪客的公開範圍"
userGeneratedContentsVisibilityForVisitor_description: "這有助於防止一些問題的發生,例如未經適當審核的不適當遠端內容無意中透過您自己的伺服器發佈到網際網路上。"
userGeneratedContentsVisibilityForVisitor_description2: "包括伺服器接收到的遠端內容在內,無條件地將伺服器內所有內容公開到網際網路上是具有風險的。特別是對於不了解分散式架構特性的瀏覽者來說,他們可能會誤以為這些遠端內容是由該伺服器所創建的,因此需要特別留意。"
restartServerSetupWizardConfirm_title: "要重新執行伺服器的初始設定精靈嗎?"
restartServerSetupWizardConfirm_text: "當前的部分設定將會被重設。"
_userGeneratedContentsVisibilityForVisitor:
all: "全部公開\n"
localOnly: "僅公開本地內容,遠端內容則不公開\n"
@ -3062,6 +3072,7 @@ _bootErrors:
otherOption1: "刪除用戶端設定和快取"
otherOption2: "啟動簡易用戶端"
otherOption3: "啟動修復工具"
otherOption4: "以安全模式啟動 Misskey"
_search:
searchScopeAll: "全部"
searchScopeLocal: "本地"
@ -3098,6 +3109,8 @@ _serverSetupWizard:
doYouConnectToFediverse_description1: "連接到由分散型伺服器構成的網絡(聯邦宇宙)後,您可以與其他伺服器進行內容的互相交流。\n"
doYouConnectToFediverse_description2: "連接到聯邦宇宙被稱為「聯邦」。\n"
youCanConfigureMoreFederationSettingsLater: "您可以在稍後進行更高級的設定,例如指定可以聯繫的伺服器等。\n"
remoteContentsCleaning: "自動清理接收的內容"
remoteContentsCleaning_description: "進行聯邦後,會持續接收大量內容。啟用自動清理功能後,系統會自動從伺服器中刪除未被參照的過時內容,以節省儲存空間。"
adminInfo: "管理員資訊"
adminInfo_description: "設定用於接收查詢的管理者資訊。\n"
adminInfo_mustBeFilled: "當設置為開放伺服器或啟用聯邦時,必須填寫此資訊。\n"

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2025.8.0-alpha.2",
"version": "2025.8.0-alpha.5",
"codename": "nasubi",
"repository": {
"type": "git",

View File

@ -70,7 +70,7 @@
"dependencies": {
"@aws-sdk/client-s3": "3.826.0",
"@aws-sdk/lib-storage": "3.826.0",
"@discordapp/twemoji": "15.1.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.2",
"@fastify/cookie": "11.0.2",
"@fastify/cors": "10.1.0",
@ -93,7 +93,7 @@
"@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.7.7",
"@swc/core": "1.12.0",
"@twemoji/parser": "15.1.1",
"@twemoji/parser": "16.0.0",
"@types/redis-info": "3.0.3",
"accepts": "1.3.8",
"ajv": "8.17.1",
@ -135,7 +135,7 @@
"jsrsasign": "11.1.0",
"juice": "11.0.1",
"meilisearch": "0.51.0",
"mfm-js": "0.24.0",
"mfm-js": "0.25.0",
"microformats-parser": "2.0.3",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",

View File

@ -42,7 +42,7 @@ async function killProc() {
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,json',
'-e', 'ts,js,mjs,cjs,json,pug',
'--exec', 'pnpm', 'run', 'build',
],
{

View File

@ -184,9 +184,9 @@ export type Config = {
authUrl: string;
driveUrl: string;
userAgent: string;
frontendEntry: string;
frontendEntry: { file: string | null };
frontendManifestExists: boolean;
frontendEmbedEntry: string;
frontendEmbedEntry: { file: string | null };
frontendEmbedManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
@ -235,10 +235,10 @@ export function loadConfig(): Config {
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
const frontendManifest = frontendManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
: { 'src/_boot_.ts': { file: null } };
const frontendEmbedManifest = frontendEmbedManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: 'src/boot.ts' } };
: { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@
import { setTimeout } from 'node:timers/promises';
import { Inject, Injectable } from '@nestjs/common';
import { And, In, IsNull, LessThan, MoreThan, Not } from 'typeorm';
import { And, Brackets, In, IsNull, LessThan, MoreThan, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiMeta, MiNote, NoteFavoritesRepository, NotesRepository, UserNotePiningsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
@ -67,69 +67,79 @@ export class CleanRemoteNotesProcessorService {
newest: null as number | null,
};
let cursor: MiNote['id'] = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
// The date limit for the newest note to be considered for deletion.
// All notes newer than this limit will always be retained.
const newestLimit = this.idService.gen(Date.now() - (1000 * 60 * 60 * 24 * this.meta.remoteNotesCleaningExpiryDaysForEachNotes));
let cursor = '0'; // oldest note ID to start from
while (true) {
const batchBeginAt = Date.now();
let notes: Pick<MiNote, 'id'>[] = await this.notesRepository.find({
where: {
id: LessThan(cursor),
userHost: Not(IsNull()),
clippedCount: 0,
renoteCount: 0,
},
take: MAX_NOTE_COUNT_PER_QUERY,
order: {
// 新しい順
// https://github.com/misskey-dev/misskey/pull/16292#issuecomment-3139376314
id: -1,
},
select: ['id'],
});
// We use string literals instead of query builder for several reasons:
// - for removeCondition, we need to use it in having clause, which is not supported by Brackets.
// - for recursive part, we need to preserve the order of columns, but typeorm query builder does not guarantee the order of columns in the result query
// The condition for removing the notes.
// The note must be:
// - old enough (older than the newestLimit)
// - a remote note (userHost is not null).
// - not have clipped
// - not have pinned on the user profile
// - not has been favorite by any user
const removeCondition = 'note.id < :newestLimit'
+ ' AND note."clippedCount" = 0'
+ ' AND note."userHost" IS NOT NULL'
// using both userId and noteId instead of just noteId to use index on user_note_pining table.
// This is safe because notes are only pinned by the user who created them.
+ ' AND NOT EXISTS(SELECT 1 FROM "user_note_pining" WHERE "noteId" = note."id" AND "userId" = note."userId")'
// We cannot use userId trick because users can favorite notes from other users.
+ ' AND NOT EXISTS(SELECT 1 FROM "note_favorite" WHERE "noteId" = note."id")'
;
// The initiator query contains the oldest ${MAX_NOTE_COUNT_PER_QUERY} remote non-clipped notes
const initiatorQuery = `
SELECT "note"."id" AS "id", "note"."replyId" AS "replyId", "note"."renoteId" AS "renoteId", "note"."id" AS "initiatorId"
FROM "note" "note" WHERE ${removeCondition} AND "note"."id" > :cursor ORDER BY "note"."id" ASC LIMIT ${MAX_NOTE_COUNT_PER_QUERY}`;
// The union query queries the related notes and replies related to the initiator query
const unionQuery = `
SELECT "note"."id", "note"."replyId", "note"."renoteId", rn."initiatorId"
FROM "note" "note"
INNER JOIN "related_notes" "rn"
ON "note"."replyId" = rn.id
OR "note"."renoteId" = rn.id
OR "note"."id" = rn."replyId"
OR "note"."id" = rn."renoteId"
`;
const recursiveQuery = `(${initiatorQuery}) UNION (${unionQuery})`;
const removableInitiatorNotesQuery = this.notesRepository.createQueryBuilder('note')
.select('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.groupBy('rn."initiatorId"')
.having(`bool_and(${removeCondition})`);
const notesQuery = this.notesRepository.createQueryBuilder('note')
.addCommonTableExpression(recursiveQuery, 'related_notes', { recursive: true })
.select('note.id', 'id')
.addSelect('rn."initiatorId"')
.innerJoin('related_notes', 'rn', 'note.id = rn.id')
.where(`rn."initiatorId" IN (${ removableInitiatorNotesQuery.getQuery() })`)
.setParameters({ cursor, newestLimit });
const notes: { id: MiNote['id'], initiatorId: MiNote['id'] }[] = await notesQuery.getRawMany();
const fetchedCount = notes.length;
// update the cursor to the newest initiatorId found in the fetched notes.
// We don't use 'id' since the note can be newer than the initiator note.
for (const note of notes) {
if (note.id < cursor) {
cursor = note.id;
if (cursor < note.initiatorId) {
cursor = note.initiatorId;
}
}
const pinings = notes.length === 0 ? [] : await this.userNotePiningsRepository.find({
where: {
noteId: In(notes.map(note => note.id)),
},
select: ['noteId'],
});
notes = notes.filter(note => {
return !pinings.some(pining => pining.noteId === note.id);
});
const favorites = notes.length === 0 ? [] : await this.noteFavoritesRepository.find({
where: {
noteId: In(notes.map(note => note.id)),
},
select: ['noteId'],
});
notes = notes.filter(note => {
return !favorites.some(favorite => favorite.noteId === note.id);
});
const replies = notes.length === 0 ? [] : await this.notesRepository.find({
where: {
replyId: In(notes.map(note => note.id)),
userHost: IsNull(),
},
select: ['replyId'],
});
notes = notes.filter(note => {
return !replies.some(reply => reply.replyId === note.id);
});
if (notes.length > 0) {
await this.notesRepository.delete(notes.map(note => note.id));

View File

@ -20,17 +20,6 @@ import type { Config } from '@/config.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import { DI } from '@/di-symbols.js';
import * as Acct from '@/misc/acct.js';
import type {
DbQueue,
DeliverQueue,
EndedPollNotificationQueue,
InboxQueue,
ObjectStorageQueue,
RelationshipQueue,
SystemQueue,
UserWebhookDeliverQueue,
SystemWebhookDeliverQueue,
} from '@/core/QueueModule.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { PageEntityService } from '@/core/entities/PageEntityService.js';
@ -129,16 +118,6 @@ export class ClientServerService {
private feedService: FeedService,
private roleService: RoleService,
private clientLoggerService: ClientLoggerService,
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@Inject('queue:relationship') public relationshipQueue: RelationshipQueue,
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
) {
//this.createServer = this.createServer.bind(this);
}

View File

@ -32,61 +32,30 @@
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
return;
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await localRes.text());
localStorage.setItem('localeVersion', v);
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
renderError('LOCALE_FETCH');
return;
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
//#endregion
//#region Script
async function importAppScript() {
await import(`/embed_vite/${CLIENT_ENTRY}`)
await import(CLIENT_ENTRY ? `/embed_vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/embed_vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT');
@ -115,10 +84,26 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const title = locale?._bootErrors?.title || 'Failed to initialize Misskey';
const reload = locale?.reload || 'Reload';
const title = messages?.title || 'Failed to initialize Misskey';
const reload = messages?.reload || 'Reload';
document.body.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" /><path d="M12 9v4" /><path d="M12 16v.01" /></svg>
<div class="message">${title}</div>

View File

@ -22,62 +22,31 @@
return;
}
//#region Detect language & fetch translations
if (!localStorage.hasOwnProperty('locale')) {
const supportedLangs = LANGS;
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
const metaRes = await window.fetch('/api/meta', {
method: 'POST',
body: JSON.stringify({}),
credentials: 'omit',
cache: 'no-cache',
headers: {
'Content-Type': 'application/json',
},
});
if (metaRes.status !== 200) {
renderError('META_FETCH');
return;
}
const meta = await metaRes.json();
const v = meta.version;
if (v == null) {
renderError('META_FETCH_V');
return;
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
const localRes = await window.fetch(`/assets/locales/${lang}.${v}.json`);
if (localRes.status === 200) {
localStorage.setItem('lang', lang);
localStorage.setItem('locale', await localRes.text());
localStorage.setItem('localeVersion', v);
//#region Detect language
const supportedLangs = LANGS;
/** @type { string } */
let lang = localStorage.getItem('lang');
if (lang == null || !supportedLangs.includes(lang)) {
if (supportedLangs.includes(navigator.language)) {
lang = navigator.language;
} else {
renderError('LOCALE_FETCH');
return;
lang = supportedLangs.find(x => x.split('-')[0] === navigator.language);
// Fallback
if (lang == null) lang = 'en-US';
}
}
// for https://github.com/misskey-dev/misskey/issues/10202
if (lang == null || lang.toString == null || lang.toString() === 'null') {
console.error('invalid lang value detected!!!', typeof lang, lang);
lang = 'en-US';
}
//#endregion
//#region Script
async function importAppScript() {
await import(`/vite/${CLIENT_ENTRY}`)
await import(CLIENT_ENTRY ? `/vite/${CLIENT_ENTRY.replace('scripts', lang)}` : '/vite/src/_boot_.ts')
.catch(async e => {
console.error(e);
renderError('APP_IMPORT', e);
@ -162,9 +131,25 @@
await new Promise(resolve => window.addEventListener('DOMContentLoaded', resolve));
}
const locale = JSON.parse(localStorage.getItem('locale') || '{}');
let messages = null;
const bootloaderLocales = localStorage.getItem('bootloaderLocales');
if (bootloaderLocales) {
messages = JSON.parse(bootloaderLocales);
}
if (!messages) {
// older version of misskey does not store bootloaderLocales, stores locale as a whole
const legacyLocale = localStorage.getItem('locale');
if (legacyLocale) {
const parsed = JSON.parse(legacyLocale);
messages = {
...(parsed._bootErrors ?? {}),
reload: parsed.reload,
};
}
}
if (!messages) messages = {};
const messages = Object.assign({
messages = Object.assign({
title: 'Failed to initialize Misskey',
solution: 'The following actions may solve the problem.',
solution1: 'Update your os and browser',
@ -176,8 +161,8 @@
otherOption2: 'Start the simple client',
otherOption3: 'Start the repair tool',
otherOption4: 'Start Misskey in safe mode',
}, locale?._bootErrors || {});
const reload = locale?.reload || 'Reload';
reload: 'Reload',
}, messages);
const safeModeUrl = new URL(window.location.href);
safeModeUrl.searchParams.set('safemode', 'true');
@ -193,7 +178,7 @@
</svg>
<h1>${messages.title}</h1>
<button class="button-big" onclick="location.reload(true);">
<span class="button-label-big">${reload}</span>
<span class="button-label-big">${messages?.reload}</span>
</button>
<p><b>${messages.solution}</b></p>
<p>${messages.solution1}</p>

View File

@ -19,7 +19,6 @@ html(class='embed')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='modulepreload' href=`/embed_vite/${entry.file}`)
if !config.frontendEmbedManifestExists
script(type="module" src="/embed_vite/@vite/client")
@ -40,7 +39,7 @@ html(class='embed')
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson

View File

@ -37,7 +37,6 @@ html
link(rel='prefetch' href=serverErrorImageUrl)
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
link(rel='modulepreload' href=`/vite/${entry.file}`)
if !config.frontendManifestExists
script(type="module" src="/vite/@vite/client")
@ -69,7 +68,7 @@ html
script.
var VERSION = "#{version}";
var CLIENT_ENTRY = "#{entry.file}";
var CLIENT_ENTRY = !{JSON.stringify(entry.file)};
script(type='application/json' id='misskey_meta' data-generated-at=now)
!= metaJson

View File

@ -79,6 +79,9 @@ async function createAdmin(host: Host): Promise<Misskey.entities.SignupResponse
rateLimitFactor: 0 as never,
},
}, res.token);
await client.request('admin/update-meta', {
federation: 'all',
}, res.token);
return res;
}).catch(err => {
if (err.info.e.message === 'access denied') return undefined;

View File

@ -24,6 +24,7 @@ describe('Endpoints', () => {
bob = await signup({ username: 'bob' });
carol = await signup({ username: 'carol' });
dave = await signup({ username: 'dave' });
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
}, 1000 * 60 * 2);
describe('signup', () => {

View File

@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { channel, clip, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js';
import { api, channel, clip, galleryPost, page, play, post, signup, simpleGet, uploadFile } from '../utils.js';
import type { SimpleGetResponse } from '../utils.js';
import type * as misskey from 'misskey-js';
@ -78,6 +78,7 @@ describe('Webリソース', () => {
beforeAll(async () => {
alice = await signup({ username: 'alice' });
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
aliceUploadedFile = (await uploadFile(alice)).body;
alicesPost = await post(alice, {
text: 'test',

View File

@ -16,6 +16,7 @@ describe('FF visibility', () => {
beforeAll(async () => {
alice = await signup({ username: 'alice' });
bob = await signup({ username: 'bob' });
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
}, 1000 * 60 * 2);
test('followingVisibility, followersVisibility がともに public なユーザーのフォロー/フォロワーを誰でも見れる', async () => {

View File

@ -6,7 +6,7 @@
process.env.NODE_ENV = 'test';
import * as assert from 'assert';
import { host, origin, relativeFetch, signup } from '../utils.js';
import { api, host, origin, relativeFetch, signup } from '../utils.js';
import type * as misskey from 'misskey-js';
describe('.well-known', () => {
@ -14,6 +14,7 @@ describe('.well-known', () => {
beforeAll(async () => {
alice = await signup({ username: 'alice' });
await api('admin/update-meta', { federation: 'all' }, alice as misskey.entities.SignupResponse);
}, 1000 * 60 * 2);
test('nodeinfo', async () => {

View File

@ -0,0 +1 @@
This package contains the common scripts that are used to build the frontend and frontend-embed packages.

View File

@ -0,0 +1,52 @@
import globals from 'globals';
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
files: [
'**/*.ts',
],
languageOptions: {
globals: {
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
...globals.browser,
// Node.js
module: false,
require: false,
__dirname: false,
// Misskey
_DEV_: false,
_LANGS_: false,
_VERSION_: false,
_ENV_: false,
_PERF_PREFIX_: false,
},
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-interface': ['error', {
allowSingleExtends: true,
}],
'import/consistent-type-specifier-style': ['error', 'prefer-top-level'],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
},
},
{
ignores: [
],
},
];

View File

@ -0,0 +1,151 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as fs from 'fs/promises';
import * as path from 'node:path';
import MagicString from 'magic-string';
import { collectModifications } from './locale-inliner/collect-modifications.js';
import { applyWithLocale } from './locale-inliner/apply-with-locale.js';
import { blankLogger } from './logger.js';
import type { Logger } from './logger.js';
import type { Locale } from '../../locales/index.js';
import type { Manifest as ViteManifest } from 'vite';
export class LocaleInliner {
outputDir: string;
scriptsDir: string;
i18nFile: string;
i18nFileName: string;
logger: Logger;
chunks: ScriptChunk[];
static async create(options: {
outputDir: string,
scriptsDir: string,
i18nFile: string,
logger: Logger,
}): Promise<LocaleInliner> {
const manifest: ViteManifest = JSON.parse(await fs.readFile(`${options.outputDir}/manifest.json`, 'utf-8'));
return new LocaleInliner({ ...options, manifest });
}
constructor(options: {
outputDir: string,
scriptsDir: string,
i18nFile: string,
manifest: ViteManifest,
logger: Logger,
}) {
this.outputDir = options.outputDir;
this.scriptsDir = options.scriptsDir;
this.i18nFile = options.i18nFile;
this.i18nFileName = this.stripScriptDir(options.manifest[this.i18nFile].file);
this.logger = options.logger;
this.chunks = Object.values(options.manifest).filter(chunk => this.isScriptFile(chunk.file)).map(chunk => ({
fileName: this.stripScriptDir(chunk.file),
chunkName: chunk.name,
}));
}
async loadFiles() {
await Promise.all(this.chunks.map(async chunk => {
const filePath = path.join(this.outputDir, this.scriptsDir, chunk.fileName);
chunk.sourceCode = await fs.readFile(filePath, 'utf-8');
}));
}
collectsModifications() {
for (const chunk of this.chunks) {
if (!chunk.sourceCode) {
throw new Error(`Source code for ${chunk.fileName} is not loaded.`);
}
const fileLogger = this.logger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
chunk.modifications = collectModifications(chunk.sourceCode, chunk.fileName, fileLogger, this);
}
}
async saveAllLocales(locales: Record<string, Locale>) {
const localeNames = Object.keys(locales);
for (const localeName of localeNames) {
await this.saveLocale(localeName, locales[localeName]);
}
}
async saveLocale(localeName: string, localeJson: Locale) {
// create directory
await fs.mkdir(path.join(this.outputDir, localeName), { recursive: true });
const localeLogger = localeName === 'ja-JP' ? this.logger : blankLogger; // we want to log for single locale only
for (const chunk of this.chunks) {
if (!chunk.sourceCode || !chunk.modifications) {
throw new Error(`Source code or modifications for ${chunk.fileName} is not available.`);
}
const fileLogger = localeLogger.prefixed(`${chunk.fileName} (${chunk.chunkName}): `);
const magicString = new MagicString(chunk.sourceCode);
applyWithLocale(magicString, chunk.modifications, localeName, localeJson, fileLogger);
await fs.writeFile(path.join(this.outputDir, localeName, chunk.fileName), magicString.toString());
}
}
isScriptFile(fileName: string) {
return fileName.startsWith(this.scriptsDir + '/') && fileName.endsWith('.js');
}
stripScriptDir(fileName: string) {
if (!fileName.startsWith(this.scriptsDir + '/')) {
throw new Error(`${fileName} does not start with ${this.scriptsDir}/`);
}
return fileName.slice(this.scriptsDir.length + 1);
}
}
interface ScriptChunk {
fileName: string;
chunkName?: string;
sourceCode?: string;
modifications?: TextModification[];
}
export type TextModification = {
type: 'delete';
begin: number;
end: number;
localizedOnly: boolean;
} | {
// can be used later to insert '../scripts' for common files
type: 'insert';
begin: number;
text: string;
localizedOnly: boolean;
} | {
type: 'replace';
begin: number;
end: number;
text: string;
localizedOnly: boolean;
} | {
type: 'localized';
begin: number;
end: number;
localizationKey: string[];
localizedOnly: true;
} | {
type: 'parameterized-function';
begin: number;
end: number;
localizationKey: string[];
localizedOnly: true;
} | {
type: 'locale-name';
begin: number;
end: number;
literal: boolean;
localizedOnly: true;
} | {
type: 'locale-json';
begin: number;
end: number;
localizedOnly: true;
};

View File

@ -0,0 +1,102 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import MagicString from 'magic-string';
import { assertNever } from '../utils.js';
import type { Locale, ILocale } from '../../../locales/index.js';
import type { TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js';
export function applyWithLocale(
sourceCode: MagicString,
modifications: TextModification[],
localeName: string,
localeJson: Locale,
fileLogger: Logger,
) {
for (const modification of modifications) {
switch (modification.type) {
case 'delete':
sourceCode.remove(modification.begin, modification.end);
break;
case 'insert':
sourceCode.appendRight(modification.begin, modification.text);
break;
case 'replace':
sourceCode.update(modification.begin, modification.end, modification.text);
break;
case 'localized': {
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
if (accessed == null) {
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
}
sourceCode.update(modification.begin, modification.end, JSON.stringify(accessed));
break;
}
case 'parameterized-function': {
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
let replacement: string;
if (typeof accessed === 'string') {
replacement = formatFunction(accessed);
} else if (typeof accessed === 'object' && accessed !== null) {
replacement = `({${Object.entries(accessed).map(([key, value]) => `${JSON.stringify(key)}:${formatFunction(value)}`).join(',')}})`;
} else {
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
replacement = '(() => "")'; // placeholder for missing locale
}
sourceCode.update(modification.begin, modification.end, replacement);
break;
function formatFunction(format: string): string {
const params = new Set<string>();
const components: string[] = [];
let lastIndex = 0;
for (const match of format.matchAll(/\{(.+?)}/g)) {
const [fullMatch, paramName] = match;
if (lastIndex < match.index) {
components.push(JSON.stringify(format.slice(lastIndex, match.index)));
}
params.add(paramName);
components.push(paramName);
lastIndex = match.index + fullMatch.length;
}
components.push(JSON.stringify(format.slice(lastIndex)));
// we replace with `(({name,count})=>(name+count+"some"))`
const paramList = Array.from(params).join(',');
let body = components.filter(x => x !== '""').join('+');
if (body === '') body = '""'; // if the body is empty, we return empty string
return `(({${paramList}})=>(${body}))`;
}
}
case 'locale-name': {
sourceCode.update(modification.begin, modification.end, modification.literal ? JSON.stringify(localeName) : localeName);
break;
}
case 'locale-json': {
// locale-json is inlined to place where initialize module-level variable which is executed only once.
// In such case we can use JSON.parse to speed up the parsing script.
// https://v8.dev/blog/cost-of-javascript-2019#json
sourceCode.update(modification.begin, modification.end, `JSON.parse(${JSON.stringify(JSON.stringify(localeJson))})`);
break;
}
default: {
assertNever(modification);
}
}
}
}
function getPropertyByPath(localeJson: ILocale, localizationKey: string[]): string | object | null {
if (localizationKey.length === 0) return localeJson;
let current: ILocale | string = localeJson;
for (const key of localizationKey) {
if (typeof current !== 'object' || !(key in current)) {
return null; // Key not found
}
current = current[key];
}
return current;
}

View File

@ -0,0 +1,425 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { parseAst } from 'vite';
import * as estreeWalker from 'estree-walker';
import { assertNever, assertType } from '../utils.js';
import type { AstNode, ProgramNode } from 'rollup';
import type * as estree from 'estree';
import type { LocaleInliner, TextModification } from '../locale-inliner.js';
import type { Logger } from '../logger.js';
// WalkerContext is not exported from estree-walker, so we define it here
interface WalkerContext {
skip: () => void;
}
export function collectModifications(sourceCode: string, fileName: string, fileLogger: Logger, inliner: LocaleInliner): TextModification[] {
let programNode: ProgramNode;
try {
programNode = parseAst(sourceCode);
} catch (err) {
fileLogger.error(`Failed to parse source code: ${err}`);
return [];
}
if (programNode.sourceType !== 'module') {
fileLogger.error('Source code is not a module.');
return [];
}
const modifications: TextModification[] = [];
// first
// 1) replace all `scripts/` path literals with locale code
// 2) replace all `localStorage.getItem("lang")` with `localeName` variable
// 3) replace all `await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json())` with `localeJson` variable
estreeWalker.walk(programNode, {
enter(this: WalkerContext, node: Node) {
assertType<AstNode>(node);
if (node.type === 'Literal' && typeof node.value === 'string' && node.raw) {
if (node.raw.substring(1).startsWith(inliner.scriptsDir)) {
// we find `scripts/\w+\.js` literal and replace 'scripts' part with locale code
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.scriptsDir}/ path literal ${node.raw}`);
modifications.push({
type: 'locale-name',
begin: node.start + 1,
end: node.start + 1 + inliner.scriptsDir.length,
literal: false,
localizedOnly: true,
});
}
if (node.raw.substring(1, node.raw.length - 1) === `${inliner.scriptsDir}/${inliner.i18nFileName}`) {
// we find `scripts/i18n.ts` literal.
// This is tipically in depmap and replace with this file name to avoid unnecessary loading i18n script
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.i18nFileName} path literal ${node.raw}`);
modifications.push({
type: 'replace',
begin: node.end - 1 - inliner.i18nFileName.length,
end: node.end - 1,
text: fileName,
localizedOnly: true,
});
}
}
if (isLocalStorageGetItemLang(node)) {
fileLogger.debug(`${lineCol(sourceCode, node)}: found localStorage.getItem("lang") call`);
modifications.push({
type: 'locale-name',
begin: node.start,
end: node.end,
literal: true,
localizedOnly: true,
});
}
if (isAwaitFetchLocaleThenJson(node)) {
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json(), () => null)
fileLogger.debug(`${lineCol(sourceCode, node)}: found await window.fetch(\`/assets/locales/\${d}.\${x}.json\`).then(u=>u.json()) call`);
modifications.push({
type: 'locale-json',
begin: node.start,
end: node.end,
localizedOnly: true,
});
}
},
});
const importSpecifierResult = findImportSpecifier(programNode, inliner.i18nFileName, 'i18n');
switch (importSpecifierResult.type) {
case 'no-import':
fileLogger.debug('No import of i18n found, skipping inlining.');
return modifications;
case 'no-specifiers':
fileLogger.debug('Importing i18n without specifiers, removing the import.');
modifications.push({
type: 'delete',
begin: importSpecifierResult.importNode.start,
end: importSpecifierResult.importNode.end,
localizedOnly: false,
});
return modifications;
case 'unexpected-specifiers':
fileLogger.info(`Importing ${inliner.i18nFileName} found but with unexpected specifiers. Skipping inlining.`);
return modifications;
case 'specifier':
fileLogger.debug(`Found import i18n as ${importSpecifierResult.localI18nIdentifier}`);
break;
}
const i18nImport = importSpecifierResult.importNode;
const localI18nIdentifier = importSpecifierResult.localI18nIdentifier;
// Check if the identifier is already declared in the file.
// If it is, we may overwrite it and cause issues so we skip inlining
let isSupported = true;
estreeWalker.walk(programNode, {
enter(node) {
if (node.type === 'VariableDeclaration') {
assertType<estree.VariableDeclaration>(node);
for (const id of node.declarations.flatMap(x => declsOfPattern(x.id))) {
if (id === localI18nIdentifier) {
isSupported = false;
}
}
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!isSupported) {
fileLogger.error(`Duplicated identifier "${localI18nIdentifier}" in variable declaration. Skipping inlining.`);
return modifications;
}
fileLogger.debug(`imports i18n as ${localI18nIdentifier}`);
// In case of substitution failure, we will preserve the import statement
// otherwise we will remove it.
let preserveI18nImport = false;
const toSkip = new Set();
toSkip.add(i18nImport);
estreeWalker.walk(programNode, {
enter(this: WalkerContext, node, parent, property) {
assertType<AstNode>(node);
assertType<AstNode>(parent);
if (toSkip.has(node)) {
// This is the import specifier, skip processing it
this.skip();
return;
}
// We don't care original name part of the import declaration
if (node.type === 'ImportDeclaration') this.skip();
if (node.type === 'Identifier') {
assertType<estree.Identifier>(node);
assertType<estree.Property | estree.MemberExpression | estree.ExportSpecifier>(parent);
if (parent.type === 'Property' && !parent.computed && property === 'key') return; // we don't care 'id' part of { id: expr }
if (parent.type === 'MemberExpression' && !parent.computed && property === 'property') return; // we don't care 'id' part of { id: expr }
if (parent.type === 'ExportSpecifier' && property === 'exported') return; // we don't care 'id' part of { id: expr }
if (node.name === localI18nIdentifier) {
fileLogger.error(`${lineCol(sourceCode, node)}: Using i18n identifier "${localI18nIdentifier}" directly. Skipping inlining.`);
preserveI18nImport = true;
}
} else if (node.type === 'MemberExpression') {
assertType<estree.MemberExpression>(node);
const i18nPath = parseI18nPropertyAccess(node);
if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'ts') {
if (parent.type === 'CallExpression' && property === 'callee') return; // we don't want to process `i18n.ts.property.stringBuiltinMethod()`
if (i18nPath.at(-1)?.startsWith('_')) fileLogger.debug(`found i18n grouped property access ${i18nPath.join('.')}`);
else fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n property access ${i18nPath.join('.')}`);
// it's i18n.ts.propertyAccess
// i18n.ts.* will always be resolved to string or object containing strings
modifications.push({
type: 'localized',
begin: node.start,
end: node.end,
localizationKey: i18nPath.slice(1), // remove 'ts' prefix
localizedOnly: true,
});
this.skip();
} else if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'tsx') {
// it's parameterized locale substitution (`i18n.tsx.property(parameters)`)
// we expect the parameter to be an object literal
fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n function access (object) ${i18nPath.join('.')}`);
modifications.push({
type: 'parameterized-function',
begin: node.start,
end: node.end,
localizationKey: i18nPath.slice(1), // remove 'tsx' prefix
localizedOnly: true,
});
this.skip();
}
} else if (node.type === 'ArrowFunctionExpression') {
assertType<estree.ArrowFunctionExpression>(node);
// If there is 'i18n' in the parameters, we care interior of the function
if (node.params.flatMap(param => declsOfPattern(param)).includes(localI18nIdentifier)) this.skip();
}
},
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!preserveI18nImport) {
fileLogger.debug('removing i18n import statement');
modifications.push({
type: 'delete',
begin: i18nImport.start,
end: i18nImport.end,
localizedOnly: true,
});
}
function parseI18nPropertyAccess(node: estree.Expression | estree.Super): string[] | null {
if (node.type === 'Identifier' && node.name === localI18nIdentifier) return []; // i18n itself
if (node.type !== 'MemberExpression') return null;
// super.*
if (node.object.type === 'Super') return null;
// i18n?.property is not supported
if (node.optional) return null;
let id: string | null = null;
if (node.computed) {
if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
id = node.property.value;
}
} else {
if (node.property.type === 'Identifier') {
id = node.property.name;
}
}
// non-constant property access
if (id == null) return null;
const parentAccess = parseI18nPropertyAccess(node.object);
if (parentAccess == null) return null;
return [...parentAccess, id];
}
return modifications;
}
function declsOfPattern(pattern: estree.Pattern | null): string[] {
if (pattern == null) return [];
switch (pattern.type) {
case 'Identifier':
return [pattern.name];
case 'ObjectPattern':
return pattern.properties.flatMap(prop => {
switch (prop.type) {
case 'Property':
return declsOfPattern(prop.value);
case 'RestElement':
return declsOfPattern(prop.argument);
default:
assertNever(prop);
}
});
case 'ArrayPattern':
return pattern.elements.flatMap(p => declsOfPattern(p));
case 'RestElement':
return declsOfPattern(pattern.argument);
case 'AssignmentPattern':
return declsOfPattern(pattern.left);
case 'MemberExpression':
// assignment pattern so no new variable is declared
return [];
default:
assertNever(pattern);
}
}
function lineCol(sourceCode: string, node: estree.Node): string {
assertType<AstNode>(node);
const leading = sourceCode.slice(0, node.start);
const lines = leading.split('\n');
const line = lines.length;
const col = lines[lines.length - 1].length + 1; // +1 for 1-based index
return `(${line}:${col})`;
}
//region checker functions
type Node =
| estree.AssignmentProperty
| estree.CatchClause
| estree.Class
| estree.ClassBody
| estree.Expression
| estree.Function
| estree.Identifier
| estree.Literal
| estree.MethodDefinition
| estree.ModuleDeclaration
| estree.ModuleSpecifier
| estree.Pattern
| estree.PrivateIdentifier
| estree.Program
| estree.Property
| estree.PropertyDefinition
| estree.SpreadElement
| estree.Statement
| estree.Super
| estree.SwitchCase
| estree.TemplateElement
| estree.VariableDeclarator
;
// localStorage.getItem("lang")
function isLocalStorageGetItemLang(getItemCall: Node): boolean {
if (getItemCall.type !== 'CallExpression') return false;
if (getItemCall.arguments.length !== 1) return false;
const langLiteral = getItemCall.arguments[0];
if (!isStringLiteral(langLiteral, 'lang')) return false;
const getItemFunction = getItemCall.callee;
if (!isMemberExpression(getItemFunction, 'getItem')) return false;
const localStorageObject = getItemFunction.object;
if (!isIdentifier(localStorageObject, 'localStorage')) return false;
return true;
}
// await window.fetch(`/assets/locales/${d}.${x}.json`).then(u => u.json(), ....)
function isAwaitFetchLocaleThenJson(awaitNode: Node): boolean {
if (awaitNode.type !== 'AwaitExpression') return false;
const thenCall = awaitNode.argument;
if (thenCall.type !== 'CallExpression') return false;
if (thenCall.arguments.length < 1) return false;
const arrowFunction = thenCall.arguments[0];
if (arrowFunction.type !== 'ArrowFunctionExpression') return false;
if (arrowFunction.params.length !== 1) return false;
const arrowBodyCall = arrowFunction.body;
if (arrowBodyCall.type !== 'CallExpression') return false;
const jsonFunction = arrowBodyCall.callee;
if (!isMemberExpression(jsonFunction, 'json')) return false;
const thenFunction = thenCall.callee;
if (!isMemberExpression(thenFunction, 'then')) return false;
const fetchCall = thenFunction.object;
if (fetchCall.type !== 'CallExpression') return false;
if (fetchCall.arguments.length !== 1) return false;
// `/assets/locales/${d}.${x}.json`
const assetLocaleTemplate = fetchCall.arguments[0];
if (assetLocaleTemplate.type !== 'TemplateLiteral') return false;
if (assetLocaleTemplate.quasis.length !== 3) return false;
if (assetLocaleTemplate.expressions.length !== 2) return false;
if (assetLocaleTemplate.quasis[0].value.cooked !== '/assets/locales/') return false;
if (assetLocaleTemplate.quasis[1].value.cooked !== '.') return false;
if (assetLocaleTemplate.quasis[2].value.cooked !== '.json') return false;
const fetchFunction = fetchCall.callee;
if (!isMemberExpression(fetchFunction, 'fetch')) return false;
const windowObject = fetchFunction.object;
if (!isIdentifier(windowObject, 'window')) return false;
return true;
}
type SpecifierResult =
| { type: 'no-import' }
| { type: 'no-specifiers', importNode: estree.ImportDeclaration & AstNode }
| { type: 'unexpected-specifiers', importNode: estree.ImportDeclaration & AstNode }
| { type: 'specifier', localI18nIdentifier: string, importNode: estree.ImportDeclaration & AstNode }
;
function findImportSpecifier(programNode: ProgramNode, i18nFileName: string, i18nSymbol: string): SpecifierResult {
const imports = programNode.body.filter(x => x.type === 'ImportDeclaration');
const importNode = imports.find(x => x.source.value === `./${i18nFileName}`) as estree.ImportDeclaration | undefined;
if (!importNode) return { type: 'no-import' };
assertType<AstNode>(importNode);
if (importNode.specifiers.length === 0) {
return { type: 'no-specifiers', importNode };
}
if (importNode.specifiers.length !== 1) {
return { type: 'unexpected-specifiers', importNode };
}
const i18nImportSpecifier = importNode.specifiers[0];
if (i18nImportSpecifier.type !== 'ImportSpecifier') {
return { type: 'unexpected-specifiers', importNode };
}
if (i18nImportSpecifier.imported.type !== 'Identifier') {
return { type: 'unexpected-specifiers', importNode };
}
const importingIdentifier = i18nImportSpecifier.imported.name;
if (importingIdentifier !== i18nSymbol) {
return { type: 'unexpected-specifiers', importNode };
}
const localI18nIdentifier = i18nImportSpecifier.local.name;
return { type: 'specifier', localI18nIdentifier, importNode };
}
// checker helpers
function isMemberExpression(node: Node, property: string): node is estree.MemberExpression {
return node.type === 'MemberExpression' && !node.computed && node.property.type === 'Identifier' && node.property.name === property;
}
function isStringLiteral(node: Node, value: string): node is estree.Literal {
return node.type === 'Literal' && typeof node.value === 'string' && node.value === value;
}
function isIdentifier(node: Node, name: string): node is estree.Identifier {
return node.type === 'Identifier' && node.name === name;
}
//endregion

View File

@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as process from 'node:process';
const debug = process.env.BUILDER_DEBUG !== undefined && process.env.BUILDER_DEBUG !== '0';
export interface Logger {
debug(message: string): void;
warn(message: string): void;
error(message: string): void;
info(message: string): void;
prefixed(newPrefix: string): Logger;
}
interface RootLogger extends Logger {
warningCount: number;
errorCount: number;
}
export function createLogger(): RootLogger {
return loggerFactory('', {
warningCount: 0,
errorCount: 0,
});
}
type LogContext = {
warningCount: number;
errorCount: number;
};
function loggerFactory(prefix: string, context: LogContext): RootLogger {
return {
debug: (message: string) => {
if (debug) console.log(`[DBG] ${prefix}${message}`);
},
warn: (message: string) => {
context.warningCount++;
console.log(`${debug ? '[WRN]' : 'w:'} ${prefix}${message}`);
},
error: (message: string) => {
context.errorCount++;
console.error(`${debug ? '[ERR]' : 'e:'} ${prefix}${message}`);
},
info: (message: string) => {
console.error(`${debug ? '[INF]' : 'i:'} ${prefix}${message}`);
},
prefixed: (newPrefix: string) => {
return loggerFactory(`${prefix}${newPrefix}`, context);
},
get warningCount() {
return context.warningCount;
},
get errorCount() {
return context.errorCount;
},
};
}
export const blankLogger: Logger = {
debug: () => void 0,
warn: () => void 0,
error: () => void 0,
info: () => void 0,
prefixed: () => blankLogger,
};

View File

@ -0,0 +1,25 @@
{
"name": "frontend-builder",
"type": "module",
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"typecheck": "tsc --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"exports": {
"./*": "./js/*"
},
"devDependencies": {
"@types/estree": "1.0.8",
"@types/node": "22.17.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"rollup": "4.46.2",
"typescript": "5.9.2"
},
"dependencies": {
"estree-walker": "3.0.3",
"magic-string": "0.30.17",
"vite": "7.0.6"
}
}

View File

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as estreeWalker from 'estree-walker';
import MagicString from 'magic-string';
import { assertType } from './utils.js';
import type { Plugin } from 'vite';
import type { CallExpression, Expression, Program } from 'estree';
import type { AstNode } from 'rollup';
// This plugin transforms `unref(i18n)` to `i18n` in the code, which is useful for removing unnecessary unref calls
// and helps locale inliner runs after vite build to inline the locale data into the final build.
//
// locale inliner cannot know minifiedSymbol(i18n) is 'unref(i18n)' or 'otherFunctionsWithEffect(i18n)' so
// it is necessary to remove unref calls before minification.
export function pluginRemoveUnrefI18n(
{
i18nSymbolName = 'i18n',
}: {
i18nSymbolName?: string
} = {}): Plugin {
return {
name: 'UnwindCssModuleClassName',
renderChunk(code) {
if (!code.includes('unref(i18n)')) return null;
const ast = this.parse(code) as Program;
const magicString = new MagicString(code);
estreeWalker.walk(ast, {
enter(node) {
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'unref'
&& node.arguments.length === 1) {
// calls to unref with single argument
const arg = node.arguments[0];
if (arg.type === 'Identifier' && arg.name === i18nSymbolName) {
// this is unref(i18n) so replace it with i18n
// to replace, remove the 'unref(' and the trailing ')'
assertType<CallExpression & AstNode>(node);
assertType<Expression & AstNode>(arg);
magicString.remove(node.start, arg.start);
magicString.remove(arg.end, node.end);
}
}
},
});
return {
code: magicString.toString(),
map: magicString.generateMap({ hires: true }),
};
},
};
}

View File

@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"declaration": true,
"declarationMap": true,
"sourceMap": false,
"noEmit": true,
"removeComments": true,
"resolveJsonModule": true,
"strict": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"baseUrl": ".",
"typeRoots": [
"./@types",
"./node_modules/@types"
],
"lib": [
"esnext"
]
}
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export function assertNever(x: never): never {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
throw new Error(`Unexpected type: ${(x as any)?.type ?? x}`);
}
export function assertType<T>(node: unknown): asserts node is T {
}

View File

@ -0,0 +1,51 @@
import * as fs from 'fs/promises';
import url from 'node:url';
import path from 'node:path';
import { execa } from 'execa';
import locales from '../../locales/index.js';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger';
// requires node 21 or later
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outputDir = __dirname + '/../../built/_frontend_embed_vite_';
/**
* @return {Promise<void>}
*/
async function viteBuild() {
await execa('vite', ['build'], {
cwd: __dirname,
stdout: process.stdout,
stderr: process.stderr,
});
}
async function buildAllLocale() {
const logger = createLogger()
const inliner = await LocaleInliner.create({
outputDir,
logger,
scriptsDir: 'scripts',
i18nFile: 'src/i18n.ts',
})
await inliner.loadFiles();
inliner.collectsModifications();
await inliner.saveAllLocales(locales);
if (logger.errorCount > 0) {
throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
}
}
async function build() {
await fs.rm(outputDir, { recursive: true, force: true });
await viteBuild();
await buildAllLocale();
}
await build();

View File

@ -4,68 +4,69 @@
"type": "module",
"scripts": {
"watch": "vite",
"build": "vite build",
"build": "tsx build.ts",
"typecheck": "vue-tsc --noEmit",
"eslint": "eslint --quiet \"src/**/*.{ts,vue}\"",
"lint": "pnpm typecheck && pnpm eslint"
},
"dependencies": {
"@discordapp/twemoji": "15.1.0",
"@discordapp/twemoji": "16.0.1",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.2.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.2.4",
"@vue/compiler-sfc": "3.5.17",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "3.5.18",
"astring": "1.9.0",
"buraha": "0.0.1",
"estree-walker": "3.0.3",
"icons-subsetter": "workspace:*",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"json5": "2.2.3",
"mfm-js": "0.24.0",
"mfm-js": "0.25.0",
"misskey-js": "workspace:*",
"punycode.js": "2.3.1",
"rollup": "4.45.1",
"rollup": "4.46.2",
"sass": "1.89.2",
"shiki": "3.8.0",
"shiki": "3.9.1",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.8.3",
"typescript": "5.9.2",
"uuid": "11.1.0",
"vite": "6.3.5",
"vue": "3.5.17"
"vite": "7.0.6",
"vue": "3.5.18"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.2",
"@tabler/icons-webfont": "3.34.0",
"@misskey-dev/summaly": "5.2.3",
"@tabler/icons-webfont": "3.34.1",
"@testing-library/vue": "8.1.0",
"@types/estree": "1.0.8",
"@types/micromatch": "4.0.9",
"@types/node": "22.16.4",
"@types/node": "22.17.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.37.0",
"@typescript-eslint/parser": "8.37.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"@vitest/coverage-v8": "3.2.4",
"@vue/runtime-core": "3.5.17",
"@vue/runtime-core": "3.5.18",
"acorn": "8.15.0",
"cross-env": "7.0.3",
"cross-env": "10.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.3.0",
"eslint-plugin-vue": "10.4.0",
"fast-glob": "3.3.3",
"happy-dom": "17.6.3",
"happy-dom": "18.0.1",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"msw": "2.10.4",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"start-server-and-test": "2.0.12",
"tsx": "4.20.3",
"vite-plugin-turbosnap": "1.0.3",
"vue-component-type-helpers": "2.2.12",
"vue-component-type-helpers": "3.0.5",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "2.2.12"
"vue-tsc": "3.0.5"
}
}

View File

@ -17,15 +17,16 @@ import { createApp, defineAsyncComponent } from 'vue';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-dark.json5';
import { MediaProxy } from '@@/js/media-proxy.js';
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
import { applyTheme, assertIsTheme } from '@/theme.js';
import { fetchCustomEmojis } from '@/custom-emojis.js';
import { DI } from '@/di.js';
import { serverMetadata } from '@/server-metadata.js';
import { url, version, locale, lang, updateLocale } from '@@/js/config.js';
import { url, version, lang } from '@@/js/config.js';
import { parseEmbedParams } from '@@/js/embed-page.js';
import { postMessageToParentWindow, setIframeId } from '@/post-message.js';
import { serverContext } from '@/server-context.js';
import { i18n, updateI18n } from '@/i18n.js';
import { i18n } from '@/i18n.js';
import type { Theme } from '@/theme.js';
@ -76,19 +77,7 @@ if (embedParams.colorMode === 'dark') {
//#endregion
//#region Detect language & fetch translations
const localeVersion = localStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
if (localeOutdated) {
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
localStorage.setItem('locale', newLocale);
localStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
}
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
//#endregion
// サイズの制限

View File

@ -5,11 +5,12 @@
import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js';
import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js';
import { locale } from '@@/js/config.js';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
// test 以外では使わないこと。インライン化されてるのでだいたい意味がない
export function updateI18n(newLocale: Locale) {
i18n.locale = newLocale;
}

View File

@ -8,6 +8,7 @@ import locales from '../../locales/index.js';
import meta from '../../package.json';
import packageInfo from './package.json' with { type: 'json' };
import pluginJson5 from './vite.json5.js';
import { pluginRemoveUnrefI18n } from '../frontend-builder/rollup-plugin-remove-unref-i18n';
const url = process.env.NODE_ENV === 'development' ? yaml.load(await fsp.readFile('../../.config/default.yml', 'utf-8')).url : null;
const host = url ? (new URL(url)).hostname : undefined;
@ -85,6 +86,7 @@ export function getConfig(): UserConfig {
plugins: [
pluginVue(),
pluginRemoveUnrefI18n(),
pluginJson5(),
],
@ -135,15 +137,20 @@ export function getConfig(): UserConfig {
manifest: 'manifest.json',
rollupOptions: {
input: {
app: './src/boot.ts',
i18n: './src/i18n.ts',
entry: './src/boot.ts',
},
external: externalPackages.map(p => p.match),
preserveEntrySignatures: 'allow-extension',
output: {
manualChunks: {
vue: ['vue'],
// dependencies of i18n.ts
'config': ['@@/js/config.js'],
},
chunkFileNames: process.env.NODE_ENV === 'production' ? '[hash:8].js' : '[name]-[hash:8].js',
assetFileNames: process.env.NODE_ENV === 'production' ? '[hash:8][extname]' : '[name]-[hash:8][extname]',
entryFileNames: 'scripts/[hash:8].js',
chunkFileNames: 'scripts/[hash:8].js',
assetFileNames: 'assets/[hash:8][extname]',
paths(id) {
for (const p of externalPackages) {
if (p.match.test(id)) {

View File

@ -3,8 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locale } from '../../../locales/index.js';
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const address = new URL(document.querySelector<HTMLMetaElement>('meta[property="instance_url"]')?.content || location.href);
const siteName = document.querySelector<HTMLMetaElement>('meta[property="og:site_name"]')?.content;
@ -17,14 +15,8 @@ export const apiUrl = location.origin + '/api';
export const wsOrigin = location.origin;
export const lang = localStorage.getItem('lang') ?? 'en-US';
export const langs = _LANGS_;
const preParseLocale = localStorage.getItem('locale');
export let locale: Locale = preParseLocale ? JSON.parse(preParseLocale) : null;
export const version = _VERSION_;
export const instanceName = (siteName === 'Misskey' || siteName == null) ? host : siteName;
export const ui = localStorage.getItem('ui');
export const debug = localStorage.getItem('debug') === 'true';
export const isSafeMode = localStorage.getItem('isSafeMode') === 'true';
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View File

@ -44,6 +44,8 @@
["😑", "expressionless", 0],
["😒", "unamused", 0],
["🙄", "roll_eyes", 0],
["🙂‍↔️", "head_shaking_horizontally", 0],
["🙂‍↕️", "head_shaking_vertically", 0],
["🤔", "thinking", 0],
["🤥", "lying_face", 0],
["🤭", "hand_over_mouth", 0],
@ -76,6 +78,7 @@
["😥", "disappointed_relieved", 0],
["🤤", "drooling_face", 0],
["😪", "sleepy", 0],
["🫩", "face_with_bags_under_eyes", 0],
["😓", "sweat", 0],
["🥵", "hot", 0],
["🥶", "cold", 0],
@ -92,6 +95,7 @@
["🥴", "woozy", 0],
["🥱", "yawning", 0],
["😴", "sleeping", 0],
["👁️‍🗨️", "eye_in_speech_bubble", 0],
["💤", "zzz", 0],
["😶‍🌫️", "face_in_clouds", 0],
["😮‍💨", "face_exhaling", 0],
@ -185,6 +189,7 @@
["👤", "bust_in_silhouette", 1],
["👥", "busts_in_silhouette", 1],
["🗣", "speaking_head", 1],
["🫆", "fingerprint", 1],
["👶", "baby", 1],
["🧒", "child", 1],
["👦", "boy", 1],
@ -198,8 +203,9 @@
["🧑‍🦰", "red_hair", 1],
["👩‍🦰", "red_hair_woman", 1],
["👨‍🦰", "red_hair_man", 1],
["👱", "blonde_person", 1],
["👱‍♀️", "blonde_woman", 1],
["👱", "blonde_man", 1],
["👱‍♂️", "blonde_man", 1],
["🧑‍🦳", "white_hair", 1],
["👩‍🦳", "white_hair_woman", 1],
["👨‍🦳", "white_hair_man", 1],
@ -207,21 +213,28 @@
["👩‍🦲", "bald_woman", 1],
["👨‍🦲", "bald_man", 1],
["🧔", "bearded_person", 1],
["🧔‍♀️", "beard_woman", 1],
["🧔‍♂️", "beard_man", 1],
["🧓", "older_adult", 1],
["👴", "older_man", 1],
["👵", "older_woman", 1],
["👲", "man_with_gua_pi_mao", 1],
["🧕", "woman_with_headscarf", 1],
["👳", "person_with_turban", 1],
["👳‍♀️", "woman_with_turban", 1],
["👳", "man_with_turban", 1],
["👳‍♂️", "man_with_turban", 1],
["👮", "police_officer", 1],
["👮‍♀️", "policewoman", 1],
["👮", "policeman", 1],
["👮‍♂️", "policeman", 1],
["👷", "construction_worker", 1],
["👷‍♀️", "construction_worker_woman", 1],
["👷", "construction_worker_man", 1],
["👷‍♂️", "construction_worker_man", 1],
["💂", "guard", 1],
["💂‍♀️", "guardswoman", 1],
["💂", "guardsman", 1],
["💂‍♂️", "guardsman", 1],
["🕵", "detective", 1],
["🕵️‍♀️", "female_detective", 1],
["🕵", "male_detective", 1],
["🕵️‍♂️", "male_detective", 1],
["🧑‍⚕️", "health_worker", 1],
["👩‍⚕️", "woman_health_worker", 1],
["👨‍⚕️", "man_health_worker", 1],
@ -270,26 +283,35 @@
["🧑‍⚖️", "judge", 1],
["👩‍⚖️", "woman_judge", 1],
["👨‍⚖️", "man_judge", 1],
["🦸", "superhero", 1],
["🦸‍♀️", "woman_superhero", 1],
["🦸‍♂️", "man_superhero", 1],
["🦹", "supervillain", 1],
["🦹‍♀️", "woman_supervillain", 1],
["🦹‍♂️", "man_supervillain", 1],
["🤶", "mrs_claus", 1],
["🧑‍🎄", "mx_claus", 1],
["🎅", "santa", 1],
["🥷", "ninja", 1],
["🧙", "mage", 1],
["🧙‍♀️", "sorceress", 1],
["🧙‍♂️", "wizard", 1],
["🧝", "elf", 1],
["🧝‍♀️", "woman_elf", 1],
["🧝‍♂️", "man_elf", 1],
["🧛", "vampire", 1],
["🧛‍♀️", "woman_vampire", 1],
["🧛‍♂️", "man_vampire", 1],
["🧟", "zombie", 1],
["🧟‍♀️", "woman_zombie", 1],
["🧟‍♂️", "man_zombie", 1],
["🧞", "genie", 1],
["🧞‍♀️", "woman_genie", 1],
["🧞‍♂️", "man_genie", 1],
["🧜", "merperson", 1],
["🧜‍♀️", "mermaid", 1],
["🧜‍♂️", "merman", 1],
["🧚", "fairy", 1],
["🧚‍♀️", "woman_fairy", 1],
["🧚‍♂️", "man_fairy", 1],
["👼", "angel", 1],
@ -305,68 +327,108 @@
["👸", "princess", 1],
["🤴", "prince", 1],
["👰", "person_with_veil", 1],
["👰", "bride_with_veil", 1],
["👰‍♀️", "bride_with_veil", 1],
["👰‍♂️", "man_with_veil", 1],
["🤵", "person_in_tuxedo", 1],
["🤵", "man_in_tuxedo", 1],
["🤵‍♀️", "woman_in_tuxedo", 1],
["🤵‍♂️", "man_in_tuxedo", 1],
["🏃", "running_person", 1],
["🏃‍➡️", "running_person_facing_right", 1],
["🏃‍♀️", "running_woman", 1],
["🏃", "running_man", 1],
["🏃‍♀️‍➡️", "running_woman_facing_right", 1],
["🏃‍♂️", "running_man", 1],
["🏃‍♂️‍➡️", "running_man_facing_right", 1],
["🚶", "walking_person", 1],
["🚶‍➡️", "walking_person_facing_right", 1],
["🚶‍♀️", "walking_woman", 1],
["🚶", "walking_man", 1],
["🚶‍♀️‍➡️", "walking_woman_facing_right", 1],
["🚶‍♂️", "walking_man", 1],
["🚶‍♂️‍➡️", "walking_man_facing_right", 1],
["💃", "dancer", 1],
["🕺", "man_dancing", 1],
["👯", "dancing_women", 1],
["👯", "dancing_people", 1],
["👯‍♀️", "dancing_women", 1],
["👯‍♂️", "dancing_men", 1],
["👫", "couple", 1],
["🧑‍🤝‍🧑", "people_holding_hands", 1],
["👬", "two_men_holding_hands", 1],
["👭", "two_women_holding_hands", 1],
["🫂", "people_hugging", 1],
["🙇", "bowing_person", 1],
["🙇‍♀️", "bowing_woman", 1],
["🙇", "bowing_man", 1],
["🙇‍♂️", "bowing_man", 1],
["🤦", "person_facepalming", 1],
["🤦‍♂️", "man_facepalming", 1],
["🤦‍♀️", "woman_facepalming", 1],
["🤷", "woman_shrugging", 1],
["🤷", "person_shrugging", 1],
["🤷‍♀️", "woman_shrugging", 1],
["🤷‍♂️", "man_shrugging", 1],
["💁", "tipping_hand_woman", 1],
["💁", "tipping_hand_person", 1],
["💁‍♀️", "tipping_hand_woman", 1],
["💁‍♂️", "tipping_hand_man", 1],
["🙅", "no_good_woman", 1],
["🙅", "no_good_person", 1],
["🙅‍♀️", "no_good_woman", 1],
["🙅‍♂️", "no_good_man", 1],
["🙆", "ok_woman", 1],
["🙆", "ok_person", 1],
["🙆‍♀️", "ok_woman", 1],
["🙆‍♂️", "ok_man", 1],
["🙋", "raising_hand_woman", 1],
["🙋", "raising_hand_person", 1],
["🙋‍♀️", "raising_hand_woman", 1],
["🙋‍♂️", "raising_hand_man", 1],
["🙎", "pouting_woman", 1],
["🙎", "pouting_person", 1],
["🙎‍♀️", "pouting_woman", 1],
["🙎‍♂️", "pouting_man", 1],
["🙍", "frowning_woman", 1],
["🙍", "frowning_person", 1],
["🙍‍♀️", "frowning_woman", 1],
["🙍‍♂️", "frowning_man", 1],
["💇", "haircut_woman", 1],
["💇", "haircut_person", 1],
["💇‍♀️", "haircut_woman", 1],
["💇‍♂️", "haircut_man", 1],
["💆", "massage_woman", 1],
["💆", "massage_person", 1],
["💆‍♀️", "massage_woman", 1],
["💆‍♂️", "massage_man", 1],
["🧖", "person_in_steamy_room", 1],
["🧖‍♀️", "woman_in_steamy_room", 1],
["🧖‍♂️", "man_in_steamy_room", 1],
["🧏", "person_deaf", 1],
["🧏‍♀️", "woman_deaf", 1],
["🧏‍♂️", "man_deaf", 1],
["🧍", "person_standing", 1],
["🧍‍♀️", "woman_standing", 1],
["🧍‍♂️", "man_standing", 1],
["🧎", "person_kneeling", 1],
["🧎‍➡️", "person_kneeling_facing_right", 1],
["🧎‍♀️", "woman_kneeling", 1],
["🧎‍♀️‍➡️", "woman_kneeling_facing_right", 1],
["🧎‍♂️", "man_kneeling", 1],
["🧎‍♂️‍➡️", "man_kneeling_facing_right", 1],
["🧑‍🦯", "person_with_probing_cane", 1],
["🧑‍🦯‍➡️", "person_with_probing_cane_facing_right", 1],
["👩‍🦯", "woman_with_probing_cane", 1],
["👩‍🦯‍➡️", "woman_with_probing_cane_facing_right", 1],
["👨‍🦯", "man_with_probing_cane", 1],
["👨‍🦯‍➡️", "man_with_probing_cane_facing_right", 1],
["🧑‍🦼", "person_in_motorized_wheelchair", 1],
["🧑‍🦼‍➡️", "person_in_motorized_wheelchair_facing_right", 1],
["👩‍🦼", "woman_in_motorized_wheelchair", 1],
["👩‍🦼‍➡️", "woman_in_motorized_wheelchair_facing_right", 1],
["👨‍🦼", "man_in_motorized_wheelchair", 1],
["👨‍🦼‍➡️", "man_in_motorized_wheelchair_facing_right", 1],
["🧑‍🦽", "person_in_manual_wheelchair", 1],
["🧑‍🦽‍➡️", "person_in_manual_wheelchair_facing_right", 1],
["👩‍🦽", "woman_in_manual_wheelchair", 1],
["👩‍🦽‍➡️", "woman_in_manual_wheelchair_facing_right", 1],
["👨‍🦽", "man_in_manual_wheelchair", 1],
["💑", "couple_with_heart_woman_man", 1],
["👨‍🦽‍➡️", "man_in_manual_wheelchair_facing_right", 1],
["💑", "couple_with_heart", 1],
["👩‍❤️‍👨", "couple_with_heart_woman_man", 1],
["👩‍❤️‍👩", "couple_with_heart_woman_woman", 1],
["👨‍❤️‍👨", "couple_with_heart_man_man", 1],
["💏", "couplekiss_man_woman", 1],
["💏", "couplekiss", 1],
["👩‍❤️‍💋‍👨", "couplekiss_woman_man", 1],
["👩‍❤️‍💋‍👩", "couplekiss_woman_woman", 1],
["👨‍❤️‍💋‍👨", "couplekiss_man_man", 1],
["👪", "family_man_woman_boy", 1],
["👨‍👩‍👦", "family_man_woman_boy", 1],
["👨‍👩‍👧", "family_man_woman_girl", 1],
["👨‍👩‍👧‍👦", "family_man_woman_girl_boy", 1],
["👨‍👩‍👦‍👦", "family_man_woman_boy_boy", 1],
@ -391,6 +453,11 @@
["👨‍👧‍👦", "family_man_girl_boy", 1],
["👨‍👦‍👦", "family_man_boy_boy", 1],
["👨‍👧‍👧", "family_man_girl_girl", 1],
["👪", "family", 1],
["🧑‍🧑‍🧒", "family_adult_adult_child", 1],
["🧑‍🧑‍🧒‍🧒", "family_adult_adult_child_child", 1],
["🧑‍🧒", "family_adult_child", 1],
["🧑‍🧒‍🧒", "family_adult_child_child", 1],
["🧶", "yarn", 1],
["🧵", "thread", 1],
["🧥", "coat", 1],
@ -475,6 +542,7 @@
["🐦‍⬛", "black_bird", 2],
["🦅", "eagle", 2],
["🦉", "owl", 2],
["🐦‍🔥", "phoenix", 2],
["🦇", "bat", 2],
["🐺", "wolf", 2],
["🐗", "boar", 2],
@ -575,6 +643,7 @@
["🌿", "herb", 2],
["☘", "shamrock", 2],
["🍀", "four_leaf_clover", 2],
["🪾", "leafless_tree", 2],
["🎍", "bamboo", 2],
["🎋", "tanabata_tree", 2],
["🍃", "leaves", 2],
@ -648,6 +717,7 @@
["🪸", "coral", 2],
["🪹", "empty_nest", 2],
["🪺", "nest_with_eggs", 2],
["🍋‍🟩", "lime", 3],
["🍏", "green_apple", 3],
["🍎", "apple", 3],
["🍐", "pear", 3],
@ -667,6 +737,8 @@
["🥑", "avocado", 3],
["🫛", "pea_pod", 3],
["🥦", "broccoli", 3],
["🍄‍🟫", "brown_mushroom", 3],
["🫜", "root_vegetable", 3],
["🍅", "tomato", 3],
["🍆", "eggplant", 3],
["🥒", "cucumber", 3],
@ -786,8 +858,9 @@
["🥏", "flying_disc", 4],
["🎱", "8ball", 4],
["⛳", "golf", 4],
["🏌", "golfing_person", 4],
["🏌️‍♀️", "golfing_woman", 4],
["🏌", "golfing_man", 4],
["🏌️‍♂️", "golfing_man", 4],
["🏓", "ping_pong", 4],
["🏸", "badminton", 4],
["🥅", "goal_net", 4],
@ -799,10 +872,13 @@
["⛷", "skier", 4],
["🏂", "snowboarder", 4],
["🤺", "person_fencing", 4],
["🤼", "people_wrestling", 4],
["🤼‍♀️", "women_wrestling", 4],
["🤼‍♂️", "men_wrestling", 4],
["🤸", "person_cartwheeling", 4],
["🤸‍♀️", "woman_cartwheeling", 4],
["🤸‍♂️", "man_cartwheeling", 4],
["🤾", "person_playing_handball", 4],
["🤾‍♀️", "woman_playing_handball", 4],
["🤾‍♂️", "man_playing_handball", 4],
["⛸", "ice_skate", 4],
@ -813,27 +889,37 @@
["🎣", "fishing_pole_and_fish", 4],
["🥊", "boxing_glove", 4],
["🥋", "martial_arts_uniform", 4],
["🚣", "rowing_person", 4],
["🚣‍♀️", "rowing_woman", 4],
["🚣", "rowing_man", 4],
["🚣‍♂️", "rowing_man", 4],
["🧗", "climbing_person", 4],
["🧗‍♀️", "climbing_woman", 4],
["🧗‍♂️", "climbing_man", 4],
["🏊", "swimming_person", 4],
["🏊‍♀️", "swimming_woman", 4],
["🏊", "swimming_man", 4],
["🏊‍♂️", "swimming_man", 4],
["🤽", "person_playing_water_polo", 4],
["🤽‍♀️", "woman_playing_water_polo", 4],
["🤽‍♂️", "man_playing_water_polo", 4],
["🧘", "person_in_lotus_position", 4],
["🧘‍♀️", "woman_in_lotus_position", 4],
["🧘‍♂️", "man_in_lotus_position", 4],
["🏄", "surfing_person", 4],
["🏄‍♀️", "surfing_woman", 4],
["🏄", "surfing_man", 4],
["🏄‍♂️", "surfing_man", 4],
["🛀", "bath", 4],
["⛹", "basketball_person", 4],
["⛹️‍♀️", "basketball_woman", 4],
["⛹", "basketball_man", 4],
["⛹️‍♂️", "basketball_man", 4],
["🏋", "weight_lifting_person", 4],
["🏋️‍♀️", "weight_lifting_woman", 4],
["🏋", "weight_lifting_man", 4],
["🏋️‍♂️", "weight_lifting_man", 4],
["🚴", "biking_person", 4],
["🚴‍♀️", "biking_woman", 4],
["🚴", "biking_man", 4],
["🚴‍♂️", "biking_man", 4],
["🚵", "mountain_biking_person", 4],
["🚵‍♀️", "mountain_biking_woman", 4],
["🚵", "mountain_biking_man", 4],
["🚵‍♂️", "mountain_biking_man", 4],
["🏇", "horse_racing", 4],
["🤿", "diving_mask", 4],
["🪀", "yo_yo", 4],
@ -856,6 +942,7 @@
["🎭", "performing_arts", 4],
["🎨", "art", 4],
["🎪", "circus_tent", 4],
["🤹", "person_juggling", 4],
["🤹‍♀️", "woman_juggling", 4],
["🤹‍♂️", "man_juggling", 4],
["🎤", "microphone", 4],
@ -872,6 +959,7 @@
["🪕", "banjo", 4],
["🪗", "accordion", 4],
["🪘", "long_drum", 4],
["🪉", "harp", 4],
["🎬", "clapper", 4],
["🎮", "video_game", 4],
["👾", "space_invader", 4],
@ -1076,8 +1164,10 @@
["🪙", "coin", 6],
["💳", "credit_card", 6],
["🪪", "identification_card", 6],
["🥾", "hiking_boot", 6],
["💎", "gem", 6],
["⚖", "balance_scale", 6],
["⛓️‍💥", "broken_chain", 6],
["🧰", "toolbox", 6],
["🔧", "wrench", 6],
["🔨", "hammer", 6],
@ -1093,6 +1183,7 @@
["🪛", "screwdriver", 6],
["🪝", "hook", 6],
["🪜", "ladder", 6],
["🪏", "shovel", 6],
["🧱", "brick", 6],
["⛓", "chains", 6],
["🧲", "magnet", 6],
@ -1304,6 +1395,8 @@
["♓", "pisces", 7],
["🆔", "id", 7],
["⚛", "atom_symbol", 7],
["♀️", "female_sign", 7],
["♂️", "male_sign", 7],
["⚧️", "transgender_symbol", 7],
["🈳", "u7a7a", 7],
["🈹", "u5272", 7],
@ -1463,9 +1556,11 @@
["♾", "infinity", 7],
["💲", "heavy_dollar_sign", 7],
["💱", "currency_exchange", 7],
["⚕️", "medical_symbol", 7],
["©️", "copyright", 7],
["®️", "registered", 7],
["™️", "tm", 7],
["🫟", "splatter", 7],
["🔚", "end", 7],
["🔙", "back", 7],
["🔛", "on", 7],
@ -1576,6 +1671,7 @@
["🇧🇲", "bermuda", 8],
["🇧🇹", "bhutan", 8],
["🇧🇴", "bolivia", 8],
["🇧🇻", "bouvet_island", 8],
["🇧🇶", "caribbean_netherlands", 8],
["🇧🇦", "bosnia_herzegovina", 8],
["🇧🇼", "botswana", 8],
@ -1593,10 +1689,12 @@
["🇮🇨", "canary_islands", 8],
["🇰🇾", "cayman_islands", 8],
["🇨🇫", "central_african_republic", 8],
["🇪🇦", "ceuta_melilla", 8],
["🇹🇩", "chad", 8],
["🇨🇱", "chile", 8],
["🇨🇳", "cn", 8],
["🇨🇽", "christmas_island", 8],
["🇨🇵", "clipperton_island", 8],
["🇨🇨", "cocos_islands", 8],
["🇨🇴", "colombia", 8],
["🇰🇲", "comoros", 8],
@ -1610,6 +1708,7 @@
["🇨🇾", "cyprus", 8],
["🇨🇿", "czech_republic", 8],
["🇩🇰", "denmark", 8],
["🇩🇬", "diego_garcia", 8],
["🇩🇯", "djibouti", 8],
["🇩🇲", "dominica", 8],
["🇩🇴", "dominican_republic", 8],
@ -1646,6 +1745,7 @@
["🇬🇼", "guinea_bissau", 8],
["🇬🇾", "guyana", 8],
["🇭🇹", "haiti", 8],
["🇭🇲", "heard_mcdonald_islands", 8],
["🇭🇳", "honduras", 8],
["🇭🇰", "hong_kong", 8],
["🇭🇺", "hungary", 8],
@ -1733,10 +1833,12 @@
["🇷🇴", "romania", 8],
["🇷🇺", "ru", 8],
["🇷🇼", "rwanda", 8],
["🇨🇶", "sark", 8],
["🇧🇱", "st_barthelemy", 8],
["🇸🇭", "st_helena", 8],
["🇰🇳", "st_kitts_nevis", 8],
["🇱🇨", "st_lucia", 8],
["🇲🇫", "st_martin", 8],
["🇵🇲", "st_pierre_miquelon", 8],
["🇻🇨", "st_vincent_grenadines", 8],
["🇼🇸", "samoa", 8],
@ -1762,6 +1864,7 @@
["🇸🇩", "sudan", 8],
["🇸🇷", "suriname", 8],
["🇸🇿", "swaziland", 8],
["🇸🇯", "svalbard_jan_mayen", 8],
["🇸🇪", "sweden", 8],
["🇨🇭", "switzerland", 8],
["🇸🇾", "syria", 8],
@ -1788,6 +1891,7 @@
["🏴󠁧󠁢󠁳󠁣󠁴󠁿", "scotland", 8],
["🏴󠁧󠁢󠁷󠁬󠁳󠁿", "wales", 8],
["🇺🇸", "us", 8],
["🇺🇲", "us_outlying_islands", 8],
["🇻🇮", "us_virgin_islands", 8],
["🇺🇾", "uruguay", 8],
["🇺🇿", "uzbekistan", 8],

View File

@ -28,7 +28,8 @@ type ParametersOf<T extends ILocale, TKey extends FlattenKeys<T, ParameterizedSt
: never;
type Tsx<T extends ILocale> = {
readonly [K in keyof T as T[K] extends string ? never : K]: T[K] extends ParameterizedString<infer P>
// `string extends T[K] ? never : K` part removes non-parameterized string keys from Tsx type.
readonly [K in keyof T as string extends T[K] ? never : K]: T[K] extends ParameterizedString<infer P>
? (arg: { readonly [_ in P]: string | number }) => string
// @ts-expect-error -- 証明省略
: Tsx<T[K]>;
@ -39,11 +40,7 @@ export class I18n<T extends ILocale> {
private devMode: boolean;
constructor(public locale: T, devMode = false) {
// 場合によってはバージョンアップ前の翻訳データを参照した結果存在しないプロパティにアクセスしてクライアントが起動できなくなることがある問題の応急処置として非devモードでもプロキシする
// TODO: https://github.com/misskey-dev/misskey/issues/14453 が実装されたらそのようなことは発生し得なくなるため消す
const oukyuusyoti = true;
this.devMode = devMode || oukyuusyoti;
this.devMode = devMode;
//#region BIND
this.t = this.t.bind(this);

View File

@ -0,0 +1,14 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { lang, version } from '@@/js/config.js';
import type { Locale } from '../../../locales/index.js';
// ここはビルド時に const locale = JSON.parse("...") みたいな感じで置き換えられるので top-level await は消える
export let locale: Locale = await window.fetch(`/assets/locales/${lang}.${version}.json`).then(r => r.json(), () => null);
export function updateLocale(newLocale: Locale): void {
locale = newLocale;
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Locale } from '../../../locales/index.js';
type BootLoaderLocaleBody = Locale['_bootErrors'] & { reload: Locale['reload'] };
export function storeBootloaderErrors(locale: BootLoaderLocaleBody) {
localStorage.setItem('bootloaderLocales', JSON.stringify(locale));
}

View File

@ -21,13 +21,13 @@
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/node": "22.16.4",
"@typescript-eslint/eslint-plugin": "8.37.0",
"@typescript-eslint/parser": "8.37.0",
"esbuild": "0.25.6",
"eslint-plugin-vue": "10.3.0",
"@types/node": "22.17.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"esbuild": "0.25.8",
"eslint-plugin-vue": "10.4.0",
"nodemon": "3.1.10",
"typescript": "5.8.3",
"typescript": "5.9.2",
"vue-eslint-parser": "10.2.0"
},
"files": [
@ -35,6 +35,6 @@
],
"dependencies": {
"misskey-js": "workspace:*",
"vue": "3.5.17"
"vue": "3.5.18"
}
}

View File

@ -1 +1,2 @@
/storybook-static
/build/

View File

@ -34,7 +34,7 @@ export const commonHandlers = [
}),
http.get('/twemoji/:codepoints.svg', async ({ params }) => {
const { codepoints } = params;
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@15.0.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@16.0.1/dist/svg/${codepoints}.svg`).then((response) => response.blob());
return new HttpResponse(value, {
headers: {
'Content-Type': 'image/svg+xml',

View File

@ -9,7 +9,6 @@ import { type Preview, setup } from '@storybook/vue3';
import isChromatic from 'chromatic/isChromatic';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { userDetailed } from './fakes.js';
import locale from './locale.js';
import { commonHandlers, onUnhandledRequest } from './mocks.js';
import themes from './themes.js';
import '../src/style.scss';
@ -55,7 +54,6 @@ function initLocalStorage() {
...userDetailed(),
policies: {},
}));
localStorage.setItem('locale', JSON.stringify(locale));
}
initialize({

View File

@ -0,0 +1,51 @@
import * as fs from 'fs/promises';
import url from 'node:url';
import path from 'node:path';
import { execa } from 'execa';
import locales from '../../locales/index.js';
import { LocaleInliner } from '../frontend-builder/locale-inliner.js'
import { createLogger } from '../frontend-builder/logger';
// requires node 21 or later
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
const outputDir = __dirname + '/../../built/_frontend_vite_';
/**
* @return {Promise<void>}
*/
async function viteBuild() {
await execa('vite', ['build'], {
cwd: __dirname,
stdout: process.stdout,
stderr: process.stderr,
});
}
async function buildAllLocale() {
const logger = createLogger()
const inliner = await LocaleInliner.create({
outputDir,
logger,
scriptsDir: 'scripts',
i18nFile: 'src/i18n.ts',
})
await inliner.loadFiles();
inliner.collectsModifications();
await inliner.saveAllLocales(locales);
if (logger.errorCount > 0) {
throw new Error(`Build failed with ${logger.errorCount} errors and ${logger.warningCount} warnings.`);
}
}
async function build() {
await fs.rm(outputDir, { recursive: true, force: true });
await viteBuild();
await buildAllLocale();
}
await build();

View File

@ -39,6 +39,7 @@ export interface SearchIndexItem {
path?: string;
label: string;
keywords: string[];
texts: string[];
icon?: string;
inlining?: string[];
}
@ -227,14 +228,14 @@ function extractElementText2Inner(node: TemplateChildNode, processingNodeName: s
// region extractUsageInfoFromTemplateAst
/**
* SearchLabel/SearchKeyword/SearchIconを探して抽出する関数
* SearchLabel/SearchText/SearchIconを探して抽出する関数
*/
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null, keywords: string[], icon: string | null } {
function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: string | null; texts: string[]; icon: string | null; } {
let label: string | null | undefined = undefined;
let icon: string | null | undefined = undefined;
const keywords: string[] = [];
const texts: string[] = [];
logger.info(`Extracting labels and keywords from ${nodes.length} nodes`);
logger.info(`Extracting labels and texts from ${nodes.length} nodes`);
walkVueElements(nodes, null, (node) => {
switch (node.tag) {
@ -248,10 +249,10 @@ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: stri
label = extractElementText(node, id);
return;
case 'SearchKeyword':
case 'SearchText':
const content = extractElementText(node, id);
if (content) {
keywords.push(content);
texts.push(content);
}
return;
case 'SearchIcon':
@ -278,8 +279,8 @@ function extractSugarTags(nodes: TemplateChildNode[], id: string): { label: stri
});
// デバッグ情報
logger.info(`Extraction completed: label=${label}, keywords=[${keywords.join(', ')}, icon=${icon}]`);
return { label: label ?? null, keywords, icon: icon ?? null };
logger.info(`Extraction completed: label=${label}, text=[${texts.join(', ')}, icon=${icon}]`);
return { label: label ?? null, texts, icon: icon ?? null };
}
function getStringProp(attr: AttributeNode | DirectiveNode | null, id: string): string | null {
@ -351,33 +352,36 @@ function extractUsageInfoFromTemplateAst(
parentId: parentId ?? undefined,
label: '', // デフォルト値
keywords: [],
texts: [],
};
// バインドプロパティを取得
const path = getStringProp(findAttribute(node.props, 'path'), id)
const icon = getStringProp(findAttribute(node.props, 'icon'), id)
const label = getStringProp(findAttribute(node.props, 'label'), id)
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id)
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id)
const path = getStringProp(findAttribute(node.props, 'path'), id);
const icon = getStringProp(findAttribute(node.props, 'icon'), id);
const label = getStringProp(findAttribute(node.props, 'label'), id);
const inlining = getStringArrayProp(findAttribute(node.props, 'inlining'), id);
const keywords = getStringArrayProp(findAttribute(node.props, 'keywords'), id);
const texts = getStringArrayProp(findAttribute(node.props, 'texts'), id);
if (path) markerInfo.path = path;
if (icon) markerInfo.icon = icon;
if (label) markerInfo.label = label;
if (inlining) markerInfo.inlining = inlining;
if (keywords) markerInfo.keywords = keywords;
if (texts) markerInfo.texts = texts;
//pathがない場合はファイルパスを設定
// pathがない場合はファイルパスを設定
if (markerInfo.path == null && parentId == null) {
markerInfo.path = id.match(/.*(\/(admin|settings)\/[^\/]+)\.vue$/)?.[1];
}
// SearchLabelとSearchKeywordを抽出 (AST全体を探索)
// SearchLabelとSearchTextを抽出 (AST全体を探索)
{
const extracted = extractSugarTags(node.children, id);
if (extracted.label && markerInfo.label) logger.warn(`Duplicate label found for ${markerId} at ${id}:${node.loc.start.line}`);
if (extracted.icon && markerInfo.icon) logger.warn(`Duplicate icon found for ${markerId} at ${id}:${node.loc.start.line}`);
markerInfo.label = extracted.label ?? markerInfo.label ?? '';
markerInfo.keywords = [...extracted.keywords, ...markerInfo.keywords];
markerInfo.texts = [...extracted.texts, ...markerInfo.texts];
markerInfo.icon = extracted.icon ?? markerInfo.icon ?? undefined;
}

View File

@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"watch": "vite",
"build": "vite build",
"build": "tsx build.ts",
"storybook-dev": "nodemon --verbose --watch src --ext \"mdx,ts,vue\" --ignore \"*.stories.ts\" --exec \"pnpm build-storybook-pre && pnpm exec storybook dev -p 6006 --ci\"",
"build-storybook-pre": "(tsc -p .storybook || echo done.) && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js",
"build-storybook": "pnpm build-storybook-pre && storybook build --webpack-stats-json storybook-static",
@ -17,18 +17,19 @@
},
"dependencies": {
"@analytics/google-analytics": "1.1.0",
"@discordapp/twemoji": "15.1.0",
"@discordapp/twemoji": "16.0.1",
"@github/webauthn-json": "2.1.1",
"@mcaptcha/vanilla-glue": "0.1.0-alpha-3",
"@misskey-dev/browser-image-resizer": "2024.1.0",
"@rollup/plugin-json": "6.1.0",
"@rollup/plugin-replace": "6.0.2",
"@rollup/pluginutils": "5.2.0",
"@sentry/vue": "9.39.0",
"@syuilo/aiscript": "0.19.0",
"@twemoji/parser": "15.1.1",
"@vitejs/plugin-vue": "5.2.4",
"@vue/compiler-sfc": "3.5.17",
"@sentry/vue": "10.0.0",
"@syuilo/aiscript": "1.0.0",
"@syuilo/aiscript-0-19-0": "npm:@syuilo/aiscript@^0.19.0",
"@twemoji/parser": "16.0.0",
"@vitejs/plugin-vue": "6.0.1",
"@vue/compiler-sfc": "3.5.18",
"aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.15",
"analytics": "0.8.16",
"astring": "1.9.0",
@ -37,15 +38,16 @@
"canvas-confetti": "1.9.3",
"chart.js": "4.5.0",
"chartjs-adapter-date-fns": "3.0.0",
"chartjs-chart-matrix": "2.1.1",
"chartjs-chart-matrix": "3.0.0",
"chartjs-plugin-gradient": "0.6.1",
"chartjs-plugin-zoom": "2.2.0",
"chromatic": "11.29.0",
"chromatic": "13.1.3",
"compare-versions": "6.1.1",
"cropperjs": "2.0.0",
"cropperjs": "2.0.1",
"date-fns": "4.1.0",
"estree-walker": "3.0.3",
"eventemitter3": "5.0.1",
"execa": "9.6.0",
"frontend-shared": "workspace:*",
"icons-subsetter": "workspace:*",
"idb-keyval": "6.2.2",
@ -54,36 +56,36 @@
"json5": "2.2.3",
"magic-string": "0.30.17",
"matter-js": "0.20.0",
"mfm-js": "0.24.0",
"mfm-js": "0.25.0",
"misskey-bubble-game": "workspace:*",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"photoswipe": "5.4.4",
"punycode.js": "2.3.1",
"rollup": "4.45.1",
"rollup": "4.46.2",
"sanitize-html": "2.17.0",
"sass": "1.89.2",
"shiki": "3.8.0",
"shiki": "3.9.1",
"strict-event-emitter-types": "2.0.0",
"textarea-caret": "3.1.0",
"three": "0.178.0",
"three": "0.179.1",
"throttle-debounce": "5.0.2",
"tinycolor2": "1.6.0",
"tsc-alias": "1.8.16",
"tsconfig-paths": "4.2.0",
"typescript": "5.8.3",
"typescript": "5.9.2",
"v-code-diff": "1.13.1",
"vite": "6.3.5",
"vue": "3.5.17",
"vite": "7.0.6",
"vue": "3.5.18",
"vuedraggable": "next",
"wanakana": "5.3.1"
},
"devDependencies": {
"@misskey-dev/summaly": "5.2.2",
"@storybook/addon-actions": "8.6.14",
"@misskey-dev/summaly": "5.2.3",
"@storybook/addon-actions": "9.0.8",
"@storybook/addon-essentials": "8.6.14",
"@storybook/addon-interactions": "8.6.14",
"@storybook/addon-links": "8.6.14",
"@storybook/addon-links": "9.1.0",
"@storybook/addon-mdx-gfm": "8.6.14",
"@storybook/addon-storysource": "8.6.14",
"@storybook/blocks": "8.6.14",
@ -91,38 +93,38 @@
"@storybook/core-events": "8.6.14",
"@storybook/manager-api": "8.6.14",
"@storybook/preview-api": "8.6.14",
"@storybook/react": "8.6.14",
"@storybook/react-vite": "8.6.14",
"@storybook/react": "9.1.0",
"@storybook/react-vite": "9.1.0",
"@storybook/test": "8.6.14",
"@storybook/theming": "8.6.14",
"@storybook/types": "8.6.14",
"@storybook/vue3": "8.6.14",
"@storybook/vue3-vite": "8.6.14",
"@tabler/icons-webfont": "3.34.0",
"@storybook/vue3": "9.1.0",
"@storybook/vue3-vite": "9.1.0",
"@tabler/icons-webfont": "3.34.1",
"@testing-library/vue": "8.1.0",
"@types/canvas-confetti": "1.9.0",
"@types/estree": "1.0.8",
"@types/matter-js": "0.19.8",
"@types/micromatch": "4.0.9",
"@types/node": "22.16.4",
"@types/node": "22.17.0",
"@types/punycode.js": "npm:@types/punycode@2.1.4",
"@types/sanitize-html": "2.16.0",
"@types/seedrandom": "3.0.8",
"@types/throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.37.0",
"@typescript-eslint/parser": "8.37.0",
"@typescript-eslint/eslint-plugin": "8.38.0",
"@typescript-eslint/parser": "8.38.0",
"@vitest/coverage-v8": "3.2.4",
"@vue/compiler-core": "3.5.17",
"@vue/runtime-core": "3.5.17",
"@vue/compiler-core": "3.5.18",
"@vue/runtime-core": "3.5.18",
"acorn": "8.15.0",
"cross-env": "7.0.3",
"cypress": "14.5.2",
"cross-env": "10.0.0",
"cypress": "14.5.3",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-vue": "10.3.0",
"eslint-plugin-vue": "10.4.0",
"fast-glob": "3.3.3",
"happy-dom": "17.6.3",
"happy-dom": "18.0.1",
"intersection-observer": "0.12.2",
"micromatch": "4.0.8",
"minimatch": "10.0.3",
@ -130,17 +132,18 @@
"msw-storybook-addon": "2.0.5",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react": "19.1.1",
"react-dom": "19.1.1",
"seedrandom": "3.0.5",
"start-server-and-test": "2.0.12",
"storybook": "8.6.14",
"storybook": "9.1.0",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"tsx": "4.20.3",
"vite-plugin-turbosnap": "1.0.3",
"vitest": "3.2.4",
"vitest-fetch-mock": "0.4.5",
"vue-component-type-helpers": "2.2.12",
"vue-component-type-helpers": "3.0.5",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "2.2.12"
"vue-tsc": "3.0.5"
}
}

View File

@ -5,9 +5,10 @@
import { computed, watch, version as vueVersion } from 'vue';
import { compareVersions } from 'compare-versions';
import { version, lang, updateLocale, locale, apiUrl, isSafeMode } from '@@/js/config.js';
import { version, lang, apiUrl, isSafeMode } from '@@/js/config.js';
import defaultLightTheme from '@@/themes/l-light.json5';
import defaultDarkTheme from '@@/themes/d-green-lime.json5';
import { storeBootloaderErrors } from '@@/js/store-boot-errors';
import type { App } from 'vue';
import widgets from '@/widgets/index.js';
import directives from '@/directives/index.js';
@ -79,25 +80,7 @@ export async function common(createVue: () => Promise<App<Element>>) {
//#endregion
//#region Detect language & fetch translations
const localeVersion = miLocalStorage.getItem('localeVersion');
const localeOutdated = (localeVersion == null || localeVersion !== version || locale == null);
async function fetchAndUpdateLocale({ useCache } = { useCache: true }) {
const fetchOptions: RequestInit | undefined = useCache ? undefined : { cache: 'no-store' };
const res = await window.fetch(`/assets/locales/${lang}.${version}.json`, fetchOptions);
if (res.status === 200) {
const newLocale = await res.text();
const parsedNewLocale = JSON.parse(newLocale);
miLocalStorage.setItem('locale', newLocale);
miLocalStorage.setItem('localeVersion', version);
updateLocale(parsedNewLocale);
updateI18n(parsedNewLocale);
}
}
if (localeOutdated) {
fetchAndUpdateLocale();
}
storeBootloaderErrors({ ...i18n.ts._bootErrors, reload: i18n.ts.reload });
if (import.meta.hot) {
import.meta.hot.on('locale-update', async (updatedLang: string) => {
@ -106,7 +89,8 @@ export async function common(createVue: () => Promise<App<Element>>) {
await new Promise(resolve => {
window.setTimeout(resolve, 500);
});
await fetchAndUpdateLocale({ useCache: false });
// fetch with cache: 'no-store' to ensure the latest locale is fetched
await window.fetch(`/assets/locales/${lang}.${version}.json`, { cache: 'no-store' }).then(async res => res.status === 200 && await res.text());
window.location.reload();
}
});

View File

@ -17,7 +17,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo :warn="true">{{ i18n.ts._externalResourceInstaller.checkVendorBeforeInstall }}</MkInfo>
<div v-if="isPlugin" class="_gaps_s">
<div v-if="extension.type === 'plugin'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@ -60,7 +60,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkCode :code="extension.raw"/>
</MkFolder>
</div>
<div v-else-if="isTheme" class="_gaps_s">
<div v-else-if="extension.type === 'theme'" class="_gaps_s">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.metadata }}</template>
@ -78,7 +78,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</FormSplit>
<MkKeyValue>
<template #key>{{ i18n.ts._externalResourceInstaller._meta.base }}</template>
<template #value>{{ i18n.ts[extension.meta.base ?? 'none'] }}</template>
<template #value>{{ { light: i18n.ts.light, dark: i18n.ts.dark, none: i18n.ts.none }[extension.meta.base ?? 'none'] }}</template>
</MkKeyValue>
</div>
</MkFolder>

View File

@ -265,21 +265,19 @@ const currentClip = inject<Ref<Misskey.entities.Clip> | null>('currentClip', nul
let note = deepClone(props.note);
// Transition
// https://github.com/aiscript-dev/aiscript/issues/937
//// plugin
//const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
//if (noteViewInterruptors.length > 0) {
// let result: Misskey.entities.Note | null = deepClone(note);
// for (const interruptor of noteViewInterruptors) {
// try {
// result = await interruptor.handler(result!) as Misskey.entities.Note | null;
// } catch (err) {
// console.error(err);
// }
// }
// note = result as Misskey.entities.Note;
//}
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
note = result as Misskey.entities.Note;
}
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note) ?? note;

View File

@ -287,20 +287,19 @@ const inChannel = inject('inChannel', null);
let note = deepClone(props.note);
// Transition
//// plugin
//const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
//if (noteViewInterruptors.length > 0) {
// let result: Misskey.entities.Note | null = deepClone(note);
// for (const interruptor of noteViewInterruptors) {
// try {
// result = await interruptor.handler(result!) as Misskey.entities.Note | null;
// } catch (err) {
// console.error(err);
// }
// }
// note = result as Misskey.entities.Note;
//}
// plugin
const noteViewInterruptors = getPluginHandlers('note_view_interruptor');
if (noteViewInterruptors.length > 0) {
let result: Misskey.entities.Note | null = deepClone(note);
for (const interruptor of noteViewInterruptors) {
try {
result = interruptor.handler(result!) as Misskey.entities.Note | null;
} catch (err) {
console.error(err);
}
}
note = result as Misskey.entities.Note;
}
const isRenote = Misskey.note.isPureRenote(note);
const appearNote = getAppearNote(note);

View File

@ -911,6 +911,11 @@ async function post(ev?: MouseEvent) {
if (uploader.items.value.some(x => x.uploaded == null)) {
await uploadFiles();
//
if (uploader.items.value.some(x => x.uploaded == null)) {
return;
}
}
let postData = {

View File

@ -52,9 +52,9 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ item.label }}
</template>
<template v-else>
<span style="opacity: 0.7; font-size: 90%;">{{ item.parentLabels.join(' > ') }}</span>
<span style="opacity: 0.7; font-size: 90%; word-break: break-word;">{{ item.parentLabels.join(' > ') }}</span>
<br>
<span>{{ item.label }}</span>
<span style="word-break: break-word;">{{ item.label }}</span>
</template>
</span>
</MkA>
@ -95,7 +95,7 @@ export type SuperMenuDef = {
<script lang="ts" setup>
import { useTemplateRef, ref, watch, nextTick, computed } from 'vue';
import { getScrollContainer } from '@@/js/scroll.js';
import type { SearchIndexItem } from '@/utility/settings-search-index.js';
import type { SearchIndexItem } from '@/utility/inapp-search.js';
import MkInput from '@/components/MkInput.vue';
import { i18n } from '@/i18n.js';
import { useRouter } from '@/router.js';
@ -165,12 +165,28 @@ watch(rawSearchQuery, (value) => {
});
};
for (const item of searchIndexItemById.values()) {
if (
compareStringIncludes(item.label, value) ||
item.keywords.some((x) => compareStringIncludes(x, value))
) {
// label, keywords, texts
let items = Array.from(searchIndexItemById.values());
for (const item of items) {
if (compareStringIncludes(item.label, value)) {
addSearchResult(item);
items = items.filter(i => i.id !== item.id);
}
}
for (const item of items) {
if (item.keywords.some((x) => compareStringIncludes(x, value))) {
addSearchResult(item);
items = items.filter(i => i.id !== item.id);
}
}
for (const item of items) {
if (item.texts.some((x) => compareStringIncludes(x, value))) {
addSearchResult(item);
items = items.filter(i => i.id !== item.id);
}
}
}

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div :class="$style.tabs">
<div :class="[$style.tabs, { [$style.centered]: props.centered }]">
<div :class="$style.tabsInner">
<button
v-for="t in tabs" :ref="(el) => tabRefs[t.key] = (el as HTMLElement)" v-tooltip.noDelay="t.title"
@ -30,7 +30,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div
ref="tabHighlightEl"
:class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation }]"
:class="[$style.tabHighlight, { [$style.animate]: prefer.s.animation, [$style.tabHighlightUpper]: tabHighlightUpper }]"
></div>
</div>
</template>
@ -59,6 +59,8 @@ import { prefer } from '@/preferences.js';
const props = withDefaults(defineProps<{
tabs?: Tab[];
tab?: string;
centered?: boolean;
tabHighlightUpper?: boolean;
}>(), {
tabs: () => ([] as Tab[]),
});
@ -169,6 +171,16 @@ onUnmounted(() => {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
&.centered {
text-align: center;
}
}
@container (max-width: 450px) {
.tabs {
font-size: 80%;
}
}
.tabsInner {
@ -227,5 +239,10 @@ onUnmounted(() => {
&.animate {
transition: width 0.15s ease, left 0.15s ease;
}
&.tabHighlightUpper {
top: 0;
bottom: auto;
}
}
</style>

View File

@ -52,6 +52,7 @@ export type PageHeaderProps = {
actions?: PageHeaderItem[] | null;
thin?: boolean;
hideTitle?: boolean;
canOmitTitle?: boolean;
displayMyAvatar?: boolean;
};
</script>
@ -77,7 +78,7 @@ const emit = defineEmits<{
const injectedPageMetadata = inject(DI.pageMetadata, ref(null));
const pageMetadata = computed(() => props.overridePageMetadata ?? injectedPageMetadata.value);
const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle);
const hideTitle = computed(() => inject('shouldOmitHeaderTitle', false) || props.hideTitle || (props.canOmitTitle && props.tabs.length > 0));
const thin_ = props.thin || inject('shouldHeaderThin', false);
const el = useTemplateRef('el');

View File

@ -6,14 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div ref="rootEl" :class="[$style.root, reversed ? '_pageScrollableReversed' : '_pageScrollable']">
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" v-bind="pageHeaderProps"/></template>
<template #header>
<MkPageHeader v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" v-bind="pageHeaderPropsWithoutTabs"/>
<MkPageHeader v-else v-model:tab="tab" v-bind="pageHeaderProps"/>
</template>
<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">
<slot></slot>
</MkSwiper>
<slot v-else></slot>
</div>
<template #footer><slot name="footer"></slot></template>
<template #footer>
<slot name="footer"></slot>
<div v-if="prefer.s.showPageTabBarBottom && (props.tabs?.length ?? 0) > 0" :class="$style.footerTabs">
<MkTabs v-model:tab="tab" :tabs="props.tabs" :centered="true" :tabHighlightUpper="true"/>
</div>
</template>
</MkStickyContainer>
</div>
</template>
@ -26,6 +34,7 @@ import { useScrollPositionKeeper } from '@/composables/use-scroll-position-keepe
import MkSwiper from '@/components/MkSwiper.vue';
import { useRouter } from '@/router.js';
import { prefer } from '@/preferences.js';
import MkTabs from '@/components/MkTabs.vue';
const props = withDefaults(defineProps<PageHeaderProps & {
reversed?: boolean;
@ -40,6 +49,11 @@ const pageHeaderProps = computed(() => {
return rest;
});
const pageHeaderPropsWithoutTabs = computed(() => {
const { reversed, tabs, ...rest } = props;
return rest;
});
const tab = defineModel<string>('tab');
const rootEl = useTemplateRef('rootEl');
@ -68,4 +82,11 @@ defineExpose({
.body, .swiper {
min-height: calc(100cqh - (var(--MI-stickyTop, 0px) + var(--MI-stickyBottom, 0px)));
}
.footerTabs {
background: color(from var(--MI_THEME-pageHeaderBg) srgb r g b / 0.75);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
border-top: solid 0.5px var(--MI_THEME-divider);
}
</style>

View File

@ -0,0 +1,14 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<slot></slot>
</template>
<script lang="ts" setup>
</script>
<style lang="scss" module>
</style>

View File

@ -31,7 +31,7 @@ import PageWithHeader from './global/PageWithHeader.vue';
import PageWithAnimBg from './global/PageWithAnimBg.vue';
import SearchMarker from './global/SearchMarker.vue';
import SearchLabel from './global/SearchLabel.vue';
import SearchKeyword from './global/SearchKeyword.vue';
import SearchText from './global/SearchText.vue';
import SearchIcon from './global/SearchIcon.vue';
import type { App } from 'vue';
@ -71,7 +71,7 @@ export const components = {
PageWithAnimBg: PageWithAnimBg,
SearchMarker: SearchMarker,
SearchLabel: SearchLabel,
SearchKeyword: SearchKeyword,
SearchText: SearchText,
SearchIcon: SearchIcon,
};
@ -105,7 +105,7 @@ declare module '@vue/runtime-core' {
PageWithAnimBg: typeof PageWithAnimBg;
SearchMarker: typeof SearchMarker;
SearchLabel: typeof SearchLabel;
SearchKeyword: typeof SearchKeyword;
SearchText: typeof SearchText;
SearchIcon: typeof SearchIcon;
}
}

View File

@ -5,11 +5,12 @@
import { markRaw } from 'vue';
import { I18n } from '@@/js/i18n.js';
import { locale } from '@@/js/locale.js';
import type { Locale } from '../../../locales/index.js';
import { locale } from '@@/js/config.js';
export const i18n = markRaw(new I18n<Locale>(locale, _DEV_));
// test 以外では使わないこと。インライン化されてるのでだいたい意味がない
export function updateI18n(newLocale: Locale) {
i18n.locale = newLocale;
}

View File

@ -165,6 +165,8 @@ function buildFullPath(args: {
const replaceRegex = new RegExp(`:${key}(\\?)?`, 'g');
fullPath = fullPath.replace(replaceRegex, value ? encodeURIComponent(value) : '');
}
// remove any optional parameters that are not provided
fullPath = fullPath.replace(/\/:\w+\?(?=\/|$)/g, '');
}
if (args.query) {

View File

@ -22,8 +22,7 @@ export type Keys = (
'fontSize' |
'ui' |
'ui_temp' |
'locale' |
'localeVersion' |
'bootloaderLocales' |
'theme' |
'themeId' |
'customCss' |

View File

@ -4,158 +4,161 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder>
<template #icon><i class="ti ti-shield"></i></template>
<template #label>{{ i18n.ts.botProtection }}</template>
<template v-if="botProtectionForm.savedState.provider === 'hcaptcha'" #suffix>hCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<template #footer>
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
</template>
<SearchMarker markerId="botProtection" :keywords="['bot', 'protection', 'captcha', 'hcaptcha', 'mcaptcha', 'recaptcha', 'turnstile']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-shield"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.botProtection }}</SearchLabel></template>
<template v-if="botProtectionForm.savedState.provider === 'hcaptcha'" #suffix>hCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'mcaptcha'" #suffix>mCaptcha</template>
<template v-else-if="botProtectionForm.savedState.provider === 'recaptcha'" #suffix>reCAPTCHA</template>
<template v-else-if="botProtectionForm.savedState.provider === 'turnstile'" #suffix>Turnstile</template>
<template v-else-if="botProtectionForm.savedState.provider === 'testcaptcha'" #suffix>testCaptcha</template>
<template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template>
<template #footer>
<MkFormFooter :canSaving="canSaving" :form="botProtectionForm"/>
</template>
<div class="_gaps_m">
<MkRadios v-model="botProtectionForm.state.provider">
<option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
<option value="testcaptcha">testCaptcha</option>
</MkRadios>
<div class="_gaps_m">
<MkRadios v-model="botProtectionForm.state.provider">
<option value="none">{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</option>
<option value="hcaptcha">hCaptcha</option>
<option value="mcaptcha">mCaptcha</option>
<option value="recaptcha">reCAPTCHA</option>
<option value="turnstile">Turnstile</option>
<option value="testcaptcha">testCaptcha</option>
</MkRadios>
<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="hcaptcha"
:sitekey="botProtectionForm.state.hcaptchaSiteKey"
:secretKey="botProtectionForm.state.hcaptchaSecretKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
<div>
<span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a>
<template v-if="botProtectionForm.state.provider === 'hcaptcha'">
<MkInput v-model="botProtectionForm.state.hcaptchaSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.hcaptchaSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.hcaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.hcaptchaSiteKey">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="hcaptcha"
:sitekey="botProtectionForm.state.hcaptchaSiteKey"
:secretKey="botProtectionForm.state.hcaptchaSecretKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
<div>
<span>ref: </span><a href="https://docs.hcaptcha.com/#integration-testing-test-keys" target="_blank">hCaptcha Developer Guide</a>
</div>
</div>
</div>
</MkInfo>
</template>
</MkInfo>
</template>
<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'">
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce>
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="mcaptcha"
:sitekey="botProtectionForm.state.mcaptchaSiteKey"
:secretKey="botProtectionForm.state.mcaptchaSecretKey"
:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"
/>
</FormSlot>
</template>
<template v-else-if="botProtectionForm.state.provider === 'mcaptcha'">
<MkInput v-model="botProtectionForm.state.mcaptchaSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.mcaptchaSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.mcaptchaSecretKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.mcaptchaInstanceUrl" debounce>
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.mcaptchaInstanceUrl }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.mcaptchaSiteKey && botProtectionForm.state.mcaptchaInstanceUrl">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="mcaptcha"
:sitekey="botProtectionForm.state.mcaptchaSiteKey"
:secretKey="botProtectionForm.state.mcaptchaSecretKey"
:instanceUrl="botProtectionForm.state.mcaptchaInstanceUrl"
/>
</FormSlot>
</template>
<template v-else-if="botProtectionForm.state.provider === 'recaptcha'">
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="recaptcha"
:sitekey="botProtectionForm.state.recaptchaSiteKey"
:secretKey="botProtectionForm.state.recaptchaSecretKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
<div>
<span>ref: </span>
<a
href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do"
target="_blank"
>reCAPTCHA FAQ</a>
<template v-else-if="botProtectionForm.state.provider === 'recaptcha'">
<MkInput v-model="botProtectionForm.state.recaptchaSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.recaptchaSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.recaptchaSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.recaptchaSiteKey">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="recaptcha"
:sitekey="botProtectionForm.state.recaptchaSiteKey"
:secretKey="botProtectionForm.state.recaptchaSecretKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>{{ i18n.ts._captcha.testSiteKeyMessage }}</div>
<div>
<span>ref: </span>
<a
href="https://developers.google.com/recaptcha/docs/faq?hl=ja#id-like-to-run-automated-tests-with-recaptcha.-what-should-i-do"
target="_blank"
>reCAPTCHA FAQ</a>
</div>
</div>
</div>
</MkInfo>
</template>
</MkInfo>
</template>
<template v-else-if="botProtectionForm.state.provider === 'turnstile'">
<MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="turnstile"
:sitekey="botProtectionForm.state.turnstileSiteKey"
:secretKey="botProtectionForm.state.turnstileSecretKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>
{{ i18n.ts._captcha.testSiteKeyMessage }}
<template v-else-if="botProtectionForm.state.provider === 'turnstile'">
<MkInput v-model="botProtectionForm.state.turnstileSiteKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSiteKey }}</template>
</MkInput>
<MkInput v-model="botProtectionForm.state.turnstileSecretKey" debounce>
<template #prefix><i class="ti ti-key"></i></template>
<template #label>{{ i18n.ts.turnstileSecretKey }}</template>
</MkInput>
<FormSlot v-if="botProtectionForm.state.turnstileSiteKey">
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha
v-model="captchaResult"
provider="turnstile"
:sitekey="botProtectionForm.state.turnstileSiteKey"
:secretKey="botProtectionForm.state.turnstileSecretKey"
/>
</FormSlot>
<MkInfo>
<div :class="$style.captchaInfoMsg">
<div>
{{ i18n.ts._captcha.testSiteKeyMessage }}
</div>
<div>
<span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a>
</div>
</div>
<div>
<span>ref: </span><a href="https://developers.cloudflare.com/turnstile/troubleshooting/testing/" target="_blank">Cloudflare Docs</a>
</div>
</div>
</MkInfo>
</template>
</MkInfo>
</template>
<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
<FormSlot>
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
</FormSlot>
</template>
</div>
</MkFolder>
<template v-else-if="botProtectionForm.state.provider === 'testcaptcha'">
<MkInfo warn><span v-html="i18n.ts.testCaptchaWarning"></span></MkInfo>
<FormSlot>
<template #label>{{ i18n.ts._captcha.verify }}</template>
<MkCaptcha v-model="captchaResult" provider="testcaptcha" :sitekey="null"/>
</FormSlot>
</template>
</div>
</MkFolder>
</SearchMarker>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import * as Misskey from 'misskey-js';
import type { ApiWithDialogCustomErrors } from '@/os.js';
import MkRadios from '@/components/MkRadios.vue';
import MkInput from '@/components/MkInput.vue';
import FormSlot from '@/components/form/slot.vue';
@ -167,7 +170,6 @@ import { useForm } from '@/composables/use-form.js';
import MkFormFooter from '@/components/MkFormFooter.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import type { ApiWithDialogCustomErrors } from '@/os.js';
const MkCaptcha = defineAsyncComponent(() => import('@/components/MkCaptcha.vue'));

View File

@ -6,89 +6,117 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<SearchMarker path="/admin/branding" :label="i18n.ts.branding" :keywords="['branding']" icon="ti ti-paint">
<div class="_gaps_m">
<MkInput v-model="iconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }}</template>
</MkInput>
<SearchMarker :keywords="['icon', 'image']">
<MkInput v-model="iconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.iconUrl }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="app192IconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</template>
<template #caption>
<div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div>
</template>
</MkInput>
<SearchMarker :keywords="['icon', 'image']">
<MkInput v-model="app192IconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.iconUrl }} (App/192px)</SearchLabel></template>
<template #caption>
<div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '192x192px' }) }}</strong></div>
</template>
</MkInput>
</SearchMarker>
<MkInput v-model="app512IconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</template>
<template #caption>
<div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div>
</template>
</MkInput>
<SearchMarker :keywords="['icon', 'image']">
<MkInput v-model="app512IconUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.iconUrl }} (App/512px)</SearchLabel></template>
<template #caption>
<div>{{ i18n.tsx._serverSettings.appIconDescription({ host: instance.name ?? host }) }}</div>
<div>({{ i18n.ts._serverSettings.appIconUsageExample }})</div>
<div>{{ i18n.ts._serverSettings.appIconStyleRecommendation }}</div>
<div><strong>{{ i18n.tsx._serverSettings.appIconResolutionMustBe({ resolution: '512x512px' }) }}</strong></div>
</template>
</MkInput>
</SearchMarker>
<MkInput v-model="bannerUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.bannerUrl }}</template>
</MkInput>
<SearchMarker :keywords="['banner', 'image']">
<MkInput v-model="bannerUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.bannerUrl }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="backgroundImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.backgroundImageUrl }}</template>
</MkInput>
<SearchMarker :keywords="['background', 'image']">
<MkInput v-model="backgroundImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.backgroundImageUrl }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="notFoundImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.notFoundDescription }}</template>
</MkInput>
<SearchMarker :keywords="['image']">
<MkInput v-model="notFoundImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.notFoundDescription }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="infoImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.nothing }}</template>
</MkInput>
<SearchMarker :keywords="['image']">
<MkInput v-model="infoImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.nothing }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="serverErrorImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.somethingHappened }}</template>
</MkInput>
<SearchMarker :keywords="['image']">
<MkInput v-model="serverErrorImageUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.somethingHappened }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkColorInput v-model="themeColor">
<template #label>{{ i18n.ts.themeColor }}</template>
</MkColorInput>
<SearchMarker :keywords="['theme', 'color']">
<MkColorInput v-model="themeColor">
<template #label><SearchLabel>{{ i18n.ts.themeColor }}</SearchLabel></template>
</MkColorInput>
</SearchMarker>
<MkTextarea v-model="defaultLightTheme">
<template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template>
<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
</MkTextarea>
<SearchMarker :keywords="['theme', 'default', 'light']">
<MkTextarea v-model="defaultLightTheme">
<template #label><SearchLabel>{{ i18n.ts.instanceDefaultLightTheme }}</SearchLabel></template>
<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
</MkTextarea>
</SearchMarker>
<MkTextarea v-model="defaultDarkTheme">
<template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template>
<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
</MkTextarea>
<SearchMarker :keywords="['theme', 'default', 'dark']">
<MkTextarea v-model="defaultDarkTheme">
<template #label><SearchLabel>{{ i18n.ts.instanceDefaultDarkTheme }}</SearchLabel></template>
<template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template>
</MkTextarea>
</SearchMarker>
<MkInput v-model="repositoryUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.repositoryUrl }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="repositoryUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.repositoryUrl }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="feedbackUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.feedbackUrl }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="feedbackUrl" type="url">
<template #prefix><i class="ti ti-link"></i></template>
<template #label><SearchLabel>{{ i18n.ts.feedbackUrl }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkTextarea v-model="manifestJsonOverride">
<template #label>{{ i18n.ts._serverSettings.manifestJsonOverride }}</template>
</MkTextarea>
<SearchMarker>
<MkTextarea v-model="manifestJsonOverride">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.manifestJsonOverride }}</SearchLabel></template>
</MkTextarea>
</SearchMarker>
</div>
</FormSuspense>
</SearchMarker>
</div>
<template #footer>
<div :class="$style.footer">
@ -106,7 +134,6 @@ import JSON5 from 'json5';
import { host } from '@@/js/config.js';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { instance, fetchInstance } from '@/instance.js';
@ -115,38 +142,22 @@ import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkColorInput from '@/components/MkColorInput.vue';
const iconUrl = ref<string | null>(null);
const app192IconUrl = ref<string | null>(null);
const app512IconUrl = ref<string | null>(null);
const bannerUrl = ref<string | null>(null);
const backgroundImageUrl = ref<string | null>(null);
const themeColor = ref<string | null>(null);
const defaultLightTheme = ref<string | null>(null);
const defaultDarkTheme = ref<string | null>(null);
const serverErrorImageUrl = ref<string | null>(null);
const infoImageUrl = ref<string | null>(null);
const notFoundImageUrl = ref<string | null>(null);
const repositoryUrl = ref<string | null>(null);
const feedbackUrl = ref<string | null>(null);
const manifestJsonOverride = ref<string>('{}');
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
iconUrl.value = meta.iconUrl;
app192IconUrl.value = meta.app192IconUrl;
app512IconUrl.value = meta.app512IconUrl;
bannerUrl.value = meta.bannerUrl;
backgroundImageUrl.value = meta.backgroundImageUrl;
themeColor.value = meta.themeColor;
defaultLightTheme.value = meta.defaultLightTheme;
defaultDarkTheme.value = meta.defaultDarkTheme;
serverErrorImageUrl.value = meta.serverErrorImageUrl;
infoImageUrl.value = meta.infoImageUrl;
notFoundImageUrl.value = meta.notFoundImageUrl;
repositoryUrl.value = meta.repositoryUrl;
feedbackUrl.value = meta.feedbackUrl;
manifestJsonOverride.value = meta.manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON.parse(meta.manifestJsonOverride), null, '\t');
}
const iconUrl = ref(meta.iconUrl);
const app192IconUrl = ref(meta.app192IconUrl);
const app512IconUrl = ref(meta.app512IconUrl);
const bannerUrl = ref(meta.bannerUrl);
const backgroundImageUrl = ref(meta.backgroundImageUrl);
const themeColor = ref(meta.themeColor);
const defaultLightTheme = ref(meta.defaultLightTheme);
const defaultDarkTheme = ref(meta.defaultDarkTheme);
const serverErrorImageUrl = ref(meta.serverErrorImageUrl);
const infoImageUrl = ref(meta.infoImageUrl);
const notFoundImageUrl = ref(meta.notFoundImageUrl);
const repositoryUrl = ref(meta.repositoryUrl);
const feedbackUrl = ref(meta.feedbackUrl);
const manifestJsonOverride = ref(meta.manifestJsonOverride === '' ? '{}' : JSON.stringify(JSON.parse(meta.manifestJsonOverride), null, '\t'));
function save() {
os.apiWithDialog('admin/update-meta', {

View File

@ -6,48 +6,67 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<SearchMarker path="/admin/email-settings" :label="i18n.ts.emailServer" :keywords="['email']" icon="ti ti-mail">
<div class="_gaps_m">
<MkSwitch v-model="enableEmail">
<template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template>
<template #caption>{{ i18n.ts.emailConfigInfo }}</template>
</MkSwitch>
<SearchMarker>
<MkSwitch v-model="enableEmail">
<template #label><SearchLabel>{{ i18n.ts.enableEmail }}</SearchLabel> ({{ i18n.ts.recommended }})</template>
<template #caption><SearchText>{{ i18n.ts.emailConfigInfo }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<template v-if="enableEmail">
<MkInput v-model="email" type="email">
<template #label>{{ i18n.ts.emailAddress }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="email" type="email">
<template #label><SearchLabel>{{ i18n.ts.emailAddress }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<FormSection>
<template #label>{{ i18n.ts.smtpConfig }}</template>
<SearchMarker>
<FormSection>
<template #label><SearchLabel>{{ i18n.ts.smtpConfig }}</SearchLabel></template>
<div class="_gaps_m">
<FormSplit :minWidth="280">
<MkInput v-model="smtpHost">
<template #label>{{ i18n.ts.smtpHost }}</template>
</MkInput>
<MkInput v-model="smtpPort" type="number">
<template #label>{{ i18n.ts.smtpPort }}</template>
</MkInput>
</FormSplit>
<FormSplit :minWidth="280">
<MkInput v-model="smtpUser">
<template #label>{{ i18n.ts.smtpUser }}</template>
</MkInput>
<MkInput v-model="smtpPass" type="password">
<template #label>{{ i18n.ts.smtpPass }}</template>
</MkInput>
</FormSplit>
<FormInfo>{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
<MkSwitch v-model="smtpSecure">
<template #label>{{ i18n.ts.smtpSecure }}</template>
<template #caption>{{ i18n.ts.smtpSecureInfo }}</template>
</MkSwitch>
</div>
</FormSection>
<div class="_gaps_m">
<FormSplit :minWidth="280">
<SearchMarker>
<MkInput v-model="smtpHost">
<template #label><SearchLabel>{{ i18n.ts.smtpHost }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<SearchMarker>
<MkInput v-model="smtpPort" type="number">
<template #label><SearchLabel>{{ i18n.ts.smtpPort }}</SearchLabel></template>
</MkInput>
</SearchMarker>
</FormSplit>
<FormSplit :minWidth="280">
<SearchMarker>
<MkInput v-model="smtpUser">
<template #label><SearchLabel>{{ i18n.ts.smtpUser }}</SearchLabel></template>
</MkInput>
</SearchMarker>
<SearchMarker>
<MkInput v-model="smtpPass" type="password">
<template #label><SearchLabel>{{ i18n.ts.smtpPass }}</SearchLabel></template>
</MkInput>
</SearchMarker>
</FormSplit>
<FormInfo>{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo>
<SearchMarker>
<MkSwitch v-model="smtpSecure">
<template #label><SearchLabel>{{ i18n.ts.smtpSecure }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.smtpSecureInfo }}</SearchText></template>
</MkSwitch>
</SearchMarker>
</div>
</FormSection>
</SearchMarker>
</template>
</div>
</FormSuspense>
</SearchMarker>
</div>
<template #footer>
<div :class="$style.footer">
@ -67,7 +86,6 @@ import { ref, computed } from 'vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue';
import FormInfo from '@/components/MkInfo.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import FormSection from '@/components/form/section.vue';
import * as os from '@/os.js';
@ -77,24 +95,15 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
const enableEmail = ref<boolean>(false);
const email = ref<string | null>(null);
const smtpSecure = ref<boolean>(false);
const smtpHost = ref<string>('');
const smtpPort = ref<number>(0);
const smtpUser = ref<string>('');
const smtpPass = ref<string>('');
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
enableEmail.value = meta.enableEmail;
email.value = meta.email;
smtpSecure.value = meta.smtpSecure;
smtpHost.value = meta.smtpHost;
smtpPort.value = meta.smtpPort;
smtpUser.value = meta.smtpUser;
smtpPass.value = meta.smtpPass;
}
const enableEmail = ref(meta.enableEmail);
const email = ref(meta.email);
const smtpSecure = ref(meta.smtpSecure);
const smtpHost = ref(meta.smtpHost);
const smtpPort = ref(meta.smtpPort);
const smtpUser = ref(meta.smtpUser);
const smtpPass = ref(meta.smtpPass);
async function testEmail() {
const { canceled, result: destination } = await os.inputText({

View File

@ -6,36 +6,49 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<SearchMarker path="/admin/external-services" :label="i18n.ts.externalServices" :keywords="['external', 'services', 'thirdparty']" icon="ti ti-link">
<div class="_gaps_m">
<MkFolder>
<template #label>Google Analytics<span class="_beta">{{ i18n.ts.beta }}</span></template>
<SearchMarker v-slot="slotProps">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>Google Analytics</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
<div class="_gaps_m">
<MkInput v-model="googleAnalyticsMeasurementId">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Measurement ID</template>
</MkInput>
<MkButton primary @click="save_googleAnalytics">Save</MkButton>
</div>
</MkFolder>
<div class="_gaps_m">
<SearchMarker>
<MkInput v-model="googleAnalyticsMeasurementId">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>Measurement ID</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkFolder>
<template #label>DeepL Translation</template>
<MkButton primary @click="save_googleAnalytics">Save</MkButton>
</div>
</MkFolder>
</SearchMarker>
<div class="_gaps_m">
<MkInput v-model="deeplAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>DeepL Auth Key</template>
</MkInput>
<MkSwitch v-model="deeplIsPro">
<template #label>Pro account</template>
</MkSwitch>
<MkButton primary @click="save_deepl">Save</MkButton>
</div>
</MkFolder>
<SearchMarker v-slot="slotProps">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>DeepL Translation</SearchLabel></template>
<div class="_gaps_m">
<SearchMarker>
<MkInput v-model="deeplAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>Auth Key</SearchLabel></template>
</MkInput>
</SearchMarker>
<SearchMarker>
<MkSwitch v-model="deeplIsPro">
<template #label><SearchLabel>Pro account</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<MkButton primary @click="save_deepl">Save</MkButton>
</div>
</MkFolder>
</SearchMarker>
</div>
</FormSuspense>
</SearchMarker>
</div>
</PageWithHeader>
</template>
@ -45,7 +58,6 @@ import { ref, computed } from 'vue';
import MkInput from '@/components/MkInput.vue';
import MkButton from '@/components/MkButton.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
@ -53,17 +65,11 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkFolder from '@/components/MkFolder.vue';
const deeplAuthKey = ref<string>('');
const deeplIsPro = ref<boolean>(false);
const meta = await misskeyApi('admin/meta');
const googleAnalyticsMeasurementId = ref<string>('');
async function init() {
const meta = await misskeyApi('admin/meta');
deeplAuthKey.value = meta.deeplAuthKey ?? '';
deeplIsPro.value = meta.deeplIsPro;
googleAnalyticsMeasurementId.value = meta.googleAnalyticsMeasurementId ?? '';
}
const deeplAuthKey = ref(meta.deeplAuthKey ?? '');
const deeplIsPro = ref(meta.deeplIsPro);
const googleAnalyticsMeasurementId = ref(meta.googleAnalyticsMeasurementId ?? '');
function save_deepl() {
os.apiWithDialog('admin/update-meta', {

View File

@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo v-if="noEmailServer" warn>{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
</div>
<MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
<MkSuperMenu :def="menuDef" :searchIndex="searchIndex" :grid="narrow"></MkSuperMenu>
</div>
</div>
</div>
@ -44,6 +44,9 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { lookupUser, lookupUserByEmail, lookupFile } from '@/utility/admin-lookup.js';
import { definePage, provideMetadataReceiver, provideReactiveMetadata } from '@/page.js';
import { useRouter } from '@/router.js';
import { genSearchIndexes } from '@/utility/inapp-search.js';
const searchIndex = await import('search-index:admin').then(({ searchIndexes }) => genSearchIndexes(searchIndexes));
const isEmpty = (x: string | null) => x == null || x === '';
@ -324,12 +327,6 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => INFO.value);
defineExpose({
header: {
title: i18n.ts.controlPanel,
},
});
</script>
<style lang="scss" scoped>

View File

@ -6,140 +6,162 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<SearchMarker path="/admin/moderation" :label="i18n.ts.moderation" :keywords="['moderation']" icon="ti ti-shield" :inlining="['serverRules']">
<div class="_gaps_m">
<MkSwitch :modelValue="enableRegistration" @update:modelValue="onChange_enableRegistration">
<template #label>{{ i18n.ts._serverSettings.openRegistration }}</template>
<template #caption>
<div>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.openRegistrationWarning }}</div>
</template>
</MkSwitch>
<SearchMarker :keywords="['open', 'registration']">
<MkSwitch :modelValue="enableRegistration" @update:modelValue="onChange_enableRegistration">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.openRegistration }}</SearchLabel></template>
<template #caption>
<div><SearchText>{{ i18n.ts._serverSettings.thisSettingWillAutomaticallyOffWhenModeratorsInactive }}</SearchText></div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <SearchText>{{ i18n.ts._serverSettings.openRegistrationWarning }}</SearchText></div>
</template>
</MkSwitch>
</SearchMarker>
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">
<template #label>{{ i18n.ts.emailRequiredForSignup }} ({{ i18n.ts.recommended }})</template>
</MkSwitch>
<SearchMarker :keywords="['email', 'required', 'signup']">
<MkSwitch v-model="emailRequiredForSignup" @change="onChange_emailRequiredForSignup">
<template #label><SearchLabel>{{ i18n.ts.emailRequiredForSignup }}</SearchLabel> ({{ i18n.ts.recommended }})</template>
</MkSwitch>
</SearchMarker>
<MkSelect v-model="ugcVisibilityForVisitor" @update:modelValue="onChange_ugcVisibilityForVisitor">
<template #label>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</template>
<option value="all">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all }}</option>
<option value="local">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly }} ({{ i18n.ts.recommended }})</option>
<option value="none">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none }}</option>
<template #caption>
<div>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> {{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</div>
</template>
</MkSelect>
<SearchMarker :keywords="['ugc', 'content', 'visibility', 'visitor', 'guest']">
<MkSelect v-model="ugcVisibilityForVisitor" @update:modelValue="onChange_ugcVisibilityForVisitor">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor }}</SearchLabel></template>
<option value="all">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.all }}</option>
<option value="local">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.localOnly }} ({{ i18n.ts.recommended }})</option>
<option value="none">{{ i18n.ts._serverSettings._userGeneratedContentsVisibilityForVisitor.none }}</option>
<template #caption>
<div><SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description }}</SearchText></div>
<div><i class="ti ti-alert-triangle" style="color: var(--MI_THEME-warn);"></i> <SearchText>{{ i18n.ts._serverSettings.userGeneratedContentsVisibilityForVisitor_description2 }}</SearchText></div>
</template>
</MkSelect>
</SearchMarker>
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<XServerRules/>
<MkFolder>
<template #icon><i class="ti ti-lock-star"></i></template>
<template #label>{{ i18n.ts.preservedUsernames }}</template>
<SearchMarker :keywords="['preserved', 'usernames']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-lock-star"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.preservedUsernames }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="preservedUsernames">
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_preservedUsernames">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="preservedUsernames">
<template #caption>{{ i18n.ts.preservedUsernamesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_preservedUsernames">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-message-exclamation"></i></template>
<template #label>{{ i18n.ts.sensitiveWords }}</template>
<SearchMarker :keywords="['sensitive', 'words']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-message-exclamation"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.sensitiveWords }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="sensitiveWords">
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="sensitiveWords">
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}<br>{{ i18n.ts.sensitiveWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_sensitiveWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-message-x"></i></template>
<template #label>{{ i18n.ts.prohibitedWords }}</template>
<SearchMarker :keywords="['prohibited', 'words']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-message-x"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.prohibitedWords }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="prohibitedWords">
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="prohibitedWords">
<template #caption>{{ i18n.ts.prohibitedWordsDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWords">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-user-x"></i></template>
<template #label>{{ i18n.ts.prohibitedWordsForNameOfUser }}</template>
<SearchMarker :keywords="['prohibited', 'name', 'user']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-user-x"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.prohibitedWordsForNameOfUser }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="prohibitedWordsForNameOfUser">
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="prohibitedWordsForNameOfUser">
<template #caption>{{ i18n.ts.prohibitedWordsForNameOfUserDescription }}<br>{{ i18n.ts.prohibitedWordsDescription2 }}</template>
</MkTextarea>
<MkButton primary @click="save_prohibitedWordsForNameOfUser">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.hiddenTags }}</template>
<SearchMarker :keywords="['hidden', 'tags', 'hashtags']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.hiddenTags }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="hiddenTags">
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_hiddenTags">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="hiddenTags">
<template #caption>{{ i18n.ts.hiddenTagsDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_hiddenTags">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.silencedInstances }}</template>
<SearchMarker :keywords="['silenced', 'servers', 'hosts']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.silencedInstances }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="silencedHosts">
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_silencedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="silencedHosts">
<template #caption>{{ i18n.ts.silencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_silencedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.mediaSilencedInstances }}</template>
<SearchMarker :keywords="['media', 'silenced', 'servers', 'hosts']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.mediaSilencedInstances }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="mediaSilencedHosts">
<template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_mediaSilencedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="mediaSilencedHosts">
<template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_mediaSilencedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-ban"></i></template>
<template #label>{{ i18n.ts.blockedInstances }}</template>
<SearchMarker :keywords="['blocked', 'servers', 'hosts']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-ban"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.blockedInstances }}</SearchLabel></template>
<div class="_gaps">
<MkTextarea v-model="blockedHosts">
<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_blockedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
<div class="_gaps">
<MkTextarea v-model="blockedHosts">
<template #caption>{{ i18n.ts.blockedInstancesDescription }}</template>
</MkTextarea>
<MkButton primary @click="save_blockedHosts">{{ i18n.ts.save }}</MkButton>
</div>
</MkFolder>
</SearchMarker>
</div>
</FormSuspense>
</SearchMarker>
</div>
</PageWithHeader>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import XServerRules from './server-rules.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSuspense from '@/components/form/suspense.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { fetchInstance } from '@/instance.js';
@ -150,32 +172,19 @@ import FormLink from '@/components/form/link.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false);
const ugcVisibilityForVisitor = ref<string>('all');
const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>('');
const prohibitedWordsForNameOfUser = ref<string>('');
const hiddenTags = ref<string>('');
const preservedUsernames = ref<string>('');
const blockedHosts = ref<string>('');
const silencedHosts = ref<string>('');
const mediaSilencedHosts = ref<string>('');
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
enableRegistration.value = !meta.disableRegistration;
emailRequiredForSignup.value = meta.emailRequiredForSignup;
ugcVisibilityForVisitor.value = meta.ugcVisibilityForVisitor;
sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n');
prohibitedWordsForNameOfUser.value = meta.prohibitedWordsForNameOfUser.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n');
preservedUsernames.value = meta.preservedUsernames.join('\n');
blockedHosts.value = meta.blockedHosts.join('\n');
silencedHosts.value = meta.silencedHosts?.join('\n') ?? '';
mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n');
}
const enableRegistration = ref(!meta.disableRegistration);
const emailRequiredForSignup = ref(meta.emailRequiredForSignup);
const ugcVisibilityForVisitor = ref(meta.ugcVisibilityForVisitor);
const sensitiveWords = ref(meta.sensitiveWords.join('\n'));
const prohibitedWords = ref(meta.prohibitedWords.join('\n'));
const prohibitedWordsForNameOfUser = ref(meta.prohibitedWordsForNameOfUser.join('\n'));
const hiddenTags = ref(meta.hiddenTags.join('\n'));
const preservedUsernames = ref(meta.preservedUsernames.join('\n'));
const blockedHosts = ref(meta.blockedHosts.join('\n'));
const silencedHosts = ref(meta.silencedHosts?.join('\n') ?? '');
const mediaSilencedHosts = ref(meta.mediaSilencedHosts.join('\n'));
async function onChange_enableRegistration(value: boolean) {
if (value) {

View File

@ -6,70 +6,94 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<FormSuspense :p="init">
<SearchMarker path="/admin/object-storage" :label="i18n.ts.objectStorage" :keywords="['objectStorage']" icon="ti ti-cloud">
<div class="_gaps_m">
<MkSwitch v-model="useObjectStorage">{{ i18n.ts.useObjectStorage }}</MkSwitch>
<SearchMarker>
<MkSwitch v-model="useObjectStorage"><SearchLabel>{{ i18n.ts.useObjectStorage }}</SearchLabel></MkSwitch>
</SearchMarker>
<template v-if="useObjectStorage">
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url">
<template #label>{{ i18n.ts.objectStorageBaseUrl }}</template>
<template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStorageBaseUrl" :placeholder="'https://example.com'" type="url">
<template #label><SearchLabel>{{ i18n.ts.objectStorageBaseUrl }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.objectStorageBaseUrlDesc }}</SearchText></template>
</MkInput>
</SearchMarker>
<MkInput v-model="objectStorageBucket">
<template #label>{{ i18n.ts.objectStorageBucket }}</template>
<template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStorageBucket">
<template #label><SearchLabel>{{ i18n.ts.objectStorageBucket }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.objectStorageBucketDesc }}</SearchText></template>
</MkInput>
</SearchMarker>
<MkInput v-model="objectStoragePrefix">
<template #label>{{ i18n.ts.objectStoragePrefix }}</template>
<template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStoragePrefix">
<template #label><SearchLabel>{{ i18n.ts.objectStoragePrefix }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.objectStoragePrefixDesc }}</SearchText></template>
</MkInput>
</SearchMarker>
<MkInput v-model="objectStorageEndpoint" :placeholder="'example.com'">
<template #label>{{ i18n.ts.objectStorageEndpoint }}</template>
<template #prefix>https://</template>
<template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStorageEndpoint" :placeholder="'example.com'">
<template #label><SearchLabel>{{ i18n.ts.objectStorageEndpoint }}</SearchLabel></template>
<template #prefix>https://</template>
<template #caption><SearchText>{{ i18n.ts.objectStorageEndpointDesc }}</SearchText></template>
</MkInput>
</SearchMarker>
<MkInput v-model="objectStorageRegion">
<template #label>{{ i18n.ts.objectStorageRegion }}</template>
<template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStorageRegion">
<template #label><SearchLabel>{{ i18n.ts.objectStorageRegion }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.objectStorageRegionDesc }}</SearchText></template>
</MkInput>
</SearchMarker>
<FormSplit :minWidth="280">
<MkInput v-model="objectStorageAccessKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Access key</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStorageAccessKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>Access key</SearchLabel></template>
</MkInput>
</SearchMarker>
<MkInput v-model="objectStorageSecretKey" type="password">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Secret key</template>
</MkInput>
<SearchMarker>
<MkInput v-model="objectStorageSecretKey" type="password">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>Secret key</SearchLabel></template>
</MkInput>
</SearchMarker>
</FormSplit>
<MkSwitch v-model="objectStorageUseSSL">
<template #label>{{ i18n.ts.objectStorageUseSSL }}</template>
<template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template>
</MkSwitch>
<SearchMarker>
<MkSwitch v-model="objectStorageUseSSL">
<template #label><SearchLabel>{{ i18n.ts.objectStorageUseSSL }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.objectStorageUseSSLDesc }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<MkSwitch v-model="objectStorageUseProxy">
<template #label>{{ i18n.ts.objectStorageUseProxy }}</template>
<template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template>
</MkSwitch>
<SearchMarker>
<MkSwitch v-model="objectStorageUseProxy">
<template #label><SearchLabel>{{ i18n.ts.objectStorageUseProxy }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.objectStorageUseProxyDesc }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<MkSwitch v-model="objectStorageSetPublicRead">
<template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template>
</MkSwitch>
<SearchMarker>
<MkSwitch v-model="objectStorageSetPublicRead">
<template #label><SearchLabel>{{ i18n.ts.objectStorageSetPublicRead }}</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<MkSwitch v-model="objectStorageS3ForcePathStyle">
<template #label>s3ForcePathStyle</template>
<template #caption>{{ i18n.ts.s3ForcePathStyleDesc }}</template>
</MkSwitch>
<SearchMarker>
<MkSwitch v-model="objectStorageS3ForcePathStyle">
<template #label><SearchLabel>s3ForcePathStyle</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts.s3ForcePathStyleDesc }}</SearchText></template>
</MkSwitch>
</SearchMarker>
</template>
</div>
</FormSuspense>
</SearchMarker>
</div>
<template #footer>
<div :class="$style.footer">
@ -94,36 +118,21 @@ import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
const useObjectStorage = ref<boolean>(false);
const objectStorageBaseUrl = ref<string | null>(null);
const objectStorageBucket = ref<string | null>(null);
const objectStoragePrefix = ref<string | null>(null);
const objectStorageEndpoint = ref<string | null>(null);
const objectStorageRegion = ref<string | null>(null);
const objectStoragePort = ref<number | null>(null);
const objectStorageAccessKey = ref<string | null>(null);
const objectStorageSecretKey = ref<string | null>(null);
const objectStorageUseSSL = ref<boolean>(false);
const objectStorageUseProxy = ref<boolean>(false);
const objectStorageSetPublicRead = ref<boolean>(false);
const objectStorageS3ForcePathStyle = ref<boolean>(true);
const meta = await misskeyApi('admin/meta');
async function init() {
const meta = await misskeyApi('admin/meta');
useObjectStorage.value = meta.useObjectStorage;
objectStorageBaseUrl.value = meta.objectStorageBaseUrl;
objectStorageBucket.value = meta.objectStorageBucket;
objectStoragePrefix.value = meta.objectStoragePrefix;
objectStorageEndpoint.value = meta.objectStorageEndpoint;
objectStorageRegion.value = meta.objectStorageRegion;
objectStoragePort.value = meta.objectStoragePort;
objectStorageAccessKey.value = meta.objectStorageAccessKey;
objectStorageSecretKey.value = meta.objectStorageSecretKey;
objectStorageUseSSL.value = meta.objectStorageUseSSL;
objectStorageUseProxy.value = meta.objectStorageUseProxy;
objectStorageSetPublicRead.value = meta.objectStorageSetPublicRead;
objectStorageS3ForcePathStyle.value = meta.objectStorageS3ForcePathStyle;
}
const useObjectStorage = ref(meta.useObjectStorage);
const objectStorageBaseUrl = ref(meta.objectStorageBaseUrl);
const objectStorageBucket = ref(meta.objectStorageBucket);
const objectStoragePrefix = ref(meta.objectStoragePrefix);
const objectStorageEndpoint = ref(meta.objectStorageEndpoint);
const objectStorageRegion = ref(meta.objectStorageRegion);
const objectStoragePort = ref(meta.objectStoragePort);
const objectStorageAccessKey = ref(meta.objectStorageAccessKey);
const objectStorageSecretKey = ref(meta.objectStorageSecretKey);
const objectStorageUseSSL = ref(meta.objectStorageUseSSL);
const objectStorageUseProxy = ref(meta.objectStorageUseProxy);
const objectStorageSetPublicRead = ref(meta.objectStorageSetPublicRead);
const objectStorageS3ForcePathStyle = ref(meta.objectStorageS3ForcePathStyle);
function save() {
os.apiWithDialog('admin/update-meta', {

View File

@ -6,131 +6,163 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<div class="_gaps">
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats">
<template #label>{{ i18n.ts.enableServerMachineStats }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration">
<template #label>{{ i18n.ts.enableIdenticonGeneration }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser">
<template #label>{{ i18n.ts.enableChartsForRemoteUser }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances">
<template #label>{{ i18n.ts.enableStatsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label>{{ i18n.ts.enableChartsForFederatedInstances }}</template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Fan-out Timeline Technology (FTT)</template>
<template v-if="fttForm.savedState.enableFanoutTimeline" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="fttForm.modified.value" #footer>
<MkFormFooter :form="fttForm"/>
</template>
<div class="_gaps">
<MkSwitch v-model="fttForm.state.enableFanoutTimeline">
<template #label>{{ i18n.ts.enable }}<span v-if="fttForm.modifiedStates.enableFanoutTimeline" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<div>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</div>
<div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div>
</template>
</MkSwitch>
<template v-if="fttForm.state.enableFanoutTimeline">
<MkSwitch v-model="fttForm.state.enableFanoutTimelineDbFallback">
<template #label>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}<span v-if="fttForm.modifiedStates.enableFanoutTimelineDbFallback" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</template>
<SearchMarker path="/admin/performance" :label="i18n.ts.performance" :keywords="['performance']" icon="ti ti-bolt">
<div class="_gaps">
<SearchMarker>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableServerMachineStats" @change="onChange_enableServerMachineStats">
<template #label><SearchLabel>{{ i18n.ts.enableServerMachineStats }}</SearchLabel></template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</SearchMarker>
<MkInput v-model="fttForm.state.perLocalUserUserTimelineCacheMax" type="number">
<template #label>perLocalUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perLocalUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<SearchMarker>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableIdenticonGeneration" @change="onChange_enableIdenticonGeneration">
<template #label><SearchLabel>{{ i18n.ts.enableIdenticonGeneration }}</SearchLabel></template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</SearchMarker>
<MkInput v-model="fttForm.state.perRemoteUserUserTimelineCacheMax" type="number">
<template #label>perRemoteUserUserTimelineCacheMax<span v-if="fttForm.modifiedStates.perRemoteUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<SearchMarker>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForRemoteUser" @change="onChange_enableChartsForRemoteUser">
<template #label><SearchLabel>{{ i18n.ts.enableChartsForRemoteUser }}</SearchLabel></template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</SearchMarker>
<MkInput v-model="fttForm.state.perUserHomeTimelineCacheMax" type="number">
<template #label>perUserHomeTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserHomeTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<SearchMarker>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableStatsForFederatedInstances" @change="onChange_enableStatsForFederatedInstances">
<template #label><SearchLabel>{{ i18n.ts.enableStatsForFederatedInstances }}</SearchLabel></template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</SearchMarker>
<MkInput v-model="fttForm.state.perUserListTimelineCacheMax" type="number">
<template #label>perUserListTimelineCacheMax<span v-if="fttForm.modifiedStates.perUserListTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</template>
</div>
</MkFolder>
<SearchMarker>
<div class="_panel" style="padding: 16px;">
<MkSwitch v-model="enableChartsForFederatedInstances" @change="onChange_enableChartsForFederatedInstances">
<template #label><SearchLabel>{{ i18n.ts.enableChartsForFederatedInstances }}</SearchLabel></template>
<template #caption>{{ i18n.ts.turnOffToImprovePerformance }}</template>
</MkSwitch>
</div>
</SearchMarker>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-bolt"></i></template>
<template #label>Misskey® Reactions Boost Technology (RBT)<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="rbtForm.modified.value" #footer>
<MkFormFooter :form="rbtForm"/>
</template>
<SearchMarker>
<MkFolder :defaultOpen="true">
<template #icon><SearchIcon><i class="ti ti-bolt"></i></SearchIcon></template>
<template #label><SearchLabel>Misskey® Fan-out Timeline Technology (FTT)</SearchLabel></template>
<template v-if="fttForm.savedState.enableFanoutTimeline" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="fttForm.modified.value" #footer>
<MkFormFooter :form="fttForm"/>
</template>
<div class="_gaps_m">
<MkSwitch v-model="rbtForm.state.enableReactionsBuffering">
<template #label>{{ i18n.ts.enable }}<span v-if="rbtForm.modifiedStates.enableReactionsBuffering" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</template>
</MkSwitch>
</div>
</MkFolder>
<div class="_gaps">
<SearchMarker>
<MkSwitch v-model="fttForm.state.enableFanoutTimeline">
<template #label><SearchLabel>{{ i18n.ts.enable }}</SearchLabel><span v-if="fttForm.modifiedStates.enableFanoutTimeline" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<div><SearchText>{{ i18n.ts._serverSettings.fanoutTimelineDescription }}</SearchText></div>
<div><MkLink target="_blank" url="https://misskey-hub.net/docs/for-admin/features/ftt/">{{ i18n.ts.details }}</MkLink></div>
</template>
</MkSwitch>
</SearchMarker>
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-recycle"></i></template>
<template #label>Remote Notes Cleaning ()</template>
<template v-if="remoteNotesCleaningForm.savedState.enableRemoteNotesCleaning" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="remoteNotesCleaningForm.modified.value" #footer>
<MkFormFooter :form="remoteNotesCleaningForm"/>
</template>
<template v-if="fttForm.state.enableFanoutTimeline">
<SearchMarker :keywords="['db', 'database', 'fallback']">
<MkSwitch v-model="fttForm.state.enableFanoutTimelineDbFallback">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.fanoutTimelineDbFallback }}</SearchLabel><span v-if="fttForm.modifiedStates.enableFanoutTimelineDbFallback" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.fanoutTimelineDbFallbackDescription }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<div class="_gaps_m">
<MkSwitch v-model="remoteNotesCleaningForm.state.enableRemoteNotesCleaning">
<template #label>{{ i18n.ts.enable }}<span v-if="remoteNotesCleaningForm.modifiedStates.enableRemoteNotesCleaning" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.remoteNotesCleaning_description }}</template>
</MkSwitch>
<SearchMarker>
<MkInput v-model="fttForm.state.perLocalUserUserTimelineCacheMax" type="number">
<template #label><SearchLabel>perLocalUserUserTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perLocalUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</SearchMarker>
<template v-if="remoteNotesCleaningForm.state.enableRemoteNotesCleaning">
<MkInput v-model="remoteNotesCleaningForm.state.remoteNotesCleaningExpiryDaysForEachNotes" type="number">
<template #label>{{ i18n.ts._serverSettings.remoteNotesCleaningExpiryDaysForEachNotes }} ({{ i18n.ts.inDays }})<span v-if="remoteNotesCleaningForm.modifiedStates.remoteNotesCleaningExpiryDaysForEachNotes" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #suffix>{{ i18n.ts._time.day }}</template>
</MkInput>
<SearchMarker>
<MkInput v-model="fttForm.state.perRemoteUserUserTimelineCacheMax" type="number">
<template #label><SearchLabel>perRemoteUserUserTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perRemoteUserUserTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</SearchMarker>
<MkInput v-model="remoteNotesCleaningForm.state.remoteNotesCleaningMaxProcessingDurationInMinutes" type="number">
<template #label>{{ i18n.ts._serverSettings.remoteNotesCleaningMaxProcessingDuration }} ({{ i18n.ts.inMinutes }})<span v-if="remoteNotesCleaningForm.modifiedStates.remoteNotesCleaningMaxProcessingDurationInMinutes" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #suffix>{{ i18n.ts._time.minute }}</template>
</MkInput>
</template>
</div>
</MkFolder>
</div>
<SearchMarker>
<MkInput v-model="fttForm.state.perUserHomeTimelineCacheMax" type="number">
<template #label><SearchLabel>perUserHomeTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perUserHomeTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</SearchMarker>
<SearchMarker>
<MkInput v-model="fttForm.state.perUserListTimelineCacheMax" type="number">
<template #label><SearchLabel>perUserListTimelineCacheMax</SearchLabel><span v-if="fttForm.modifiedStates.perUserListTimelineCacheMax" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</SearchMarker>
</template>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker>
<MkFolder :defaultOpen="true">
<template #icon><SearchIcon><i class="ti ti-bolt"></i></SearchIcon></template>
<template #label><SearchLabel>Misskey® Reactions Boost Technology (RBT)</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
<template v-if="rbtForm.savedState.enableReactionsBuffering" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="rbtForm.modified.value" #footer>
<MkFormFooter :form="rbtForm"/>
</template>
<div class="_gaps_m">
<SearchMarker>
<MkSwitch v-model="rbtForm.state.enableReactionsBuffering">
<template #label><SearchLabel>{{ i18n.ts.enable }}</SearchLabel><span v-if="rbtForm.modifiedStates.enableReactionsBuffering" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.reactionsBufferingDescription }}</SearchText></template>
</MkSwitch>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker>
<MkFolder :defaultOpen="true">
<template #icon><SearchIcon><i class="ti ti-recycle"></i></SearchIcon></template>
<template #label><SearchLabel>Remote Notes Cleaning ()</SearchLabel></template>
<template v-if="remoteNotesCleaningForm.savedState.enableRemoteNotesCleaning" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="remoteNotesCleaningForm.modified.value" #footer>
<MkFormFooter :form="remoteNotesCleaningForm"/>
</template>
<div class="_gaps_m">
<MkSwitch v-model="remoteNotesCleaningForm.state.enableRemoteNotesCleaning">
<template #label><SearchLabel>{{ i18n.ts.enable }}</SearchLabel><span v-if="remoteNotesCleaningForm.modifiedStates.enableRemoteNotesCleaning" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.remoteNotesCleaning_description }}</SearchText></template>
</MkSwitch>
<template v-if="remoteNotesCleaningForm.state.enableRemoteNotesCleaning">
<MkInput v-model="remoteNotesCleaningForm.state.remoteNotesCleaningExpiryDaysForEachNotes" type="number">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.remoteNotesCleaningExpiryDaysForEachNotes }}</SearchLabel> ({{ i18n.ts.inDays }})<span v-if="remoteNotesCleaningForm.modifiedStates.remoteNotesCleaningExpiryDaysForEachNotes" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #suffix>{{ i18n.ts._time.day }}</template>
</MkInput>
<MkInput v-model="remoteNotesCleaningForm.state.remoteNotesCleaningMaxProcessingDurationInMinutes" type="number">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.remoteNotesCleaningMaxProcessingDuration }}</SearchLabel> ({{ i18n.ts.inMinutes }})<span v-if="remoteNotesCleaningForm.modifiedStates.remoteNotesCleaningMaxProcessingDurationInMinutes" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #suffix>{{ i18n.ts._time.minute }}</template>
</MkInput>
</template>
</div>
</MkFolder>
</SearchMarker>
</div>
</SearchMarker>
</div>
</PageWithHeader>
</template>
@ -243,7 +275,7 @@ const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePage(() => ({
title: i18n.ts.other,
icon: 'ti ti-adjustments',
title: i18n.ts.performance,
icon: 'ti ti-bolt',
}));
</script>

View File

@ -6,18 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 800px;">
<div class="_gaps">
<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
<div style="margin: 8px 0;">
<i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--MI_THEME-success);"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--MI_THEME-error);"></i>
<i v-else class="ti ti-clock" :class="$style.icon"></i>
<span>{{ i18n.ts._relayStatus[relay.status] }}</span>
<SearchMarker path="/admin/relays" :label="i18n.ts.relays" :keywords="['relays']" icon="ti ti-planet">
<div class="_gaps">
<div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel" style="padding: 16px;">
<div>{{ relay.inbox }}</div>
<div style="margin: 8px 0;">
<i v-if="relay.status === 'accepted'" class="ti ti-check" :class="$style.icon" style="color: var(--MI_THEME-success);"></i>
<i v-else-if="relay.status === 'rejected'" class="ti ti-ban" :class="$style.icon" style="color: var(--MI_THEME-error);"></i>
<i v-else class="ti ti-clock" :class="$style.icon"></i>
<span>{{ i18n.ts._relayStatus[relay.status] }}</span>
</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div>
<MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="ti ti-trash"></i> {{ i18n.ts.remove }}</MkButton>
</div>
</div>
</SearchMarker>
</div>
</PageWithHeader>
</template>

View File

@ -6,115 +6,153 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<div class="_gaps_m">
<XBotProtection/>
<SearchMarker path="/admin/security" :label="i18n.ts.security" :keywords="['security']" icon="ti ti-lock" :inlining="['botProtection']">
<div class="_gaps_m">
<XBotProtection/>
<MkFolder>
<template #icon><i class="ti ti-eye-off"></i></template>
<template #label>{{ i18n.ts.sensitiveMediaDetection }}</template>
<template v-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
<template v-else #suffix>{{ i18n.ts.none }}</template>
<template v-if="sensitiveMediaDetectionForm.modified.value" #footer>
<MkFormFooter :form="sensitiveMediaDetectionForm"/>
</template>
<SearchMarker v-slot="slotProps" :keywords="['sensitive', 'media', 'detection']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-eye-off"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.sensitiveMediaDetection }}</SearchLabel></template>
<template v-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template>
<template v-else-if="sensitiveMediaDetectionForm.savedState.sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template>
<template v-else #suffix>{{ i18n.ts.none }}</template>
<template v-if="sensitiveMediaDetectionForm.modified.value" #footer>
<MkFormFooter :form="sensitiveMediaDetectionForm"/>
</template>
<div class="_gaps_m">
<span>{{ i18n.ts._sensitiveMediaDetection.description }}</span>
<div class="_gaps_m">
<div><SearchText>{{ i18n.ts._sensitiveMediaDetection.description }}</SearchText></div>
<MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection">
<option value="none">{{ i18n.ts.none }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.localOnly }}</option>
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
<MkRadios v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetection">
<option value="none">{{ i18n.ts.none }}</option>
<option value="all">{{ i18n.ts.all }}</option>
<option value="local">{{ i18n.ts.localOnly }}</option>
<option value="remote">{{ i18n.ts.remoteOnly }}</option>
</MkRadios>
<MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template>
</MkRange>
<SearchMarker :keywords="['sensitivity']">
<MkRange v-model="sensitiveMediaDetectionForm.state.sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :textConverter="(v) => `${v + 1}`">
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</SearchLabel></template>
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</SearchText></template>
</MkRange>
</SearchMarker>
<MkSwitch v-model="sensitiveMediaDetectionForm.state.enableSensitiveMediaDetectionForVideos">
<template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template>
</MkSwitch>
<SearchMarker :keywords="['video', 'analyze']">
<MkSwitch v-model="sensitiveMediaDetectionForm.state.enableSensitiveMediaDetectionForVideos">
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}</SearchLabel><span class="_beta">{{ i18n.ts.beta }}</span></template>
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<MkSwitch v-model="sensitiveMediaDetectionForm.state.setSensitiveFlagAutomatically">
<template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template>
<template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template>
</MkSwitch>
<SearchMarker :keywords="['flag', 'automatically']">
<MkSwitch v-model="sensitiveMediaDetectionForm.state.setSensitiveFlagAutomatically">
<template #label><SearchLabel>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }}</SearchLabel> ({{ i18n.ts.notRecommended }})</template>
<template #caption><SearchText>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<!-- 現状 false positive が多すぎて実用に耐えない
<!-- 現状 false positive が多すぎて実用に耐えない
<MkSwitch v-model="disallowUploadWhenPredictedAsPorn">
<template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template>
</MkSwitch>
-->
</div>
</MkFolder>
</div>
</MkFolder>
</SearchMarker>
<MkFolder>
<template #label>Active Email Validation</template>
<template v-if="emailValidationForm.savedState.enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="emailValidationForm.modified.value" #footer>
<MkFormFooter :form="emailValidationForm"/>
</template>
<SearchMarker v-slot="slotProps" :keywords="['email', 'validation']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>Active Email Validation</SearchLabel></template>
<template v-if="emailValidationForm.savedState.enableActiveEmailValidation" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="emailValidationForm.modified.value" #footer>
<MkFormFooter :form="emailValidationForm"/>
</template>
<div class="_gaps_m">
<span>{{ i18n.ts.activeEmailValidationDescription }}</span>
<MkSwitch v-model="emailValidationForm.state.enableActiveEmailValidation">
<template #label>Enable</template>
</MkSwitch>
<MkSwitch v-model="emailValidationForm.state.enableVerifymailApi">
<template #label>Use Verifymail.io API</template>
</MkSwitch>
<MkInput v-model="emailValidationForm.state.verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>Verifymail.io API Auth Key</template>
</MkInput>
<MkSwitch v-model="emailValidationForm.state.enableTruemailApi">
<template #label>Use TrueMail API</template>
</MkSwitch>
<MkInput v-model="emailValidationForm.state.truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Instance</template>
</MkInput>
<MkInput v-model="emailValidationForm.state.truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label>TrueMail API Auth Key</template>
</MkInput>
</div>
</MkFolder>
<div class="_gaps_m">
<div><SearchText>{{ i18n.ts.activeEmailValidationDescription }}</SearchText></div>
<MkFolder>
<template #label>Banned Email Domains</template>
<template v-if="bannedEmailDomainsForm.modified.value" #footer>
<MkFormFooter :form="bannedEmailDomainsForm"/>
</template>
<SearchMarker>
<MkSwitch v-model="emailValidationForm.state.enableActiveEmailValidation">
<template #label><SearchLabel>Enable</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<div class="_gaps_m">
<MkTextarea v-model="bannedEmailDomainsForm.state.bannedEmailDomains">
<template #label>Banned Email Domains List</template>
</MkTextarea>
</div>
</MkFolder>
<SearchMarker>
<MkSwitch v-model="emailValidationForm.state.enableVerifymailApi">
<template #label><SearchLabel>Use Verifymail.io API</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<MkFolder>
<template #label>Log IP address</template>
<template v-if="ipLoggingForm.savedState.enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="ipLoggingForm.modified.value" #footer>
<MkFormFooter :form="ipLoggingForm"/>
</template>
<SearchMarker>
<MkInput v-model="emailValidationForm.state.verifymailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>Verifymail.io API Auth Key</SearchLabel></template>
</MkInput>
</SearchMarker>
<div class="_gaps_m">
<MkSwitch v-model="ipLoggingForm.state.enableIpLogging">
<template #label>Enable</template>
</MkSwitch>
</div>
</MkFolder>
</div>
<SearchMarker>
<MkSwitch v-model="emailValidationForm.state.enableTruemailApi">
<template #label><SearchLabel>Use TrueMail API</SearchLabel></template>
</MkSwitch>
</SearchMarker>
<SearchMarker>
<MkInput v-model="emailValidationForm.state.truemailInstance">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>TrueMail API Instance</SearchLabel></template>
</MkInput>
</SearchMarker>
<SearchMarker>
<MkInput v-model="emailValidationForm.state.truemailAuthKey">
<template #prefix><i class="ti ti-key"></i></template>
<template #label><SearchLabel>TrueMail API Auth Key</SearchLabel></template>
</MkInput>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['banned', 'email', 'domains', 'blacklist']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>Banned Email Domains</SearchLabel></template>
<template v-if="bannedEmailDomainsForm.modified.value" #footer>
<MkFormFooter :form="bannedEmailDomainsForm"/>
</template>
<div class="_gaps_m">
<SearchMarker>
<MkTextarea v-model="bannedEmailDomainsForm.state.bannedEmailDomains">
<template #label><SearchLabel>Banned Email Domains List</SearchLabel></template>
</MkTextarea>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['log', 'ipAddress']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #label><SearchLabel>Log IP address</SearchLabel></template>
<template v-if="ipLoggingForm.savedState.enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<template v-if="ipLoggingForm.modified.value" #footer>
<MkFormFooter :form="ipLoggingForm"/>
</template>
<div class="_gaps_m">
<SearchMarker>
<MkSwitch v-model="ipLoggingForm.state.enableIpLogging">
<template #label><SearchLabel>Enable</SearchLabel></template>
</MkSwitch>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
</div>
</SearchMarker>
</div>
</PageWithHeader>
</template>

View File

@ -4,10 +4,14 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<SearchMarker markerId="serverRules" :keywords="['rules']">
<MkFolder>
<template #icon><SearchIcon><i class="ti ti-checkbox"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.serverRules }}</SearchLabel></template>
<div class="_gaps_m">
<div>{{ i18n.ts._serverRules.description }}</div>
<div><SearchText>{{ i18n.ts._serverRules.description }}</SearchText></div>
<Sortable
v-model="serverRules"
class="_gaps_m"
@ -33,8 +37,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</div>
</PageWithHeader>
</MkFolder>
</SearchMarker>
</template>
<script lang="ts" setup>
@ -42,9 +46,9 @@ import { defineAsyncComponent, ref, computed } from 'vue';
import * as os from '@/os.js';
import { fetchInstance, instance } from '@/instance.js';
import { i18n } from '@/i18n.js';
import { definePage } from '@/page.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkFolder from '@/components/MkFolder.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@ -60,13 +64,6 @@ const save = async () => {
const remove = (index: number): void => {
serverRules.value.splice(index, 1);
};
const headerTabs = computed(() => []);
definePage(() => ({
title: i18n.ts.serverRules,
icon: 'ti ti-checkbox',
}));
</script>
<style lang="scss" module>

View File

@ -6,292 +6,369 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;">
<div class="_gaps_m">
<MkFolder :defaultOpen="true">
<template #icon><i class="ti ti-info-circle"></i></template>
<template #label>{{ i18n.ts.info }}</template>
<template v-if="infoForm.modified.value" #footer>
<MkFormFooter :form="infoForm"/>
</template>
<div class="_gaps">
<MkInput v-model="infoForm.state.name">
<template #label>{{ i18n.ts.instanceName }}<span v-if="infoForm.modifiedStates.name" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="infoForm.state.shortName">
<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})<span v-if="infoForm.modifiedStates.shortName" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
</MkInput>
<MkTextarea v-model="infoForm.state.description">
<template #label>{{ i18n.ts.instanceDescription }}<span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkTextarea>
<FormSplit :minWidth="300">
<MkInput v-model="infoForm.state.maintainerName">
<template #label>{{ i18n.ts.maintainerName }}<span v-if="infoForm.modifiedStates.maintainerName" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
<MkInput v-model="infoForm.state.maintainerEmail" type="email">
<template #label>{{ i18n.ts.maintainerEmail }}<span v-if="infoForm.modifiedStates.maintainerEmail" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-mail"></i></template>
</MkInput>
</FormSplit>
<MkInput v-model="infoForm.state.tosUrl" type="url">
<template #label>{{ i18n.ts.tosUrl }}<span v-if="infoForm.modifiedStates.tosUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.privacyPolicyUrl" type="url">
<template #label>{{ i18n.ts.privacyPolicyUrl }}<span v-if="infoForm.modifiedStates.privacyPolicyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.inquiryUrl" type="url">
<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}<span v-if="infoForm.modifiedStates.inquiryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInput v-model="infoForm.state.repositoryUrl" type="url">
<template #label>{{ i18n.ts.repositoryUrl }}<span v-if="infoForm.modifiedStates.repositoryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
<MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn>
{{ i18n.ts.repositoryUrlOrTarballRequired }}
</MkInfo>
<MkInput v-model="infoForm.state.impressumUrl" type="url">
<template #label>{{ i18n.ts.impressumUrl }}<span v-if="infoForm.modifiedStates.impressumUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.impressumDescription }}</template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-user-star"></i></template>
<template #label>{{ i18n.ts.pinnedUsers }}</template>
<template v-if="pinnedUsersForm.modified.value" #footer>
<MkFormFooter :form="pinnedUsersForm"/>
</template>
<MkTextarea v-model="pinnedUsersForm.state.pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}<span v-if="pinnedUsersForm.modifiedStates.pinnedUsers" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
</MkTextarea>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-world-cog"></i></template>
<template #label>ServiceWorker</template>
<template v-if="serviceWorkerForm.modified.value" #footer>
<MkFormFooter :form="serviceWorkerForm"/>
</template>
<div class="_gaps">
<MkSwitch v-model="serviceWorkerForm.state.enableServiceWorker">
<template #label>{{ i18n.ts.enableServiceworker }}<span v-if="serviceWorkerForm.modifiedStates.enableServiceWorker" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
</MkSwitch>
<template v-if="serviceWorkerForm.state.enableServiceWorker">
<MkInput v-model="serviceWorkerForm.state.swPublicKey">
<template #label>Public key<span v-if="serviceWorkerForm.modifiedStates.swPublicKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
<MkInput v-model="serviceWorkerForm.state.swPrivateKey">
<template #label>Private key<span v-if="serviceWorkerForm.modifiedStates.swPrivateKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
</template>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-ad"></i></template>
<template #label>{{ i18n.ts._ad.adsSettings }}</template>
<template v-if="adForm.modified.value" #footer>
<MkFormFooter :form="adForm"/>
</template>
<div class="_gaps">
<div class="_gaps_s">
<MkInput v-model="adForm.state.notesPerOneAd" :min="0" type="number">
<template #label>{{ i18n.ts._ad.notesPerOneAd }}<span v-if="adForm.modifiedStates.notesPerOneAd" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
<MkInfo v-if="adForm.state.notesPerOneAd > 0 && adForm.state.notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-world-search"></i></template>
<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
<template v-if="urlPreviewForm.modified.value" #footer>
<MkFormFooter :form="urlPreviewForm"/>
</template>
<div class="_gaps">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewEnabled">
<template #label>{{ i18n.ts._urlPreviewSetting.enable }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewEnabled" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkSwitch>
<template v-if="urlPreviewForm.state.urlPreviewEnabled">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewAllowRedirect">
<template #label>{{ i18n.ts._urlPreviewSetting.allowRedirect }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewAllowRedirect" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.allowRedirectDescription }}</template>
</MkSwitch>
<MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength">
<template #label>{{ i18n.ts._urlPreviewSetting.requireContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
</MkSwitch>
<MkInput v-model="urlPreviewForm.state.urlPreviewMaximumContentLength" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewMaximumContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewForm.state.urlPreviewTimeout" type="number">
<template #label>{{ i18n.ts._urlPreviewSetting.timeout }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewTimeout" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
</MkInput>
<MkInput v-model="urlPreviewForm.state.urlPreviewUserAgent" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.userAgent }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewUserAgent" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
</MkInput>
<div>
<MkInput v-model="urlPreviewForm.state.urlPreviewSummaryProxyUrl" type="text">
<template #label>{{ i18n.ts._urlPreviewSetting.summaryProxy }}<span v-if="urlPreviewForm.modifiedStates.urlPreviewSummaryProxyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
</MkInput>
<div :class="$style.subCaption">
{{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
<ul style="padding-left: 20px; margin: 4px 0">
<li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
<li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
<li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
<li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
</ul>
</div>
</div>
</template>
</div>
</MkFolder>
<MkFolder>
<template #icon><i class="ti ti-planet"></i></template>
<template #label>{{ i18n.ts.federation }}</template>
<template v-if="federationForm.savedState.federation === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="federationForm.savedState.federation === 'specified'" #suffix>{{ i18n.ts.specifyHost }}</template>
<template v-else-if="federationForm.savedState.federation === 'none'" #suffix>{{ i18n.ts.none }}</template>
<template v-if="federationForm.modified.value" #footer>
<MkFormFooter :form="federationForm"/>
</template>
<div class="_gaps">
<MkRadios v-model="federationForm.state.federation">
<template #label>{{ i18n.ts.behavior }}<span v-if="federationForm.modifiedStates.federation" class="_modified">{{ i18n.ts.modified }}</span></template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="specified">{{ i18n.ts.specifyHost }}</option>
<option value="none">{{ i18n.ts.none }}</option>
</MkRadios>
<MkTextarea v-if="federationForm.state.federation === 'specified'" v-model="federationForm.state.federationHosts">
<template #label>{{ i18n.ts.federationAllowedHosts }}<span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template>
</MkTextarea>
<MkFolder>
<template #icon><i class="ti ti-list"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template>
<template #footer>
<div class="_buttons">
<MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
</div>
<SearchMarker path="/admin/settings" :label="i18n.ts.general" :keywords="['general', 'settings']" icon="ti ti-settings">
<div class="_gaps_m">
<SearchMarker v-slot="slotProps" :keywords="['information', 'meta']">
<MkFolder :defaultOpen="true">
<template #icon><SearchIcon><i class="ti ti-info-circle"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.info }}</SearchLabel></template>
<template v-if="infoForm.modified.value" #footer>
<MkFormFooter :form="infoForm"/>
</template>
<div :class="$style.metadataRoot" class="_gaps_s">
<MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo>
<div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem">
<button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button>
<div :class="$style.dragItemForm">
<FormSplit :minWidth="200">
<MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName">
</MkInput>
<MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version">
</MkInput>
</FormSplit>
</div>
<div class="_gaps">
<SearchMarker :keywords="['name']">
<MkInput v-model="infoForm.state.name">
<template #label><SearchLabel>{{ i18n.ts.instanceName }}</SearchLabel><span v-if="infoForm.modifiedStates.name" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</SearchMarker>
<SearchMarker :keywords="['shortName']">
<MkInput v-model="infoForm.state.shortName">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.shortName }}</SearchLabel> ({{ i18n.ts.optional }})<span v-if="infoForm.modifiedStates.shortName" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.shortNameDescription }}</SearchText></template>
</MkInput>
</SearchMarker>
<SearchMarker :keywords="['description']">
<MkTextarea v-model="infoForm.state.description">
<template #label><SearchLabel>{{ i18n.ts.instanceDescription }}</SearchLabel><span v-if="infoForm.modifiedStates.description" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkTextarea>
</SearchMarker>
<FormSplit :minWidth="300">
<SearchMarker :keywords="['maintainer', 'name']">
<MkInput v-model="infoForm.state.maintainerName">
<template #label><SearchLabel>{{ i18n.ts.maintainerName }}</SearchLabel><span v-if="infoForm.modifiedStates.maintainerName" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkInput>
</SearchMarker>
<SearchMarker :keywords="['maintainer', 'email', 'contact']">
<MkInput v-model="infoForm.state.maintainerEmail" type="email">
<template #label><SearchLabel>{{ i18n.ts.maintainerEmail }}</SearchLabel><span v-if="infoForm.modifiedStates.maintainerEmail" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-mail"></i></template>
</MkInput>
</SearchMarker>
</FormSplit>
<SearchMarker :keywords="['tos', 'termsOfService']">
<MkInput v-model="infoForm.state.tosUrl" type="url">
<template #label><SearchLabel>{{ i18n.ts.tosUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.tosUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</SearchMarker>
<SearchMarker :keywords="['privacyPolicy']">
<MkInput v-model="infoForm.state.privacyPolicyUrl" type="url">
<template #label><SearchLabel>{{ i18n.ts.privacyPolicyUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.privacyPolicyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</SearchMarker>
<SearchMarker :keywords="['inquiry', 'contact']">
<MkInput v-model="infoForm.state.inquiryUrl" type="url">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.inquiryUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.inquiryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</SearchText></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</SearchMarker>
<SearchMarker :keywords="['repository', 'url']">
<MkInput v-model="infoForm.state.repositoryUrl" type="url">
<template #label><SearchLabel>{{ i18n.ts.repositoryUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.repositoryUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts.repositoryUrlDescription }}</SearchText></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</SearchMarker>
<MkInfo v-if="!instance.providesTarball && !infoForm.state.repositoryUrl" warn>
{{ i18n.ts.repositoryUrlOrTarballRequired }}
</MkInfo>
<SearchMarker :keywords="['impressum', 'legalNotice']">
<MkInput v-model="infoForm.state.impressumUrl" type="url">
<template #label><SearchLabel>{{ i18n.ts.impressumUrl }}</SearchLabel><span v-if="infoForm.modifiedStates.impressumUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts.impressumDescription }}</SearchText></template>
<template #prefix><i class="ti ti-link"></i></template>
</MkInput>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['pinned', 'users']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-user-star"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.pinnedUsers }}</SearchLabel></template>
<template v-if="pinnedUsersForm.modified.value" #footer>
<MkFormFooter :form="pinnedUsersForm"/>
</template>
<MkTextarea v-model="pinnedUsersForm.state.pinnedUsers">
<template #label>{{ i18n.ts.pinnedUsers }}<span v-if="pinnedUsersForm.modifiedStates.pinnedUsers" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts.pinnedUsersDescription }}</SearchText></template>
</MkTextarea>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['serviceWorker']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-world-cog"></i></SearchIcon></template>
<template #label><SearchLabel>ServiceWorker</SearchLabel></template>
<template v-if="serviceWorkerForm.modified.value" #footer>
<MkFormFooter :form="serviceWorkerForm"/>
</template>
<div class="_gaps">
<SearchMarker>
<MkSwitch v-model="serviceWorkerForm.state.enableServiceWorker">
<template #label><SearchLabel>{{ i18n.ts.enableServiceworker }}</SearchLabel><span v-if="serviceWorkerForm.modifiedStates.enableServiceWorker" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts.serviceworkerInfo }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<template v-if="serviceWorkerForm.state.enableServiceWorker">
<SearchMarker>
<MkInput v-model="serviceWorkerForm.state.swPublicKey">
<template #label><SearchLabel>Public key</SearchLabel><span v-if="serviceWorkerForm.modifiedStates.swPublicKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
</SearchMarker>
<SearchMarker>
<MkInput v-model="serviceWorkerForm.state.swPrivateKey">
<template #label><SearchLabel>Private key</SearchLabel><span v-if="serviceWorkerForm.modifiedStates.swPrivateKey" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
</SearchMarker>
</template>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['ads']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-ad"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts._ad.adsSettings }}</SearchLabel></template>
<template v-if="adForm.modified.value" #footer>
<MkFormFooter :form="adForm"/>
</template>
<div class="_gaps">
<div class="_gaps_s">
<SearchMarker>
<MkInput v-model="adForm.state.notesPerOneAd" :min="0" type="number">
<template #label><SearchLabel>{{ i18n.ts._ad.notesPerOneAd }}</SearchLabel><span v-if="adForm.modifiedStates.notesPerOneAd" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._ad.setZeroToDisable }}</template>
</MkInput>
</SearchMarker>
<MkInfo v-if="adForm.state.notesPerOneAd > 0 && adForm.state.notesPerOneAd < 20" :warn="true">
{{ i18n.ts._ad.adsTooClose }}
</MkInfo>
</div>
</div>
</MkFolder>
</SearchMarker>
<MkSwitch v-model="federationForm.state.signToActivityPubGet">
<template #label>{{ i18n.ts._serverSettings.signToActivityPubGet }}<span v-if="federationForm.modifiedStates.signToActivityPubGet" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.signToActivityPubGet_description }}</template>
</MkSwitch>
<MkSwitch v-model="federationForm.state.proxyRemoteFiles">
<template #label>{{ i18n.ts._serverSettings.proxyRemoteFiles }}<span v-if="federationForm.modifiedStates.proxyRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._serverSettings.proxyRemoteFiles_description }}</template>
</MkSwitch>
<MkSwitch v-model="federationForm.state.allowExternalApRedirect">
<template #label>{{ i18n.ts._serverSettings.allowExternalApRedirect }}<span v-if="federationForm.modifiedStates.allowExternalApRedirect" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<div>{{ i18n.ts._serverSettings.allowExternalApRedirect_description }}</div>
<div>{{ i18n.ts.needToRestartServerToApply }}</div>
<SearchMarker v-slot="slotProps" :keywords="['url', 'preview']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-world-search"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.title }}</SearchLabel></template>
<template v-if="urlPreviewForm.modified.value" #footer>
<MkFormFooter :form="urlPreviewForm"/>
</template>
</MkSwitch>
<MkSwitch v-model="federationForm.state.cacheRemoteFiles">
<template #label>{{ i18n.ts.cacheRemoteFiles }}<span v-if="federationForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
</MkSwitch>
<div class="_gaps">
<SearchMarker>
<MkSwitch v-model="urlPreviewForm.state.urlPreviewEnabled">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.enable }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewEnabled" class="_modified">{{ i18n.ts.modified }}</span></template>
</MkSwitch>
</SearchMarker>
<template v-if="federationForm.state.cacheRemoteFiles">
<MkSwitch v-model="federationForm.state.cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}<span v-if="federationForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
</template>
</div>
</MkFolder>
<template v-if="urlPreviewForm.state.urlPreviewEnabled">
<SearchMarker :keywords="['allow', 'redirect']">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewAllowRedirect">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.allowRedirect }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewAllowRedirect" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.allowRedirectDescription }}</template>
</MkSwitch>
</SearchMarker>
<MkFolder>
<template #icon><i class="ti ti-ghost"></i></template>
<template #label>{{ i18n.ts.proxyAccount }}</template>
<template v-if="proxyAccountForm.modified.value" #footer>
<MkFormFooter :form="proxyAccountForm"/>
</template>
<SearchMarker :keywords="['contentLength']">
<MkSwitch v-model="urlPreviewForm.state.urlPreviewRequireContentLength">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.requireContentLength }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewRequireContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.requireContentLengthDescription }}</template>
</MkSwitch>
</SearchMarker>
<div class="_gaps">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<SearchMarker :keywords="['contentLength']">
<MkInput v-model="urlPreviewForm.state.urlPreviewMaximumContentLength" type="number">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.maximumContentLength }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewMaximumContentLength" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.maximumContentLengthDescription }}</template>
</MkInput>
</SearchMarker>
<MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true">
<template #label>{{ i18n.ts._profile.description }}</template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
</div>
</MkFolder>
<SearchMarker :keywords="['timeout']">
<MkInput v-model="urlPreviewForm.state.urlPreviewTimeout" type="number">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.timeout }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewTimeout" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.timeoutDescription }}</template>
</MkInput>
</SearchMarker>
<MkButton primary @click="openSetupWizard">
Open setup wizard
</MkButton>
</div>
<SearchMarker :keywords="['userAgent']">
<MkInput v-model="urlPreviewForm.state.urlPreviewUserAgent" type="text">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.userAgent }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewUserAgent" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts._urlPreviewSetting.userAgentDescription }}</template>
</MkInput>
</SearchMarker>
<div>
<SearchMarker :keywords="['proxy']">
<MkInput v-model="urlPreviewForm.state.urlPreviewSummaryProxyUrl" type="text">
<template #label><SearchLabel>{{ i18n.ts._urlPreviewSetting.summaryProxy }}</SearchLabel><span v-if="urlPreviewForm.modifiedStates.urlPreviewSummaryProxyUrl" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>[{{ i18n.ts.notUsePleaseLeaveBlank }}] {{ i18n.ts._urlPreviewSetting.summaryProxyDescription }}</template>
</MkInput>
</SearchMarker>
<div :class="$style.subCaption">
{{ i18n.ts._urlPreviewSetting.summaryProxyDescription2 }}
<ul style="padding-left: 20px; margin: 4px 0">
<li>{{ i18n.ts._urlPreviewSetting.timeout }} / key:timeout</li>
<li>{{ i18n.ts._urlPreviewSetting.maximumContentLength }} / key:contentLengthLimit</li>
<li>{{ i18n.ts._urlPreviewSetting.requireContentLength }} / key:contentLengthRequired</li>
<li>{{ i18n.ts._urlPreviewSetting.userAgent }} / key:userAgent</li>
</ul>
</div>
</div>
</template>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['federation']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-planet"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.federation }}</SearchLabel></template>
<template v-if="federationForm.savedState.federation === 'all'" #suffix>{{ i18n.ts.all }}</template>
<template v-else-if="federationForm.savedState.federation === 'specified'" #suffix>{{ i18n.ts.specifyHost }}</template>
<template v-else-if="federationForm.savedState.federation === 'none'" #suffix>{{ i18n.ts.none }}</template>
<template v-if="federationForm.modified.value" #footer>
<MkFormFooter :form="federationForm"/>
</template>
<div class="_gaps">
<SearchMarker>
<MkRadios v-model="federationForm.state.federation">
<template #label><SearchLabel>{{ i18n.ts.behavior }}</SearchLabel><span v-if="federationForm.modifiedStates.federation" class="_modified">{{ i18n.ts.modified }}</span></template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="specified">{{ i18n.ts.specifyHost }}</option>
<option value="none">{{ i18n.ts.none }}</option>
</MkRadios>
</SearchMarker>
<SearchMarker :keywords="['hosts']">
<MkTextarea v-if="federationForm.state.federation === 'specified'" v-model="federationForm.state.federationHosts">
<template #label><SearchLabel>{{ i18n.ts.federationAllowedHosts }}</SearchLabel><span v-if="federationForm.modifiedStates.federationHosts" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>{{ i18n.ts.federationAllowedHostsDescription }}</template>
</MkTextarea>
</SearchMarker>
<SearchMarker :keywords="['suspended', 'software']">
<MkFolder>
<template #icon><i class="ti ti-list"></i></template>
<template #label><SearchLabel>{{ i18n.ts._serverSettings.deliverSuspendedSoftware }}</SearchLabel></template>
<template #footer>
<div class="_buttons">
<MkButton @click="federationForm.state.deliverSuspendedSoftware.push({software: '', versionRange: ''})"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
</div>
</template>
<div :class="$style.metadataRoot" class="_gaps_s">
<MkInfo>{{ i18n.ts._serverSettings.deliverSuspendedSoftwareDescription }}</MkInfo>
<div v-for="(element, index) in federationForm.state.deliverSuspendedSoftware" :key="index" v-panel :class="$style.fieldDragItem">
<button class="_button" :class="$style.dragItemRemove" @click="federationForm.state.deliverSuspendedSoftware.splice(index, 1)"><i class="ti ti-x"></i></button>
<div :class="$style.dragItemForm">
<FormSplit :minWidth="200">
<MkInput v-model="element.software" small :placeholder="i18n.ts.softwareName">
</MkInput>
<MkInput v-model="element.versionRange" small :placeholder="i18n.ts.version">
</MkInput>
</FormSplit>
</div>
</div>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker :keywords="['sign', 'get']">
<MkSwitch v-model="federationForm.state.signToActivityPubGet">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.signToActivityPubGet }}</SearchLabel><span v-if="federationForm.modifiedStates.signToActivityPubGet" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.signToActivityPubGet_description }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['proxy', 'remote', 'files']">
<MkSwitch v-model="federationForm.state.proxyRemoteFiles">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.proxyRemoteFiles }}</SearchLabel><span v-if="federationForm.modifiedStates.proxyRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts._serverSettings.proxyRemoteFiles_description }}</SearchText></template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['allow', 'external', 'redirect']">
<MkSwitch v-model="federationForm.state.allowExternalApRedirect">
<template #label><SearchLabel>{{ i18n.ts._serverSettings.allowExternalApRedirect }}</SearchLabel><span v-if="federationForm.modifiedStates.allowExternalApRedirect" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption>
<div><SearchText>{{ i18n.ts._serverSettings.allowExternalApRedirect_description }}</SearchText></div>
<div>{{ i18n.ts.needToRestartServerToApply }}</div>
</template>
</MkSwitch>
</SearchMarker>
<SearchMarker :keywords="['cache', 'remote', 'files']">
<MkSwitch v-model="federationForm.state.cacheRemoteFiles">
<template #label><SearchLabel>{{ i18n.ts.cacheRemoteFiles }}</SearchLabel><span v-if="federationForm.modifiedStates.cacheRemoteFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts.cacheRemoteFilesDescription }}</SearchText>{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
</MkSwitch>
</SearchMarker>
<template v-if="federationForm.state.cacheRemoteFiles">
<SearchMarker :keywords="['cache', 'remote', 'sensitive', 'files']">
<MkSwitch v-model="federationForm.state.cacheRemoteSensitiveFiles">
<template #label><SearchLabel>{{ i18n.ts.cacheRemoteSensitiveFiles }}</SearchLabel><span v-if="federationForm.modifiedStates.cacheRemoteSensitiveFiles" class="_modified">{{ i18n.ts.modified }}</span></template>
<template #caption><SearchText>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</SearchText></template>
</MkSwitch>
</SearchMarker>
</template>
</div>
</MkFolder>
</SearchMarker>
<SearchMarker v-slot="slotProps" :keywords="['proxy', 'account']">
<MkFolder :defaultOpen="slotProps.isParentOfTarget">
<template #icon><SearchIcon><i class="ti ti-ghost"></i></SearchIcon></template>
<template #label><SearchLabel>{{ i18n.ts.proxyAccount }}</SearchLabel></template>
<template v-if="proxyAccountForm.modified.value" #footer>
<MkFormFooter :form="proxyAccountForm"/>
</template>
<div class="_gaps">
<MkInfo>{{ i18n.ts.proxyAccountDescription }}</MkInfo>
<SearchMarker :keywords="['description']">
<MkTextarea v-model="proxyAccountForm.state.description" :max="500" tall mfmAutocomplete :mfmPreview="true">
<template #label><SearchLabel>{{ i18n.ts._profile.description }}</SearchLabel></template>
<template #caption>{{ i18n.ts._profile.youCanIncludeHashtags }}</template>
</MkTextarea>
</SearchMarker>
</div>
</MkFolder>
</SearchMarker>
<MkButton primary @click="openSetupWizard">
Open setup wizard
</MkButton>
</div>
</SearchMarker>
</div>
</PageWithHeader>
</template>

View File

@ -6,17 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<PageWithHeader :actions="headerActions" :tabs="headerTabs">
<div class="_spacer" style="--MI_SPACER-w: 900px;">
<div class="_gaps_m">
<MkButton primary @click="onCreateWebhookClicked">
<i class="ti ti-plus"></i> {{ i18n.ts._webhookSettings.createWebhook }}
</MkButton>
<SearchMarker path="/admin/system-webhook" label="SystemWebhook" :keywords="['webhook']" icon="ti ti-webhook">
<div class="_gaps_m">
<SearchMarker>
<MkButton primary @click="onCreateWebhookClicked">
<i class="ti ti-plus"></i> <SearchLabel>{{ i18n.ts._webhookSettings.createWebhook }}</SearchLabel>
</MkButton>
</SearchMarker>
<FormSection>
<div class="_gaps">
<XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/>
</div>
</FormSection>
</div>
<FormSection>
<div class="_gaps">
<XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/>
</div>
</FormSection>
</div>
</SearchMarker>
</div>
</PageWithHeader>
</template>

View File

@ -88,7 +88,7 @@ let choices = [
]
// PlayID+ID+
let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`)
let random = Math:gen_rng(\`{THIS_ID}{USER_ID}{Date:year()}{Date:month()}{Date:day()}\`, { algorithm: 'rc4_legacy' })
//
let chosen = choices[random(0, (choices.len - 1))]
@ -127,7 +127,7 @@ var results = []
//
var cursor = 0
@do() {
@main() {
if (cursor != 0) {
results = results.slice(0, (cursor + 1))
cursor = 0
@ -175,7 +175,7 @@ var cursor = 0
onClick: forward
}, {
text: "引き直す"
onClick: do
onClick: main
}]
})
Ui:C:postFormButton({
@ -191,7 +191,7 @@ var cursor = 0
])
}
do()
main()
`;
const PRESET_QUIZ = `/// @ ${AISCRIPT_VERSION}

View File

@ -63,11 +63,11 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { computed, onDeactivated, onUnmounted, ref, watch, shallowRef, defineAsyncComponent } from 'vue';
import * as Misskey from 'misskey-js';
import { Interpreter, Parser, values } from '@syuilo/aiscript';
import { url } from '@@/js/config.js';
import type { Ref } from 'vue';
import type { AsUiComponent, AsUiRoot } from '@/aiscript/ui.js';
import type { MenuItem } from '@/types/menu.js';
import type { Interpreter } from '@syuilo/aiscript';
import MkButton from '@/components/MkButton.vue';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -180,8 +180,6 @@ async function unlike() {
watch(() => props.id, fetchFlash, { immediate: true });
const parser = new Parser();
const started = ref(false);
const aiscript = shallowRef<Interpreter | null>(null);
const root = ref<AsUiRoot>();
@ -196,6 +194,12 @@ async function run() {
if (aiscript.value) aiscript.value.abort();
if (!flash.value) return;
const isLegacy = !flash.value.script.replaceAll(' ', '').startsWith('///@1.0.0');
const { Interpreter, Parser, values } = isLegacy ? await import('@syuilo/aiscript-0-19-0') : await import('@syuilo/aiscript');
const parser = new Parser();
components.value = [];
aiscript.value = new Interpreter({

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