Compare commits

...

70 Commits

Author SHA1 Message Date
50d1500dfc 12.13.0 2020-02-18 19:50:04 +09:00
94441f93a5 New Crowdin translations (#5969)
* New translations ja-JP.yml (Kannada)

* New translations ja-JP.yml (Kannada)

* New translations ja-JP.yml (Kannada)

* New translations ja-JP.yml (Kannada)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)
2020-02-18 19:48:14 +09:00
5f712fbf3c Implement photo widget 2020-02-18 19:47:30 +09:00
1c757f10e0 Update CHANGELOG.md 2020-02-18 19:36:20 +09:00
0508d5f643 Add activity widget 2020-02-18 19:31:11 +09:00
d9986b7a2f Implement featured note injection 2020-02-18 19:05:11 +09:00
3d79e7a136 Improve paging 2020-02-18 18:19:11 +09:00
52fb1237ec Imprement promo read 2020-02-18 18:14:38 +09:00
8a7197726e Merge branch 'develop' of https://github.com/syuilo/misskey into develop 2020-02-18 17:54:13 +09:00
b7f5458684 Fix bug 2020-02-18 17:53:56 +09:00
52710f3810 管理者はモデレーターに変更できないように (#5970)
* 管理者をモデレーターに変更できないように

* Change error message
2020-02-18 17:53:52 +09:00
a54de07260 Resolve #5963 2020-02-18 08:41:32 +09:00
aa2c8d101e Fix type 2020-02-18 08:13:47 +09:00
1441fd93b9 Clean up 2020-02-18 08:05:27 +09:00
4a585e8920 Improve chart logging 2020-02-18 03:03:34 +09:00
8c4245a09d Update core.ts 2020-02-18 02:27:18 +09:00
e4af16989a Fix bug 2020-02-18 01:25:02 +09:00
5dc0944fe8 Resolve #5949 2020-02-18 01:12:35 +09:00
b4d24f4377 12.12.0 2020-02-17 07:24:16 +09:00
67be47b8db New Crowdin translations (#5961)
* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (Chinese Simplified)
2020-02-17 07:23:58 +09:00
e382982d32 Fix bug 2020-02-17 07:23:39 +09:00
b09b74b5da 🎨 2020-02-17 07:14:03 +09:00
c628bdb7a6 Fix glitch 2020-02-17 06:48:04 +09:00
2fcf6fb0fd UI tweak 2020-02-17 06:43:52 +09:00
4f3fc9ffd0 🎨 2020-02-17 06:39:41 +09:00
15839a7399 🎨 2020-02-17 06:37:39 +09:00
26b3a14a63 Clean up 2020-02-17 06:23:18 +09:00
f2f0799df1 Update app.vue 2020-02-17 05:38:00 +09:00
6c99c32100 i18n 2020-02-17 03:19:27 +09:00
93d25a2a34 ユーザー設定とクライアント設定を分離 2020-02-17 03:10:51 +09:00
88f5ec59d7 🎨 2020-02-17 02:41:03 +09:00
586d3c4db7 Better instance page 2020-02-17 02:27:14 +09:00
f45fb56e15 Improve instance info page 2020-02-17 02:21:27 +09:00
8fe153c7c1 12.11.0 2020-02-16 22:53:35 +09:00
36a8720fbb New Crowdin translations (#5948)
* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Spanish)

* New translations ja-JP.yml (English)

* New translations ja-JP.yml (French)

* New translations ja-JP.yml (Korean)

* New translations ja-JP.yml (Chinese Simplified)

* New translations ja-JP.yml (German)

* New translations ja-JP.yml (German)
2020-02-16 22:53:16 +09:00
9cbfdc94d9 Clean up 2020-02-16 22:46:51 +09:00
091923764d Implement image dialog 2020-02-16 22:46:18 +09:00
dc39caed1e Resolve #5942 2020-02-16 22:15:49 +09:00
bcd7d1f007 Update CHANGELOG.md 2020-02-16 21:11:44 +09:00
40d4dc0474 Refactor 2020-02-16 21:11:27 +09:00
02ac30c0d0 Resolve #5958 2020-02-16 21:10:52 +09:00
518bc92673 Clean up 2020-02-16 21:05:17 +09:00
a5b92e316c Refactor: Extract scroll utility functions 2020-02-16 20:58:41 +09:00
828c7b66a0 Merge branch 'develop' of https://github.com/syuilo/misskey into develop 2020-02-16 20:54:33 +09:00
93474eaa06 2回目以降の読み込みは30個までフェッチするように 2020-02-16 20:54:25 +09:00
237f366aa2 Update notification (#5956)
* Add icon for messaging

This will add icon within messaging

* Update messaging-room.message.vue

Link to missing icon

* Update notification.vue

fix renote icon in notification

https://github.com/syuilo/misskey/issues/5955
2020-02-16 20:21:05 +09:00
714bcf28d5 テーマの切り替え時に時計の色が変わるように (#5959)
* テーマの切り替え時に時計の色が変わるように

* ディレイを追加
2020-02-16 20:20:02 +09:00
420eeb4d68 Clean up 2020-02-15 23:42:52 +09:00
bc6daf4a2e Update CHANGELOG.md 2020-02-15 23:16:50 +09:00
6f7832c09b API doc 2020-02-15 23:13:59 +09:00
bef67fa275 Update trend.ts 2020-02-15 23:01:41 +09:00
05d7198667 🎨 2020-02-15 22:51:46 +09:00
df0bfc14e5 🎨 2020-02-15 22:21:35 +09:00
3f28f7451f Prefetch aicons 2020-02-15 21:56:21 +09:00
dbb9199d6f Fix widget bg (#5952) 2020-02-15 21:42:45 +09:00
72cb3b03af Update CHANGELOG.md 2020-02-15 21:39:59 +09:00
d0085f00ed Fix #5950 2020-02-15 21:39:38 +09:00
43734f027b Refactoring 2020-02-15 21:33:32 +09:00
bb903cab40 🎨 2020-02-15 18:47:50 +09:00
92f765bc47 Update sequential-entrance.vue 2020-02-15 18:42:43 +09:00
742889a035 Update sequential-entrance.vue 2020-02-15 18:39:45 +09:00
24453ebcc3 Improve banner animation performance 2020-02-15 17:44:26 +09:00
8b8ab1bf5c Update CHANGELOG.md 2020-02-15 17:33:51 +09:00
e9bc9b8675 Fix bug 2020-02-15 17:31:45 +09:00
eeaa27c7ca Improve usability 2020-02-15 09:22:16 +09:00
ccea1755fc なんか 2020-02-15 09:10:49 +09:00
c32a5d602b 🎨 2020-02-15 08:52:21 +09:00
2a04f2ca4d Improve follow-requests page 2020-02-15 08:42:21 +09:00
37c80e8ef5 Improve wallpaper feature 2020-02-15 08:29:59 +09:00
1dce62e42a 🎨 2020-02-15 07:54:20 +09:00
332 changed files with 2961 additions and 1566 deletions

View File

@ -1,6 +1,49 @@
ChangeLog ChangeLog
========= =========
12.13.0 (2020/02/18)
-------------------
### ✨Improvements
* プロモーションノート機能を実装
* インスタンス管理者が、重要なお知らせやユーザーにやってもらいたいアンケートなどをタイムラインの途中に挿入する機能
* プロモーションされる期限を設定できる
* 複数のプロモーションがある場合はランダムに選択されて表示される
* ユーザーがプロモーションを個別に非表示にすることもできる
* ハイライトインジェクション機能を実装
* タイムラインの途中におすすめのノートを表示できる機能
* 設定で有効/無効を切り替えられる
* アクティビティウィジェットを実装
* フォトウィジェットを実装
* タイムラインの一番上までスクロールできるように
* 管理者はモデレーターに変更できないように
### 🐛Fixes
* admin/show-users APIがadminかつmoderator設定されているとき使えない問題を修正
12.12.0 (2020/02/17)
-------------------
### ✨Improvements
* インスタンス情報ページを強化
* インスタンス設定ページを強化
* 設定ページをアカウント設定ページとクライアント設定ページに分離
* UIの調整
12.11.0 (2020/02/16)
-------------------
### ✨Improvements
* 投稿詳細ページで前後の投稿を見れるように
* 自分のfollowersートはRenoteできるように
* 画像ダイアログを実装
* フォロー申請ページの調整
* 壁紙設定の強化
* 画面が狭い状態でMisskeyを起動した場合でも、画面幅が広がったときにウィジェットを表示するように
* 「もっと読み込む」したときの読み込み量を増量
### 🐛Fixes
* 認証なしでグローバルTLにアクセスすると妙なエラーが返る問題を修正
* API docが見れない問題を修正
* 画面右上に当たり判定があるのを修正
12.10.0 (2020/02/15) 12.10.0 (2020/02/15)
------------------- -------------------
### ✨Improvements ### ✨Improvements

View File

@ -1,2 +1,39 @@
--- ---
_lang_: "Deutsch" _lang_: "Deutsch"
monthAndDay: "{day}/{month}"
search: "Suchen"
notifications: "Benachrichtigungen"
username: "Benutzername"
password: "Passwort"
fetchingAsApObject: "Aus Fediverse holen"
ok: "OK"
gotIt: "Verstanden!"
cancel: "Abbrechen"
enterUsername: "Benutzername eingeben"
renotedBy: "Renote von {user}"
noNotes: "Keine Notizen"
noNotifications: "Keine Benachrichtigungen"
instance: "Instanz"
settings: "Einstellungen"
profile: "Profil"
timeline: "Zeitleiste"
noAccountDescription: "Keine Selbsteinführung"
login: "Einloggen"
loggingIn: "Einloggen in bearbeitung"
logout: "Ausloggen"
signup: "Registrieren"
uploading: "Upload läuft"
save: "Speichern"
users: "Benutzer"
addUser: "Benutzer hinzufügen"
copyUsername: "Benutzernamen kopieren"
selectUser: "Benutzer wählen"
instances: "Instanz"
mutedUsers: "Stummgestellte Benutzer"
blockedUsers: "Blockierte Benutzer"
noUsers: "Keine Benutzer"
_widgets:
notifications: "Benachrichtigungen"
timeline: "Zeitleiste"
_profile:
username: "Benutzername"

View File

@ -252,9 +252,9 @@ tosUrl: "Terms of Service URL"
thisYear: "Year" thisYear: "Year"
thisMonth: "Month" thisMonth: "Month"
today: "Today" today: "Today"
dayX: "{day} days" dayX: "{day}"
monthX: "{month} months" monthX: "{month}"
yearX: "{year} years" yearX: "{year} /"
pages: "Pages" pages: "Pages"
integration: "Integration" integration: "Integration"
connectSerice: "Connect" connectSerice: "Connect"
@ -400,6 +400,18 @@ docSource: "Source of this document"
createAccount: "Create account" createAccount: "Create account"
existingAcount: "Existing accounts" existingAcount: "Existing accounts"
regenerate: "Regenerate" regenerate: "Regenerate"
fontSize: "Font size"
noFollowRequests: "You don't have any pending follow requests"
openImageInNewTab: "Open image in new tab"
dashboard: "Dashboard"
local: "Local"
remote: "Remote"
total: "Total"
weekOverWeekChanges: "Weekly"
dayOverDayChanges: "Daily"
accessibility: "Accessibility"
clinetSettings: "Client Settings"
accountSettings: "Account Settings"
_ago: _ago:
unknown: "Unknown" unknown: "Unknown"
future: "Future" future: "Future"
@ -426,7 +438,7 @@ _tutorial:
step3_1: "Finished setting up your profile?" step3_1: "Finished setting up your profile?"
step3_2: "The next step is to post a note. You can do this by pressing a pencil icon on the screen." step3_2: "The next step is to post a note. You can do this by pressing a pencil icon on the screen."
step3_3: "Fill in the modal and press the button on the right top to post." step3_3: "Fill in the modal and press the button on the right top to post."
step3_4: "Have nothing to say? Try \"I just started Misskey!\"" step3_4: "Have nothing to say? Try \"just setting up my msky\"!"
step4_1: "Finished posting your first note?" step4_1: "Finished posting your first note?"
step4_2: "Hurray! Now your first note is displayed on your timeline." step4_2: "Hurray! Now your first note is displayed on your timeline."
step5_1: "Now, let's try making your timeline more lively by following other people." step5_1: "Now, let's try making your timeline more lively by following other people."
@ -500,6 +512,7 @@ _widgets:
trends: "Trending" trends: "Trending"
clock: "Clock" clock: "Clock"
rss: "RSS reader" rss: "RSS reader"
activity: "Activity"
_cw: _cw:
hide: "Hide" hide: "Hide"
show: "Load more" show: "Load more"

View File

@ -400,6 +400,18 @@ docSource: "Fuente de este documento"
createAccount: "Crear cuenta" createAccount: "Crear cuenta"
existingAcount: "Cuentas existentes" existingAcount: "Cuentas existentes"
regenerate: "Regenerar" regenerate: "Regenerar"
fontSize: "Tamaño de la letra"
noFollowRequests: "No hay solicitudes de seguimiento"
openImageInNewTab: "Abrir imagen en nueva pestaña"
dashboard: "Panel de control"
local: "Local"
remote: "Remoto"
total: "Total"
weekOverWeekChanges: "Dif semanal"
dayOverDayChanges: "Dif diaria"
accessibility: "Accesibilidad"
clinetSettings: "Ajustes del cliente"
accountSettings: "Ajustes de cuenta"
_ago: _ago:
unknown: "Desconocido" unknown: "Desconocido"
future: "Futuro" future: "Futuro"
@ -500,6 +512,7 @@ _widgets:
trends: "Tendencias" trends: "Tendencias"
clock: "Reloj" clock: "Reloj"
rss: "Lector RSS" rss: "Lector RSS"
activity: "Actividad"
_cw: _cw:
hide: "Ocultar" hide: "Ocultar"
show: "Ver más" show: "Ver más"

View File

@ -400,6 +400,18 @@ docSource: "Source de ce document"
createAccount: "Créer compte" createAccount: "Créer compte"
existingAcount: "Comptes existants" existingAcount: "Comptes existants"
regenerate: "Régénérer" regenerate: "Régénérer"
fontSize: "Taille de la police"
noFollowRequests: "Vous n'avez aucune demandes d'abonnement en attente"
openImageInNewTab: "Ouvrir l'image dans un nouvel onglet"
dashboard: "Tableau de bord"
local: "Local"
remote: "Distant"
total: "Total"
weekOverWeekChanges: "Diff hebdo"
dayOverDayChanges: "Diff quotidien"
accessibility: "Accessibilité"
clinetSettings: "Paramètres du client"
accountSettings: "Paramètres du compte"
_ago: _ago:
unknown: "Inconnu" unknown: "Inconnu"
future: "Futur" future: "Futur"
@ -480,6 +492,7 @@ _widgets:
trends: "Tendances" trends: "Tendances"
clock: "Horloge" clock: "Horloge"
rss: "Lecteur de flux RSS" rss: "Lecteur de flux RSS"
activity: "Activités"
_cw: _cw:
hide: "Masquer" hide: "Masquer"
show: "Voir plus" show: "Voir plus"

View File

@ -401,6 +401,22 @@ createAccount: "アカウントを作成"
existingAcount: "既存のアカウント" existingAcount: "既存のアカウント"
regenerate: "再生成" regenerate: "再生成"
fontSize: "フォントサイズ" fontSize: "フォントサイズ"
noFollowRequests: "フォロー申請はありません"
openImageInNewTab: "画像を新しいタブで開く"
dashboard: "ダッシュボード"
local: "ローカル"
remote: "リモート"
total: "合計"
weekOverWeekChanges: "前週比"
dayOverDayChanges: "前日比"
accessibility: "アクセシビリティ"
clinetSettings: "クライアント設定"
accountSettings: "アカウント設定"
promotion: "プロモーション"
promote: "プロモート"
numberOfDays: "日数"
hideThisNote: "このノートを非表示"
showFeaturedNotesInTimeline: "タイムラインにおすすめのノートを表示する"
_ago: _ago:
unknown: "謎" unknown: "謎"
@ -510,6 +526,8 @@ _widgets:
trends: "トレンド" trends: "トレンド"
clock: "時計" clock: "時計"
rss: "RSSリーダー" rss: "RSSリーダー"
activity: "アクティビティ"
photos: "フォト"
_cw: _cw:
hide: "隠す" hide: "隠す"

View File

@ -1,2 +1,31 @@
--- ---
_lang_: "ಕನ್ನಡ" _lang_: "ಕನ್ನಡ"
introMisskey: "ಸ್ವಾಗತ! Misskey ಓಪನ್ ಸೋರ್ಸ್ ಒಕ್ಕೂಟ ಮೈಕ್ರೋಬ್ಲಾಗಿಂಗ್ ಸೇವೆಯಾಗಿದೆ.\n ಏನಾಗುತ್ತಿದೆ ಎಂಬುದನ್ನು ಹಂಚಿಕೊಳ್ಳಲು ಅಥವಾ ನಿಮ್ಮ ಬಗ್ಗೆ ಎಲ್ಲರಿಗೂ ಹೇಳಲು \"ಟಿಪ್ಪಣಿ\"ಗಳನ್ನು ರಚಿಸಿ📡\n \"ಸ್ಪಂದನೆ\" ಕ್ರಿಯೆಯೊಂದಿಗೆ, ನೀವು ಎಲ್ಲರ ಟಿಪ್ಪಣಿಗಳಿಗೆ ತ್ವರಿತವಾಗಿ ಸ್ಪಂದನೆಗಳನ್ನು ಕೂಡ ಸೇರಿಸಬಹುದು.👍\n ಹೊಸ ಜಗತ್ತನ್ನು ಅನ್ವೇಷಿಸಿ🚀"
monthAndDay: "{month}ನೇ ತಿಂಗಳ {day}ನೇ ದಿನ"
search: "ಹುಡುಕು"
notifications: "ಅಧಿಸೂಚನೆಗಳು"
username: "ಬಳಕೆಹೆಸರು"
password: "ಗುಪ್ತಪದ"
fetchingAsApObject: "ಒಕ್ಕೂಟದಿಂದ ಪಡೆಯಲಾಗುತ್ತಿದೆ..."
ok: "ಸರಿ"
gotIt: "ಅರ್ಥವಾಯಿತು!"
cancel: "ರದ್ದು"
enterUsername: "ಬಳಕೆಹೆಸರನ್ನು ಭರ್ತಿ ಮಾಡಿ"
renotedBy: "{user} ಪುನರಾವರ್ತಿಸಿದರು"
noNotes: "ಟಿಪ್ಪಣಿಗಳಿಲ್ಲ"
noNotifications: "ಅಧಿಸೂಚನೆಗಳಿಲ್ಲ"
instance: "ನಿದರ್ಶನ"
settings: "ಸಿದ್ಧತೆಗಳು"
profile: "ಪ್ರೊಫೈಲು"
timeline: "ಸಮಯಸಾಲು"
noAccountDescription: "ಇವರು ಸ್ವಯಂ ಪರಿಚಯ ರಚಿಸಿಲ್ಲ"
login: "ಪ್ರವೇಶ"
loggingIn: "ಪ್ರವೇಶಿಸುತ್ತಾ..."
logout: "ಆಚೆಗೆ"
signup: "ನೋಂದಣಿ"
instances: "ನಿದರ್ಶನ"
_widgets:
notifications: "ಅಧಿಸೂಚನೆಗಳು"
timeline: "ಸಮಯಸಾಲು"
_profile:
username: "ಬಳಕೆಹೆಸರು"

View File

@ -106,8 +106,8 @@ customEmojis: "커스텀 이모지"
emojiName: "이모지 이름" emojiName: "이모지 이름"
emojiUrl: "이모지 URL" emojiUrl: "이모지 URL"
addEmoji: "이모지 추가" addEmoji: "이모지 추가"
cacheRemoteFiles: "원격 파일을 캐시" cacheRemoteFiles: "리모트 파일을 캐시"
cacheRemoteFilesDescription: "이 설정을 해지하면 원격 파일을 캐시하지 않고 해당 파일을 직접 링크하게 됩니다. 그에 따라 서버의 저장 공간을 절약할 수 있지만, 썸네일이 생성되지 않기 때문에 통신량이 증가합니다." cacheRemoteFilesDescription: "이 설정을 해지하면 리모트 파일을 캐시하지 않고 해당 파일을 직접 링크하게 됩니다. 그에 따라 서버의 저장 공간을 절약할 수 있지만, 썸네일이 생성되지 않기 때문에 통신량이 증가합니다."
flagAsBot: "나는 봇입니다" flagAsBot: "나는 봇입니다"
flagAsCat: "나는 고양이다냥" flagAsCat: "나는 고양이다냥"
autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락"
@ -154,7 +154,7 @@ clearQueue: "대기열 비우기"
clearQueueConfirmTitle: "대기열을 비우시겠습니까?" clearQueueConfirmTitle: "대기열을 비우시겠습니까?"
clearQueueConfirmText: "대기열에 남아 있는 노트는 더이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다." clearQueueConfirmText: "대기열에 남아 있는 노트는 더이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다."
clearCachedFiles: "캐시 비우기" clearCachedFiles: "캐시 비우기"
clearCachedFilesConfirm: "캐시된 원격 파일을 모두 삭제하시겠습니까?" clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제하시겠습니까?"
blockedInstances: "차단된 인스턴스" blockedInstances: "차단된 인스턴스"
blockedInstancesDescription: "차단하려는 인스턴스의 호스트 이름을 줄바꿈으로 구분하여 설정합니다. 차단된 인스턴스는 이 인스턴스와 통신할 수 없게 됩니다." blockedInstancesDescription: "차단하려는 인스턴스의 호스트 이름을 줄바꿈으로 구분하여 설정합니다. 차단된 인스턴스는 이 인스턴스와 통신할 수 없게 됩니다."
muteAndBlock: "뮤트 및 차단" muteAndBlock: "뮤트 및 차단"
@ -253,7 +253,7 @@ thisYear: "올해"
thisMonth: "이번 달" thisMonth: "이번 달"
today: "오늘" today: "오늘"
dayX: "{day}일" dayX: "{day}일"
monthX: "{month}월" monthX: "{month}월"
yearX: "{year}년" yearX: "{year}년"
pages: "페이지" pages: "페이지"
integration: "연동" integration: "연동"
@ -265,8 +265,8 @@ disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리
registration: "등록" registration: "등록"
enableRegistration: "신규 회원가입을 활성화" enableRegistration: "신규 회원가입을 활성화"
invite: "초대" invite: "초대"
proxyRemoteFiles: "원격 파일 프록시" proxyRemoteFiles: "리모트 파일 프록시"
proxyRemoteFilesDescription: "이 설정을 활성화할 경우, 저장되지 않았거나 저장용량 초과로 삭제된 원격 파일을 로컬에서 프록시하여 썸네일을 생성하게 됩니다. 서버의 스토리지에는 영향을 주지 않습니다." proxyRemoteFilesDescription: "이 설정을 활성화할 경우, 저장되지 않았거나 저장용량 초과로 삭제된 리모트 파일을 로컬에서 프록시하여 썸네일을 생성하게 됩니다. 서버의 스토리지에는 영향을 주지 않습니다."
driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량"
driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량" driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량"
inMb: "메가바이트 단위" inMb: "메가바이트 단위"
@ -400,6 +400,18 @@ docSource: "이 문서의 소스"
createAccount: "계정 만들기" createAccount: "계정 만들기"
existingAcount: "기존 계정" existingAcount: "기존 계정"
regenerate: "다시 생성" regenerate: "다시 생성"
fontSize: "글자 크기"
noFollowRequests: "처리되지 않은 팔로우 요청이 없습니다"
openImageInNewTab: "새 탭에서 이미지 열기"
dashboard: "대시보드"
local: "로컬"
remote: "리모트"
total: "합계"
weekOverWeekChanges: "지난주보다"
dayOverDayChanges: "어제보다"
accessibility: "접근성"
clinetSettings: "클라이언트 설정"
accountSettings: "계정 설정"
_ago: _ago:
unknown: "알 수 없음" unknown: "알 수 없음"
future: "미래" future: "미래"
@ -500,6 +512,7 @@ _widgets:
trends: "트렌드" trends: "트렌드"
clock: "시계" clock: "시계"
rss: "RSS 리더" rss: "RSS 리더"
activity: "활동"
_cw: _cw:
hide: "숨기기" hide: "숨기기"
show: "더 보기" show: "더 보기"

View File

@ -121,18 +121,23 @@ searchWith: "搜索:{q}"
youHaveNoLists: "列表为空" youHaveNoLists: "列表为空"
followConfirm: "你确定要关注{name}吗?" followConfirm: "你确定要关注{name}吗?"
proxyAccount: "代理账户" proxyAccount: "代理账户"
proxyAccountDescription: "代理帐户是在某些情况下充当用户的远程关注者的帐户。 例如,当一个用户列出一个远程用户时,如果没有人跟随该列出的用户,则该活动将不会传递到该实例,因此将代之以代理帐户。"
host: "主机名" host: "主机名"
selectUser: "选择用户" selectUser: "选择用户"
recipient: "收件人" recipient: "收件人"
annotation: "注解" annotation: "注解"
federation: "联合" federation: "联合"
instances: "实例" instances: "实例"
registeredAt: "初次观察"
latestRequestSentAt: "上次发送的请求" latestRequestSentAt: "上次发送的请求"
latestRequestReceivedAt: "上次收到的请求" latestRequestReceivedAt: "上次收到的请求"
latestStatus: "最后状态"
storageUsage: "已用存储" storageUsage: "已用存储"
charts: "图表" charts: "图表"
perHour: "每小时" perHour: "每小时"
perDay: "每天" perDay: "每天"
stopActivityDelivery: "停止发送活动"
blockThisInstance: "阻止此实例"
operations: "操作" operations: "操作"
software: "软件" software: "软件"
version: "版本" version: "版本"
@ -147,6 +152,7 @@ instanceInfo: "实例情报"
statistics: "统计" statistics: "统计"
clearQueue: "清除队列" clearQueue: "清除队列"
clearQueueConfirmTitle: "确定清除队列?" clearQueueConfirmTitle: "确定清除队列?"
clearQueueConfirmText: "未送达的帖子将不会送达。 通常,您不需要这样做。"
clearCachedFiles: "清除缓存" clearCachedFiles: "清除缓存"
clearCachedFilesConfirm: "确定要清除缓存文件?" clearCachedFilesConfirm: "确定要清除缓存文件?"
blockedInstances: "被阻拦的实例" blockedInstances: "被阻拦的实例"
@ -273,6 +279,7 @@ recaptcha: "reCAPTCHA"
enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)" enableRecaptcha: "启用 reCAPTCHA\n(请注意, 此功能在中国大陆不可用. 如果启用, 可能导致无法正常使用登录或注册等功能)"
recaptchaSiteKey: "网站密钥" recaptchaSiteKey: "网站密钥"
recaptchaSecretKey: "reCAPTCHA 密钥" recaptchaSecretKey: "reCAPTCHA 密钥"
antennas: "天线"
name: "名称" name: "名称"
antennaKeywordsDescription: "使用空格分隔会产生AND规范并且使用换行符分隔会产生OR规范" antennaKeywordsDescription: "使用空格分隔会产生AND规范并且使用换行符分隔会产生OR规范"
serviceworker: "ServiceWorker" serviceworker: "ServiceWorker"
@ -297,6 +304,7 @@ aboutMisskey: "关于 Misskey"
aboutMisskeyText: "Misskey是由syuilo于2014年开发的开放源代码软件。" aboutMisskeyText: "Misskey是由syuilo于2014年开发的开放源代码软件。"
misskeyMembers: "现在由以下成员进行开发和维护:" misskeyMembers: "现在由以下成员进行开发和维护:"
misskeySource: "源代码在这里公开:" misskeySource: "源代码在这里公开:"
misskeyTranslation: "与我们一同进行Misskey的翻译工作"
misskeyDonate: "可以向 Misskey 进行捐款以支持开发:" misskeyDonate: "可以向 Misskey 进行捐款以支持开发:"
morePatrons: "还有很多其他的人也在支持我们,非常感谢🥰" morePatrons: "还有很多其他的人也在支持我们,非常感谢🥰"
patrons: "支持者" patrons: "支持者"
@ -343,6 +351,50 @@ retype: "重新输入"
noteOf: "{user}的帖子" noteOf: "{user}的帖子"
inviteToGroup: "群组邀请" inviteToGroup: "群组邀请"
maxNoteTextLength: "帖子的字数限制" maxNoteTextLength: "帖子的字数限制"
quoteAttached: "已引用"
quoteQuestion: "是否将其作为引用附上?"
newMessageExists: "新信息"
onlyOneFileCanBeAttached: "只能添加一个附件"
signinRequired: "请先登录"
invitationCode: "邀请码"
checking: "正在确认"
available: "可用"
unavailable: "不可用"
usernameInvalidFormat: "可使用大小写英文字母、数字和下划线。"
tooShort: "过短"
tooLong: "过长"
weakPassword: "密码强度:弱"
normalPassword: "密码强度:中等"
strongPassword: "密码强度:强"
passwordMatched: "密码一致"
passwordNotMatched: "密码不一致"
or: "或者"
uiLanguage: "显示语言"
groupInvited: "群组招待"
aboutX: "关于 {x}"
useOsNativeEmojis: "使用OS原生Emoji"
noGroups: "没有组"
joinOrCreateGroup: "加入或者创建群组"
noHistory: "没有历史记录"
disableAnimatedMfm: "禁用MFM动画"
doing: "正在进行"
category: "类别"
tags: "标签"
createAccount: "注册账户"
existingAcount: "现有的帐户"
regenerate: "重新生成"
fontSize: "字体大小"
noFollowRequests: "没有关注申请"
openImageInNewTab: "在新标签页中打开图片"
dashboard: "Dashboard"
local: "本地"
remote: "远程"
total: "总计"
weekOverWeekChanges: "与前一周相比"
dayOverDayChanges: "与前一日相比"
accessibility: "辅助功能"
clinetSettings: "客户端设置"
accountSettings: "账户设置"
_ago: _ago:
unknown: "未知" unknown: "未知"
future: "未来" future: "未来"
@ -362,6 +414,7 @@ _time:
_tutorial: _tutorial:
title: "Misskey的使用方法" title: "Misskey的使用方法"
step1_1: "欢迎!" step1_1: "欢迎!"
step7_3: "接下来享受Misskey带来的乐趣吧🚀"
_2fa: _2fa:
alreadyRegistered: "此设备已被注册" alreadyRegistered: "此设备已被注册"
registerDevice: "注册设备" registerDevice: "注册设备"
@ -392,6 +445,8 @@ _permissions:
"write:user-groups": "操作用户组" "write:user-groups": "操作用户组"
_auth: _auth:
permissionAsk: "这个应用程序需要以下权限" permissionAsk: "这个应用程序需要以下权限"
_antennaSources:
all: "所有帖子"
_weekday: _weekday:
sunday: "星期日" sunday: "星期日"
monday: "星期一" monday: "星期一"
@ -408,6 +463,7 @@ _widgets:
trends: "趋势" trends: "趋势"
clock: "时钟" clock: "时钟"
rss: "RSS阅读器" rss: "RSS阅读器"
activity: "活动"
_cw: _cw:
hide: "隐藏" hide: "隐藏"
show: "查看更多" show: "查看更多"
@ -439,13 +495,27 @@ _poll:
_visibility: _visibility:
public: "公开" public: "公开"
home: "首页" home: "首页"
homeDescription: "仅发送至首页的时间线"
followers: "关注者" followers: "关注者"
followersDescription: "仅发送至关注者"
specified: "指定用户" specified: "指定用户"
specifiedDescription: "仅发送至指定用户"
localOnly: "仅限本地" localOnly: "仅限本地"
_postForm:
replyPlaceholder: "回复这个帖子..."
quotePlaceholder: "引用这个帖子..."
_placeholders:
a: "现在如何?"
b: "发生了什么?"
c: "你有什么想法?"
d: "你想要发布些什么吗?"
e: "请写下来吧"
f: "等待您的发布..."
_profile: _profile:
name: "名称" name: "名称"
username: "用户名" username: "用户名"
description: "个人简介" description: "个人简介"
youCanIncludeHashtags: "您可以包含一个哈希标签。"
metadata: "额外信息" metadata: "额外信息"
metadataLabel: "标签" metadataLabel: "标签"
metadataContent: "内容" metadataContent: "内容"
@ -467,6 +537,7 @@ _instanceCharts:
users: "用户数量:增加/减少" users: "用户数量:增加/减少"
usersTotal: "用户总数" usersTotal: "用户总数"
notes: "帖子:增加/减少" notes: "帖子:增加/减少"
notesTotal: "帖子:总数"
ff: "关注/被关注:数量变化" ff: "关注/被关注:数量变化"
ffTotal: "关注/被关注:总数" ffTotal: "关注/被关注:总数"
cacheSize: "缓存大小:增加/减少" cacheSize: "缓存大小:增加/减少"

View File

@ -0,0 +1,28 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class promo1581979837262 implements MigrationInterface {
name = 'promo1581979837262'
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`CREATE TABLE "promo_note" ("noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "REL_e263909ca4fe5d57f8d4230dd5" UNIQUE ("noteId"), CONSTRAINT "PK_e263909ca4fe5d57f8d4230dd5c" PRIMARY KEY ("noteId"))`, undefined);
await queryRunner.query(`CREATE INDEX "IDX_83f0862e9bae44af52ced7099e" ON "promo_note" ("userId") `, undefined);
await queryRunner.query(`CREATE TABLE "promo_read" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_61917c1541002422b703318b7c9" PRIMARY KEY ("id"))`, undefined);
await queryRunner.query(`CREATE INDEX "IDX_9657d55550c3d37bfafaf7d4b0" ON "promo_read" ("userId") `, undefined);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_2882b8a1a07c7d281a98b6db16" ON "promo_read" ("userId", "noteId") `, undefined);
await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
await queryRunner.query(`ALTER TABLE "promo_read" ADD CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, undefined);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4"`, undefined);
await queryRunner.query(`ALTER TABLE "promo_read" DROP CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05"`, undefined);
await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_2882b8a1a07c7d281a98b6db16"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_9657d55550c3d37bfafaf7d4b0"`, undefined);
await queryRunner.query(`DROP TABLE "promo_read"`, undefined);
await queryRunner.query(`DROP INDEX "IDX_83f0862e9bae44af52ced7099e"`, undefined);
await queryRunner.query(`DROP TABLE "promo_note"`, undefined);
}
}

View File

@ -0,0 +1,14 @@
import {MigrationInterface, QueryRunner} from "typeorm";
export class featuredInjecttion1582019042083 implements MigrationInterface {
name = 'featuredInjecttion1582019042083'
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "injectFeaturedNote" boolean NOT NULL DEFAULT true`, undefined);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "injectFeaturedNote"`, undefined);
}
}

View File

@ -1,7 +1,7 @@
{ {
"name": "misskey", "name": "misskey",
"author": "syuilo <syuilotan@yahoo.co.jp>", "author": "syuilo <syuilotan@yahoo.co.jp>",
"version": "12.10.0", "version": "12.13.0",
"codename": "indigo", "codename": "indigo",
"repository": { "repository": {
"type": "git", "type": "git",

View File

@ -44,11 +44,11 @@
<mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/> <mk-avatar :user="$store.state.i" class="avatar"/><mk-acct class="text" :user="$store.state.i"/>
</button> </button>
<div class="divider"></div> <div class="divider"></div>
<router-link class="item index" active-class="active" to="/" exact v-if="$store.getters.isSignedIn"> <button class="item _button index active" @click="top()" v-if="$route.name === 'index'">
<fa :icon="faHome" fixed-width/><span class="text">{{ $t('timeline') }}</span> <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
</router-link> </button>
<router-link class="item index" active-class="active" to="/" exact v-else> <router-link class="item index" active-class="active" to="/" exact v-else>
<fa :icon="faHome" fixed-width/><span class="text">{{ $t('home') }}</span> <fa :icon="faHome" fixed-width/><span class="text">{{ $store.getters.isSignedIn ? $t('timeline') : $t('home') }}</span>
</router-link> </router-link>
<button class="item _button notifications" @click="notificationsOpen = !notificationsOpen" ref="notificationButton" v-if="$store.getters.isSignedIn"> <button class="item _button notifications" @click="notificationsOpen = !notificationsOpen" ref="notificationButton" v-if="$store.getters.isSignedIn">
<fa :icon="faBell" fixed-width/><span class="text">{{ $t('notifications') }}</span> <fa :icon="faBell" fixed-width/><span class="text">{{ $t('notifications') }}</span>
@ -58,13 +58,13 @@
<fa :icon="faComments" fixed-width/><span class="text">{{ $t('messaging') }}</span> <fa :icon="faComments" fixed-width/><span class="text">{{ $t('messaging') }}</span>
<i v-if="$store.state.i.hasUnreadMessagingMessage"><fa :icon="faCircle"/></i> <i v-if="$store.state.i.hasUnreadMessagingMessage"><fa :icon="faCircle"/></i>
</router-link> </router-link>
<router-link class="item" active-class="active" to="/my/follow-requests" v-if="$store.getters.isSignedIn && $store.state.i.isLocked">
<fa :icon="faUserClock" fixed-width/><span class="text">{{ $t('followRequests') }}</span>
<i v-if="$store.state.i.pendingReceivedFollowRequestsCount"><fa :icon="faCircle"/></i>
</router-link>
<router-link class="item" active-class="active" to="/my/drive" v-if="$store.getters.isSignedIn"> <router-link class="item" active-class="active" to="/my/drive" v-if="$store.getters.isSignedIn">
<fa :icon="faCloud" fixed-width/><span class="text">{{ $t('drive') }}</span> <fa :icon="faCloud" fixed-width/><span class="text">{{ $t('drive') }}</span>
</router-link> </router-link>
<router-link class="item" active-class="active" to="/my/follow-requests" v-if="$store.getters.isSignedIn && $store.state.i.isLocked">
<fa :icon="faUserClock" fixed-width/><span class="text">{{ $t('followRequests') }}</span>
<i v-if="$store.state.i.hasPendingReceivedFollowRequest"><fa :icon="faCircle"/></i>
</router-link>
<div class="divider"></div> <div class="divider"></div>
<router-link class="item" active-class="active" to="/featured"> <router-link class="item" active-class="active" to="/featured">
<fa :icon="faFireAlt" fixed-width/><span class="text">{{ $t('featured') }}</span> <fa :icon="faFireAlt" fixed-width/><span class="text">{{ $t('featured') }}</span>
@ -87,11 +87,14 @@
<fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span> <fa :icon="faEllipsisH" fixed-width/><span class="text">{{ $t('more') }}</span>
<i v-if="$store.getters.isSignedIn && ($store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes)"><fa :icon="faCircle"/></i> <i v-if="$store.getters.isSignedIn && ($store.state.i.hasUnreadMentions || $store.state.i.hasUnreadSpecifiedNotes)"><fa :icon="faCircle"/></i>
</button> </button>
<router-link class="item" active-class="active" to="/settings">
<fa :icon="faCog" fixed-width/><span class="text">{{ $t('settings') }}</span>
</router-link>
</div> </div>
</nav> </nav>
</transition> </transition>
<div class="contents" ref="contents"> <div class="contents" ref="contents" :class="{ wallpaper }">
<main ref="main"> <main ref="main">
<div class="content"> <div class="content">
<transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition">
@ -137,8 +140,9 @@
</div> </div>
<div class="buttons"> <div class="buttons">
<button v-if="$store.getters.isSignedIn" class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="$store.state.i.hasUnreadSpecifiedNotes || $store.state.i.pendingReceivedFollowRequestsCount || $store.state.i.hasUnreadMessagingMessage || $store.state.i.hasUnreadAnnouncement"><fa :icon="faCircle"/></i></button> <button class="button nav _button" @click="showNav = true" ref="navButton"><fa :icon="faBars"/><i v-if="$store.getters.isSignedIn && ($store.state.i.hasUnreadSpecifiedNotes || $store.state.i.hasPendingReceivedFollowRequest || $store.state.i.hasUnreadMessagingMessage || $store.state.i.hasUnreadAnnouncement)"><fa :icon="faCircle"/></i></button>
<button v-if="$store.getters.isSignedIn" class="button home _button" :disabled="$route.path === '/'" @click="$router.push('/')"><fa :icon="faHome"/></button> <button v-if="$route.name === 'index'" class="button home _button" @click="top()"><fa :icon="faHome"/></button>
<button v-else class="button home _button" @click="$router.push('/')"><fa :icon="faHome"/></button>
<button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="notificationsOpen = !notificationsOpen" ref="notificationButton2"><fa :icon="notificationsOpen ? faTimes : faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button> <button v-if="$store.getters.isSignedIn" class="button notifications _button" @click="notificationsOpen = !notificationsOpen" ref="notificationButton2"><fa :icon="notificationsOpen ? faTimes : faBell"/><i v-if="$store.state.i.hasUnreadNotification"><fa :icon="faCircle"/></i></button>
<button v-if="$store.getters.isSignedIn" class="button post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button> <button v-if="$store.getters.isSignedIn" class="button post _buttonPrimary" @click="post()"><fa :icon="faPencilAlt"/></button>
</div> </div>
@ -163,6 +167,8 @@ import { search } from './scripts/search';
import contains from './scripts/contains'; import contains from './scripts/contains';
import MkToast from './components/toast.vue'; import MkToast from './components/toast.vue';
const DESKTOP_THRESHOLD = 1100;
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -186,9 +192,10 @@ export default Vue.extend({
searchQuery: '', searchQuery: '',
searchWait: false, searchWait: false,
widgetsEditMode: false, widgetsEditMode: false,
isDesktop: window.innerWidth >= 1100, isDesktop: window.innerWidth >= DESKTOP_THRESHOLD,
canBack: false, canBack: false,
disconnectedDialog: null as Promise<void> | null, disconnectedDialog: null as Promise<void> | null,
wallpaper: localStorage.getItem('wallpaper') != null,
faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer faGripVertical, faChevronLeft, faComments, faHashtag, faBroadcastTower, faFireAlt, faEllipsisH, faPencilAlt, faBars, faTimes, faBell, faSearch, faUserCog, faCog, faUser, faHome, faStar, faCircle, faAt, faEnvelope, faListUl, faPlus, faUserClock, faLaugh, faUsers, faTachometerAlt, faExchangeAlt, faGlobe, faChartBar, faCloud, faServer
}; };
}, },
@ -226,6 +233,10 @@ export default Vue.extend({
el.removeEventListener('mousedown', this.onMousedown); el.removeEventListener('mousedown', this.onMousedown);
} }
} }
},
isDesktop() {
if (this.isDesktop) this.adjustWidgetsWidth();
} }
}, },
@ -274,17 +285,7 @@ export default Vue.extend({
}, },
mounted() { mounted() {
// https://stackoverflow.com/questions/33891709/when-flexbox-items-wrap-in-column-mode-container-does-not-grow-its-width if (this.isDesktop) this.adjustWidgetsWidth();
if (this.isDesktop) {
const adjustWidgetsWidth = () => {
const lastChild = this.$refs.widgets.children[this.$refs.widgets.children.length - 1];
if (lastChild == null) return;
const width = lastChild.offsetLeft + 300 + 16;
this.$refs.widgets.style.width = width + 'px';
};
setInterval(adjustWidgetsWidth, 1000);
}
const adjustTitlePosition = () => { const adjustTitlePosition = () => {
this.$refs.title.style.left = (this.$refs.main.getBoundingClientRect().left - this.$refs.nav.offsetWidth) + 'px'; this.$refs.title.style.left = (this.$refs.main.getBoundingClientRect().left - this.$refs.nav.offsetWidth) + 'px';
@ -298,10 +299,33 @@ export default Vue.extend({
ro.observe(this.$refs.contents); ro.observe(this.$refs.contents);
window.addEventListener('resize', adjustTitlePosition); window.addEventListener('resize', adjustTitlePosition, { passive: true });
if (!this.isDesktop) {
window.addEventListener('resize', () => {
if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true;
}, { passive: true });
}
}, },
methods: { methods: {
adjustWidgetsWidth() {
// https://stackoverflow.com/questions/33891709/when-flexbox-items-wrap-in-column-mode-container-does-not-grow-its-width
const adjust = () => {
const lastChild = this.$refs.widgets.children[this.$refs.widgets.children.length - 1];
if (lastChild == null) return;
const width = lastChild.offsetLeft + 300 + 16;
this.$refs.widgets.style.width = width + 'px';
};
setInterval(adjust, 1000);
setTimeout(adjust, 100);
},
top() {
window.scroll({ top: 0, behavior: 'smooth' });
},
help() { help() {
this.$router.push('/docs/keyboard-shortcut'); this.$router.push('/docs/keyboard-shortcut');
}, },
@ -361,21 +385,18 @@ export default Vue.extend({
avatar: this.$store.state.i, avatar: this.$store.state.i,
}, { }, {
type: 'link', type: 'link',
text: this.$t('settings'), text: this.$t('accountSettings'),
to: '/my/settings', to: '/my/settings',
icon: faCog, icon: faCog,
}, null, ...accountItems, { }, null, ...accountItems, {
type: 'item',
icon: faPlus, icon: faPlus,
text: this.$t('addAcount'), text: this.$t('addAcount'),
action: () => { action: () => {
this.$root.menu({ this.$root.menu({
items: [{ items: [{
type: 'item',
text: this.$t('existingAcount'), text: this.$t('existingAcount'),
action: () => { this.addAcount(); }, action: () => { this.addAcount(); },
}, { }, {
type: 'item',
text: this.$t('createAccount'), text: this.$t('createAccount'),
action: () => { this.createAccount(); }, action: () => { this.createAccount(); },
}], }],
@ -397,9 +418,14 @@ export default Vue.extend({
this.$root.menu({ this.$root.menu({
items: [{ items: [{
type: 'link', type: 'link',
text: this.$t('statistics'), text: this.$t('dashboard'),
to: '/instance/stats', to: '/instance',
icon: faChartBar, icon: faTachometerAlt,
}, null, {
type: 'link',
text: this.$t('settings'),
to: '/instance/settings',
icon: faCog,
}, { }, {
type: 'link', type: 'link',
text: this.$t('customEmojis'), text: this.$t('customEmojis'),
@ -415,11 +441,6 @@ export default Vue.extend({
text: this.$t('files'), text: this.$t('files'),
to: '/instance/files', to: '/instance/files',
icon: faCloud, icon: faCloud,
}, {
type: 'link',
text: this.$t('monitor'),
to: '/instance/monitor',
icon: faTachometerAlt,
}, { }, {
type: 'link', type: 'link',
text: this.$t('jobQueue'), text: this.$t('jobQueue'),
@ -435,11 +456,6 @@ export default Vue.extend({
text: this.$t('announcements'), text: this.$t('announcements'),
to: '/instance/announcements', to: '/instance/announcements',
icon: faBroadcastTower, icon: faBroadcastTower,
}, null, {
type: 'link',
text: this.$t('general'),
to: '/instance',
icon: faCog,
}], }],
align: 'left', align: 'left',
fixed: true, fixed: true,
@ -590,7 +606,9 @@ export default Vue.extend({
'calendar', 'calendar',
'rss', 'rss',
'trends', 'trends',
'clock' 'clock',
'activity',
'photos',
]; ];
this.$root.menu({ this.$root.menu({
@ -870,6 +888,7 @@ export default Vue.extend({
width: $nav-width; width: $nav-width;
height: 100vh; height: 100vh;
padding: 16px 0; padding: 16px 0;
padding-bottom: calc(3.7rem + 24px);
box-sizing: border-box; box-sizing: border-box;
overflow: auto; overflow: auto;
background: var(--navBg); background: var(--navBg);
@ -883,6 +902,7 @@ export default Vue.extend({
@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) { @media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
width: $nav-icon-only-width; width: $nav-icon-only-width;
padding: 8px 0; padding: 8px 0;
padding-bottom: calc(3.7rem + 24px);
> .divider { > .divider {
margin: 8px auto; margin: 8px auto;
@ -930,12 +950,24 @@ export default Vue.extend({
&:hover { &:hover {
text-decoration: none; text-decoration: none;
color: var(--navHoverFg);
} }
&.active { &.active {
color: var(--navActive); color: var(--navActive);
} }
&:last-child {
position: fixed;
bottom: 0;
width: inherit;
padding-top: 8px;
padding-bottom: 8px;
background: var(--navBg);
border-top: solid 1px var(--divider);
border-right: solid 1px var(--divider);
}
@media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) { @media (max-width: $nav-icon-only-threshold) and (min-width: $nav-hide-threshold + 1px) {
padding-left: 0; padding-left: 0;
width: 100%; width: 100%;
@ -972,6 +1004,10 @@ export default Vue.extend({
margin: 0 auto; margin: 0 auto;
min-width: 0; min-width: 0;
&.wallpaper {
background: var(--wallpaperOverlay);
}
> main { > main {
width: $main-width; width: $main-width;
min-width: $main-width; min-width: $main-width;
@ -1168,7 +1204,7 @@ export default Vue.extend({
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
color: var(--accent); color: var(--indicator);
font-size: 16px; font-size: 16px;
animation: blink 1s infinite; animation: blink 1s infinite;
} }

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html>
<head>
<title>Misskey API</title>
<!-- needed for adaptive design -->
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
<!--
ReDoc doesn't change outer page styles
-->
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<redoc spec-url='/api.json'></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@ -55,15 +55,17 @@ export default Vue.extend({
handsTailLength: 0.7, handsTailLength: 0.7,
hHandLengthRatio: 0.75, hHandLengthRatio: 0.75,
mHandLengthRatio: 1, mHandLengthRatio: 1,
sHandLengthRatio: 1 sHandLengthRatio: 1,
computedStyle: getComputedStyle(document.documentElement)
}; };
}, },
computed: { computed: {
dark(): boolean { dark(): boolean {
return tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--bg')).isDark(); return tinycolor(this.computedStyle.getPropertyValue('--bg')).isDark();
}, },
majorGraduationColor(): string { majorGraduationColor(): string {
return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)'; return this.dark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)';
}, },
@ -75,10 +77,10 @@ export default Vue.extend({
return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)'; return this.dark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.3)';
}, },
mHandColor(): string { mHandColor(): string {
return tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--fg')).toHexString(); return tinycolor(this.computedStyle.getPropertyValue('--fg')).toHexString();
}, },
hHandColor(): string { hHandColor(): string {
return tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')).toHexString(); return tinycolor(this.computedStyle.getPropertyValue('--accent')).toHexString();
}, },
ms(): number { ms(): number {
@ -123,6 +125,16 @@ export default Vue.extend({
} }
}; };
update(); update();
this.$store.subscribe((mutation, state) => {
if (mutation.type !== 'device/set') return;
if (mutation?.payload?.key !== 'theme') return;
setTimeout(() => {
this.computedStyle = getComputedStyle(document.documentElement);
}, 250);
});
}, },
beforeDestroy() { beforeDestroy() {

View File

@ -2,7 +2,7 @@
<div class="swhvrteh" @contextmenu.prevent="() => {}"> <div class="swhvrteh" @contextmenu.prevent="() => {}">
<ol class="users" ref="suggests" v-if="type === 'user'"> <ol class="users" ref="suggests" v-if="type === 'user'">
<li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user"> <li v-for="user in users" @click="complete(type, user)" @keydown="onKeydown" tabindex="-1" class="user">
<img class="avatar" :src="user.avatarUrl" alt=""/> <img class="avatar" :src="user.avatarUrl"/>
<span class="name"> <span class="name">
<mk-user-name :user="user" :key="user.id"/> <mk-user-name :user="user" :key="user.id"/>
</span> </span>

View File

@ -2,7 +2,7 @@
<sequential-entrance class="sqadhkmv" ref="list" :direction="direction" :reversed="reversed"> <sequential-entrance class="sqadhkmv" ref="list" :direction="direction" :reversed="reversed">
<template v-for="(item, i) in items"> <template v-for="(item, i) in items">
<slot :item="item" :i="i"></slot> <slot :item="item" :i="i"></slot>
<div class="separator" :key="item.id + '_date'" v-if="i != items.length - 1 && new Date(item.createdAt).getDate() != new Date(items[i + 1].createdAt).getDate()"> <div class="separator" :key="item.id + '_date'" v-if="showDate(i, item)">
<p class="date"> <p class="date">
<span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span> <span><fa class="icon" :icon="faAngleUp"/>{{ getDateText(item.createdAt) }}</span>
<span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span> <span>{{ getDateText(items[i + 1].createdAt) }}<fa class="icon" :icon="faAngleDown"/></span>
@ -52,6 +52,16 @@ export default Vue.extend({
}); });
}, },
showDate(i, item) {
return (
i != this.items.length - 1 &&
new Date(item.createdAt).getDate() != new Date(this.items[i + 1].createdAt).getDate() &&
!item._prId_ &&
!this.items[i + 1]._prId_ &&
!item._featuredId_ &&
!this.items[i + 1]._featuredId_);
},
focus() { focus() {
this.$refs.list.focus(); this.$refs.list.focus();
} }

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="mk-dialog" :class="{ iconOnly }"> <div class="mk-dialog" :class="{ iconOnly }">
<transition name="bg-fade" appear> <transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear>
<div class="bg" ref="bg" @click="onBgClick" v-if="show"></div> <div class="bg" ref="bg" @click="onBgClick" v-if="show"></div>
</transition> </transition>
<transition name="dialog" appear @after-leave="() => { destroyDom(); }"> <transition :name="$store.state.device.animation ? 'dialog' : ''" appear @after-leave="() => { destroyDom(); }">
<div class="main" ref="main" v-if="show"> <div class="main" ref="main" v-if="show">
<template v-if="type == 'signin'"> <template v-if="type == 'signin'">
<mk-signin/> <mk-signin/>

View File

@ -12,7 +12,7 @@
preload="metadata" preload="metadata"
controls controls
v-else-if="detail && is === 'video'"/> v-else-if="detail && is === 'video'"/>
<img :src="file.thumbnailUrl" alt="" @load="onThumbnailLoaded" :style="`object-fit: ${ fit }`" v-else-if="isThumbnailAvailable"/> <img :src="file.thumbnailUrl" @load="onThumbnailLoaded" :style="`object-fit: ${ fit }`" v-else-if="isThumbnailAvailable"/>
<fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/> <fa :icon="faFileImage" class="icon" v-else-if="is === 'image'"/>
<fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/> <fa :icon="faFileVideo" class="icon" v-else-if="is === 'video'"/>

View File

@ -83,17 +83,14 @@ export default Vue.extend({
} else { } else {
this.$root.menu({ this.$root.menu({
items: [{ items: [{
type: 'item',
text: this.$t('rename'), text: this.$t('rename'),
icon: faICursor, icon: faICursor,
action: this.rename action: this.rename
}, { }, {
type: 'item',
text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'), text: this.file.isSensitive ? this.$t('unmarkAsSensitive') : this.$t('markAsSensitive'),
icon: this.file.isSensitive ? faEye : faEyeSlash, icon: this.file.isSensitive ? faEye : faEyeSlash,
action: this.toggleSensitive action: this.toggleSensitive
}, null, { }, null, {
type: 'item',
text: this.$t('copyUrl'), text: this.$t('copyUrl'),
icon: faLink, icon: faLink,
action: this.copyUrl action: this.copyUrl
@ -105,7 +102,6 @@ export default Vue.extend({
icon: faDownload, icon: faDownload,
download: this.file.name download: this.file.name
}, null, { }, null, {
type: 'item',
text: this.$t('delete'), text: this.$t('delete'),
icon: faTrashAlt, icon: faTrashAlt,
action: this.deleteFile action: this.deleteFile
@ -113,11 +109,9 @@ export default Vue.extend({
type: 'nest', type: 'nest',
text: this.$t('contextmenu.else-files'), text: this.$t('contextmenu.else-files'),
menu: [{ menu: [{
type: 'item',
text: this.$t('contextmenu.set-as-avatar'), text: this.$t('contextmenu.set-as-avatar'),
action: this.setAsAvatar action: this.setAsAvatar
}, { }, {
type: 'item',
text: this.$t('contextmenu.set-as-banner'), text: this.$t('contextmenu.set-as-banner'),
action: this.setAsBanner action: this.setAsBanner
}] }]

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="mjndxjcg _panel"> <div class="mjndxjcg _panel">
<img src="https://xn--931a.moe/assets/error.png" alt="" class="_ghost"/> <img src="https://xn--931a.moe/assets/error.png" class="_ghost"/>
<p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p> <p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p>
<mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button> <mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button>
</div> </div>

View File

@ -7,7 +7,7 @@
<span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span> <span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span>
</time> </time>
</div> </div>
<div class="content _panel"> <div class="content _panel _ghost">
<mk-clock/> <mk-clock/>
</div> </div>
</div> </div>
@ -66,8 +66,10 @@ export default Vue.extend({
> .header { > .header {
padding: 0 12px; padding: 0 12px;
padding-top: 4px;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
font-family: Lucida Console, Courier, monospace;
&:hover + .content { &:hover + .content {
opacity: 1; opacity: 1;
@ -90,7 +92,6 @@ export default Vue.extend({
position: absolute; position: absolute;
top: auto; top: auto;
right: 0; right: 0;
z-index: 3;
margin: 16px 0 0 0; margin: 16px 0 0 0;
padding: 16px; padding: 16px;
width: 230px; width: 230px;

View File

@ -0,0 +1,54 @@
<template>
<x-modal ref="modal" @closed="() => { $emit('closed'); destroyDom(); }">
<img class="xubzgfga" ref="img" :src="image.url" :alt="image.name" :title="image.name" @click="close" tabindex="-1"/>
</x-modal>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
import XModal from './modal.vue';
export default Vue.extend({
i18n,
components: {
XModal,
},
props: {
image: {
type: Object,
required: true
},
},
mounted() {
this.$nextTick(() => {
this.$refs.img.focus();
});
},
methods: {
close() {
this.$refs.modal.close();
},
}
});
</script>
<style lang="scss" scoped>
.xubzgfga {
position: fixed;
z-index: 2;
top: 0;
right: 0;
bottom: 0;
left: 0;
max-width: 100%;
max-height: 100%;
margin: auto;
cursor: zoom-out;
image-orientation: from-image;
}
</style>

View File

@ -1,8 +1,91 @@
<template> <template>
<div class="mk-instance-stats"> <div class="zbcjwnqg">
<div class="stats" v-if="info">
<div class="_panel">
<div>
<b><fa :icon="faUser"/>{{ $t('users') }}</b>
<small>{{ $t('local') }}</small>
</div>
<div>
<dl class="total">
<dt>{{ $t('total') }}</dt>
<dd>{{ info.originalUsersCount | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: usersLocalDoD > 0 }">
<dt>{{ $t('dayOverDayChanges') }}</dt>
<dd>{{ usersLocalDoD | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: usersLocalWoW > 0 }">
<dt>{{ $t('weekOverWeekChanges') }}</dt>
<dd>{{ usersLocalWoW | number }}</dd>
</dl>
</div>
</div>
<div class="_panel">
<div>
<b><fa :icon="faUser"/>{{ $t('users') }}</b>
<small>{{ $t('remote') }}</small>
</div>
<div>
<dl class="total">
<dt>{{ $t('total') }}</dt>
<dd>{{ (info.usersCount - info.originalUsersCount) | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: usersRemoteDoD > 0 }">
<dt>{{ $t('dayOverDayChanges') }}</dt>
<dd>{{ usersRemoteDoD | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: usersRemoteWoW > 0 }">
<dt>{{ $t('weekOverWeekChanges') }}</dt>
<dd>{{ usersRemoteWoW | number }}</dd>
</dl>
</div>
</div>
<div class="_panel">
<div>
<b><fa :icon="faPencilAlt"/>{{ $t('notes') }}</b>
<small>{{ $t('local') }}</small>
</div>
<div>
<dl class="total">
<dt>{{ $t('total') }}</dt>
<dd>{{ info.originalNotesCount | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: notesLocalDoD > 0 }">
<dt>{{ $t('dayOverDayChanges') }}</dt>
<dd>{{ notesLocalDoD | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: notesLocalWoW > 0 }">
<dt>{{ $t('weekOverWeekChanges') }}</dt>
<dd>{{ notesLocalWoW | number }}</dd>
</dl>
</div>
</div>
<div class="_panel">
<div>
<b><fa :icon="faPencilAlt"/>{{ $t('notes') }}</b>
<small>{{ $t('remote') }}</small>
</div>
<div>
<dl class="total">
<dt>{{ $t('total') }}</dt>
<dd>{{ (info.notesCount - info.originalNotesCount) | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: notesRemoteDoD > 0 }">
<dt>{{ $t('dayOverDayChanges') }}</dt>
<dd>{{ notesRemoteDoD | number }}</dd>
</dl>
<dl class="diff" :class="{ inc: notesRemoteWoW > 0 }">
<dt>{{ $t('weekOverWeekChanges') }}</dt>
<dd>{{ notesRemoteWoW | number }}</dd>
</dl>
</div>
</div>
</div>
<section class="_card"> <section class="_card">
<div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div> <div class="_title"><fa :icon="faChartBar"/> {{ $t('statistics') }}</div>
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;"> <div class="_content" style="margin-top: -8px;">
<div class="selects" style="display: flex;"> <div class="selects" style="display: flex;">
<mk-select v-model="chartSrc" style="margin: 0; flex: 1;"> <mk-select v-model="chartSrc" style="margin: 0; flex: 1;">
<optgroup :label="$t('federation')"> <optgroup :label="$t('federation')">
@ -40,10 +123,10 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faChartBar } from '@fortawesome/free-solid-svg-icons'; import { faChartBar, faUser, faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import Chart from 'chart.js'; import Chart from 'chart.js';
import i18n from '../../i18n'; import i18n from '../i18n';
import MkSelect from '../../components/ui/select.vue'; import MkSelect from './ui/select.vue';
const chartLimit = 90; const chartLimit = 90;
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b)); const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
@ -59,24 +142,27 @@ const alpha = (hex, a) => {
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
metaInfo() {
return {
title: `${this.$t('statistics')} | ${this.$t('instance')}`
};
},
components: { components: {
MkSelect MkSelect
}, },
data() { data() {
return { return {
info: null,
notesLocalWoW: 0,
notesLocalDoD: 0,
notesRemoteWoW: 0,
notesRemoteDoD: 0,
usersLocalWoW: 0,
usersLocalDoD: 0,
usersRemoteWoW: 0,
usersRemoteDoD: 0,
now: null, now: null,
chart: null, chart: null,
chartInstance: null, chartInstance: null,
chartSrc: 'notes', chartSrc: 'notes',
chartSpan: 'hour', chartSpan: 'hour',
faChartBar faChartBar, faUser, faPencilAlt
} }
}, },
@ -121,6 +207,8 @@ export default Vue.extend({
}, },
async created() { async created() {
this.info = await this.$root.api('stats');
this.now = new Date(); this.now = new Date();
const [perHour, perDay] = await Promise.all([Promise.all([ const [perHour, perDay] = await Promise.all([Promise.all([
@ -154,6 +242,15 @@ export default Vue.extend({
} }
}; };
this.notesLocalWoW = this.info.originalNotesCount - chart.perDay.notes.local.total[7];
this.notesLocalDoD = this.info.originalNotesCount - chart.perDay.notes.local.total[1];
this.notesRemoteWoW = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[7];
this.notesRemoteDoD = (this.info.notesCount - this.info.originalNotesCount) - chart.perDay.notes.remote.total[1];
this.usersLocalWoW = this.info.originalUsersCount - chart.perDay.users.local.total[7];
this.usersLocalDoD = this.info.originalUsersCount - chart.perDay.users.local.total[1];
this.usersRemoteWoW = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[7];
this.usersRemoteDoD = (this.info.usersCount - this.info.originalUsersCount) - chart.perDay.users.remote.total[1];
this.chart = chart; this.chart = chart;
this.renderChart(); this.renderChart();
@ -489,3 +586,80 @@ export default Vue.extend({
} }
}); });
</script> </script>
<style lang="scss" scoped>
.zbcjwnqg {
> .stats {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin: calc(0px - var(--margin) / 2);
margin-bottom: calc(var(--margin) / 2);
> div {
display: flex;
flex: 1 0 213px;
margin: calc(var(--margin) / 2);
box-sizing: border-box;
padding: 16px 20px;
> div {
width: 50%;
&:first-child {
> b {
display: block;
> [data-icon] {
width: 16px;
margin-right: 8px;
}
}
> small {
margin-left: 16px + 8px;
opacity: 0.7;
}
}
&:last-child {
> dl {
display: flex;
margin: 0;
line-height: 1.5em;
> dt,
> dd {
width: 50%;
margin: 0;
}
> dt {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
&.total {
> dt,
> dd {
font-weight: bold;
}
}
&.diff.inc {
> dd {
color: #82c11c;
&:before {
content: "+";
}
}
}
}
}
}
}
}
}
</style>

View File

@ -20,6 +20,7 @@ import Vue from 'vue';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'; import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n'; import i18n from '../i18n';
import { getStaticImageUrl } from '../scripts/get-static-image-url'; import { getStaticImageUrl } from '../scripts/get-static-image-url';
import ImageViewer from './image-viewer.vue';
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -60,7 +61,16 @@ export default Vue.extend({
}, },
methods: { methods: {
onClick() { onClick() {
window.open(this.image.url, '_blank'); if (this.$store.state.device.imageNewTab) {
window.open(this.image.url, '_blank');
} else {
const viewer = this.$root.new(ImageViewer, {
image: this.image
});
this.$once('hook:beforeDestroy', () => {
viewer.close();
});
}
} }
} }
}); });

View File

@ -176,7 +176,7 @@ export default Vue.extend({
position: absolute; position: absolute;
top: 5px; top: 5px;
left: 13px; left: 13px;
color: var(--accent); color: var(--indicator);
font-size: 12px; font-size: 12px;
animation: blink 1s infinite; animation: blink 1s infinite;
} }

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="mk-modal"> <div class="mk-modal" v-hotkey.global="keymap">
<transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear> <transition :name="$store.state.device.animation ? 'bg-fade' : ''" appear>
<div class="bg" ref="bg" v-if="show" @click="close()"></div> <div class="bg" ref="bg" v-if="show" @click="close()"></div>
</transition> </transition>
@ -20,6 +20,13 @@ export default Vue.extend({
show: true, show: true,
}; };
}, },
computed: {
keymap(): any {
return {
'esc': this.close,
};
},
},
methods: { methods: {
close() { close() {
this.show = false; this.show = false;

View File

@ -77,23 +77,19 @@ export default Vue.extend({
> .admin, > .admin,
> .moderator { > .moderator {
margin-right: 0.5em; margin-right: 0.5em;
color: var(--badge);
} }
> .username { > .username {
margin: 0 .5em 0 0; margin: 0 .5em 0 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--noteHeaderAcct);
} }
> .info { > .info {
margin-left: auto; margin-left: auto;
font-size: 0.9em; font-size: 0.9em;
> * {
color: var(--noteHeaderInfo);
}
> .mobile { > .mobile {
margin-right: 8px; margin-right: 8px;
} }

View File

@ -9,7 +9,9 @@
> >
<x-sub v-for="note in conversation" :key="note.id" :note="note"/> <x-sub v-for="note in conversation" :key="note.id" :note="note"/>
<x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/> <x-sub :note="appearNote.reply" class="reply-to" v-if="appearNote.reply"/>
<div class="pinned" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div> <div class="info" v-if="pinned"><fa :icon="faThumbtack"/> {{ $t('pinnedNote') }}</div>
<div class="info" v-if="appearNote._prId_"><fa :icon="faBullhorn"/> {{ $t('promotion') }}<button class="_textButton hide" @click="readPromo()">{{ $t('hideThisNote') }} <fa :icon="faTimes"/></button></div>
<div class="info" v-if="appearNote._featuredId_"><fa :icon="faBolt"/> {{ $t('featured') }}</div>
<div class="renote" v-if="isRenote"> <div class="renote" v-if="isRenote">
<mk-avatar class="avatar" :user="note.user"/> <mk-avatar class="avatar" :user="note.user"/>
<fa :icon="faRetweet"/> <fa :icon="faRetweet"/>
@ -58,7 +60,7 @@
<template v-else><fa :icon="faReply"/></template> <template v-else><fa :icon="faReply"/></template>
<p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p> <p class="count" v-if="appearNote.repliesCount > 0">{{ appearNote.repliesCount }}</p>
</button> </button>
<button v-if="['public', 'home'].includes(appearNote.visibility)" @click="renote()" class="button _button" ref="renoteButton"> <button v-if="canRenote" @click="renote()" class="button _button" ref="renoteButton">
<fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p> <fa :icon="faRetweet"/><p class="count" v-if="appearNote.renoteCount > 0">{{ appearNote.renoteCount }}</p>
</button> </button>
<button v-else class="button _button"> <button v-else class="button _button">
@ -83,7 +85,7 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons'; import { faBolt, faTimes, faBullhorn, faStar, faLink, faExternalLinkSquareAlt, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan, faQuoteRight } from '@fortawesome/free-solid-svg-icons';
import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'; import { faCopy, faTrashAlt, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { parse } from '../../mfm/parse'; import { parse } from '../../mfm/parse';
import { sum, unique } from '../../prelude/array'; import { sum, unique } from '../../prelude/array';
@ -140,7 +142,7 @@ export default Vue.extend({
replies: [], replies: [],
showContent: false, showContent: false,
hideThisNote: false, hideThisNote: false,
faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan faBolt, faTimes, faBullhorn, faPlus, faMinus, faRetweet, faReply, faReplyAll, faEllipsisH, faHome, faUnlock, faEnvelope, faThumbtack, faBan
}; };
}, },
@ -190,16 +192,16 @@ export default Vue.extend({
return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId); return this.$store.getters.isSignedIn && (this.$store.state.i.id === this.appearNote.userId);
}, },
canRenote(): boolean {
return ['public', 'home'].includes(this.appearNote.visibility) || this.isMyNote;
},
reactionsCount(): number { reactionsCount(): number {
return this.appearNote.reactions return this.appearNote.reactions
? sum(Object.values(this.appearNote.reactions)) ? sum(Object.values(this.appearNote.reactions))
: 0; : 0;
}, },
title(): string {
return '';
},
urls(): string[] { urls(): string[] {
if (this.appearNote.text) { if (this.appearNote.text) {
const ast = parse(this.appearNote.text); const ast = parse(this.appearNote.text);
@ -263,6 +265,13 @@ export default Vue.extend({
}, },
methods: { methods: {
readPromo() {
(this as any).$root.api('promo/read', {
noteId: this.appearNote.id
});
this.hideThisNote = true;
},
capture(withHandler = false) { capture(withHandler = false) {
if (this.$store.getters.isSignedIn) { if (this.$store.getters.isSignedIn) {
this.connection.send(document.body.contains(this.$el) ? 'sn' : 's', { id: this.appearNote.id }); this.connection.send(document.body.contains(this.$el) ? 'sn' : 's', { id: this.appearNote.id });
@ -522,6 +531,15 @@ export default Vue.extend({
text: this.$t('pin'), text: this.$t('pin'),
action: () => this.togglePin(true) action: () => this.togglePin(true)
} : undefined, } : undefined,
...(this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [
null,
{
icon: faBullhorn,
text: this.$t('promote'),
action: this.promote
}]
: []
),
...(this.appearNote.userId == this.$store.state.i.id ? [ ...(this.appearNote.userId == this.$store.state.i.id ? [
null, null,
{ {
@ -614,6 +632,30 @@ export default Vue.extend({
}); });
}, },
async promote() {
const { canceled, result: days } = await this.$root.dialog({
title: this.$t('numberOfDays'),
input: { type: 'number' }
});
if (canceled) return;
this.$root.api('admin/promo/create', {
noteId: this.appearNote.id,
expiresAt: Date.now() + (86400000 * days)
}).then(() => {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
},
focus() { focus() {
this.$el.focus(); this.$el.focus();
}, },
@ -710,7 +752,9 @@ export default Vue.extend({
border-radius: 0 0 var(--radius) var(--radius); border-radius: 0 0 var(--radius) var(--radius);
} }
> .pinned { > .info {
display: flex;
align-items: center;
padding: 16px 32px 8px 32px; padding: 16px 32px 8px 32px;
line-height: 24px; line-height: 24px;
font-size: 90%; font-size: 90%;
@ -724,9 +768,14 @@ export default Vue.extend({
> [data-icon] { > [data-icon] {
margin-right: 4px; margin-right: 4px;
} }
> .hide {
margin-left: auto;
color: inherit;
}
} }
> .pinned + .article { > .info + .article {
padding-top: 8px; padding-top: 8px;
} }

View File

@ -1,22 +1,29 @@
<template> <template>
<div class="mk-notes" v-size="[{ max: 500 }]"> <div class="mk-notes" v-size="[{ max: 500 }]">
<div class="empty" v-if="empty"> <div class="empty" v-if="empty">
<img src="https://xn--931a.moe/assets/info.png" alt="" class="_ghost"/> <img src="https://xn--931a.moe/assets/info.png" class="_ghost"/>
<div>{{ $t('noNotes') }}</div> <div>{{ $t('noNotes') }}</div>
</div> </div>
<mk-error v-if="error" @retry="init()"/> <mk-error v-if="error" @retry="init()"/>
<x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note }"> <div class="more" v-if="more && reversed" style="margin-bottom: var(--margin);">
<x-note :note="note" :detail="detail" :key="note.id"/>
</x-list>
<footer class="more" v-if="more">
<mk-button class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()" primary> <mk-button class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()" primary>
<template v-if="!moreFetching">{{ $t('loadMore') }}</template> <template v-if="!moreFetching">{{ $t('loadMore') }}</template>
<template v-if="moreFetching"><mk-loading inline/></template> <template v-if="moreFetching"><mk-loading inline/></template>
</mk-button> </mk-button>
</footer> </div>
<x-list ref="notes" class="notes" :items="notes" v-slot="{ item: note }" :direction="reversed ? 'up' : 'down'" :reversed="reversed">
<x-note :note="note" :detail="detail" :key="note._featuredId_ || note._prId_ || note.id"/>
</x-list>
<div class="more" v-if="more && !reversed" style="margin-top: var(--margin);">
<mk-button class="button" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" @click="fetchMore()" primary>
<template v-if="!moreFetching">{{ $t('loadMore') }}</template>
<template v-if="moreFetching"><mk-loading inline/></template>
</mk-button>
</div>
</div> </div>
</template> </template>
@ -67,6 +74,10 @@ export default Vue.extend({
notes(): any[] { notes(): any[] {
return this.extract ? this.extract(this.items) : this.items; return this.extract ? this.extract(this.items) : this.items;
}, },
reversed(): boolean {
return this.pagination.reversed;
}
}, },
methods: { methods: {
@ -92,14 +103,14 @@ export default Vue.extend({
} }
> .notes { > .notes {
> ::v-deep * { > ::v-deep *:not(:last-child) {
margin-bottom: var(--marginFull); margin-bottom: var(--marginFull);
} }
} }
&.max-width_500px { &.max-width_500px {
> .notes { > .notes {
> ::v-deep * { > ::v-deep *:not(:last-child) {
margin-bottom: var(--marginHalf); margin-bottom: var(--marginHalf);
} }
} }

View File

@ -169,7 +169,7 @@ export default Vue.extend({
background: #36aed2; background: #36aed2;
} }
&.retweet { &.renote {
padding: 3px; padding: 3px;
background: #36d298; background: #36d298;
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="lzyxtsnt"> <div class="lzyxtsnt">
<img v-if="image" :src="image.url" alt=""/> <img v-if="image" :src="image.url"/>
</div> </div>
</template> </template>

View File

@ -1,12 +1,10 @@
<template> <template>
<transition-group v-if="$store.state.device.animation" <transition-group v-if="$store.state.device.animation"
name="staggered-fade" class="uupnnhew"
:data-direction="direction"
:data-reversed="reversed ? 'true' : 'false'"
name="staggered"
tag="div" tag="div"
:css="false"
@before-enter="beforeEnter"
@enter="enter"
@leave="leave"
mode="out-in"
appear appear
> >
<slot></slot> <slot></slot>
@ -37,48 +35,46 @@ export default Vue.extend({
default: false default: false
} }
}, },
i: 0,
methods: { methods: {
beforeEnter(el) {
if (document.hidden) return;
el.style.opacity = 0;
el.style.transform = this.direction === 'down' ? 'translateY(-64px)' : 'translateY(64px)';
const delay = this.delay * this.$options.i;
el.style.transition = [getComputedStyle(el).transition, `transform 0.7s cubic-bezier(0.23, 1, 0.32, 1) ${delay}ms`, `opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1) ${delay}ms`].filter(x => x != '').join(',');
this.$options.i++;
setTimeout(() => {
el.style.transition = null;
el.style.transform = null;
el.style.opacity = null;
this.$options.i--;
}, delay + 710);
},
enter(el) {
if (document.hidden) {
el.style.opacity = 1;
el.style.transform = 'translateY(0px)';
} else {
setTimeout(() => { // 必要
el.style.opacity = 1;
el.style.transform = 'translateY(0px)';
});
}
},
leave(el) {
el.style.opacity = 0;
el.style.transform = this.direction === 'down' ? 'translateY(64px)' : 'translateY(-64px)';
},
focus() { focus() {
this.$slots.default[0].elm.focus(); this.$slots.default[0].elm.focus();
} }
} },
}); });
</script> </script>
<style lang="scss"> <style lang="scss">
.staggered-fade-move { .staggered-move {
transition: transform 0.7s !important; transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1) !important;
}
.uupnnhew[data-direction="up"] {
.staggered-enter {
opacity: 0;
transform: translateY(64px);
}
}
.uupnnhew[data-direction="down"] {
.staggered-enter {
opacity: 0;
transform: translateY(-64px);
}
}
.uupnnhew[data-reversed="true"] {
@for $i from 1 through 30 {
.staggered-enter-active:nth-last-child(#{$i}) {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1) (15ms * ($i - 1)), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1) (15ms * ($i - 1));
}
}
}
.uupnnhew[data-reversed="false"] {
@for $i from 1 through 30 {
.staggered-enter-active:nth-child(#{$i}) {
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1) (15ms * ($i - 1)), opacity 0.7s cubic-bezier(0.23, 1, 0.32, 1) (15ms * ($i - 1));
}
}
} }
</style> </style>

View File

@ -243,6 +243,10 @@ export default Vue.extend({
margin-top: 8px; margin-top: 8px;
} }
&:not(.inline):last-child {
margin-bottom: 8px;
}
> .icon { > .icon {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -81,6 +81,10 @@ export default Vue.extend({
margin-top: 8px; margin-top: 8px;
} }
&:not(.inline):last-child {
margin-bottom: 8px;
}
> .icon { > .icon {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -129,7 +129,6 @@ export default Vue.extend({
> .label { > .label {
margin-left: 8px; margin-left: 8px;
display: block; display: block;
font-size: 16px;
cursor: pointer; cursor: pointer;
transition: inherit; transition: inherit;
color: var(--fg); color: var(--fg);

View File

@ -3,7 +3,7 @@
<template #header><mk-user-name :user="user"/></template> <template #header><mk-user-name :user="user"/></template>
<div class="vrcsvlkm"> <div class="vrcsvlkm">
<mk-button @click="resetPassword()" primary>{{ $t('resetPassword') }}</mk-button> <mk-button @click="resetPassword()" primary>{{ $t('resetPassword') }}</mk-button>
<mk-switch v-if="$store.state.i.isAdmin" @change="toggleModerator()" v-model="moderator">{{ $t('moderator') }}</mk-switch> <mk-switch v-if="$store.state.i.isAdmin && !user.isAdmin" @change="toggleModerator()" v-model="moderator">{{ $t('moderator') }}</mk-switch>
<mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch> <mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch>
<mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch> <mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch>
</div> </div>
@ -47,7 +47,7 @@ export default Vue.extend({
type: 'waiting', type: 'waiting',
iconOnly: true iconOnly: true
}); });
this.$root.api('admin/reset-password', { this.$root.api('admin/reset-password', {
userId: this.user.id, userId: this.user.id,
}).then(({ password }) => { }).then(({ password }) => {

View File

@ -54,6 +54,8 @@ export default {
calc(); calc();
vn.context.$on('hook:activated', calc);
const ro = new ResizeObserver((entries, observer) => { const ro = new ResizeObserver((entries, observer) => {
calc(); calc();
}); });

View File

@ -136,8 +136,6 @@ document.body.innerHTML = '<div id="app"></div>';
const os = new MiOS(); const os = new MiOS();
os.init(async () => { os.init(async () => {
if (os.store.state.settings.wallpaper) document.documentElement.style.backgroundImage = `url(${os.store.state.settings.wallpaper})`;
if ('Notification' in window && os.store.getters.isSignedIn) { if ('Notification' in window && os.store.getters.isSignedIn) {
// 許可を得ていなかったらリクエスト // 許可を得ていなかったらリクエスト
if (Notification.permission === 'default') { if (Notification.permission === 'default') {

View File

@ -227,7 +227,6 @@ export default class MiOS extends EventEmitter {
// トークンが再生成されたとき // トークンが再生成されたとき
// このままではMisskeyが利用できないので強制的にサインアウトさせる // このままではMisskeyが利用できないので強制的にサインアウトさせる
main.on('myTokenRegenerated', () => { main.on('myTokenRegenerated', () => {
alert(locale['common']['my-token-regenerated']);
this.signout(); this.signout();
}); });
} }

View File

@ -12,14 +12,12 @@
<div><b>{{ $t('administrator') }}</b><span>{{ meta.maintainerName }}</span></div> <div><b>{{ $t('administrator') }}</b><span>{{ meta.maintainerName }}</span></div>
<div><b></b><span>{{ meta.maintainerEmail }}</span></div> <div><b></b><span>{{ meta.maintainerEmail }}</span></div>
</div> </div>
<div class="_content table" v-if="stats">
<div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
<div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
</div>
<div class="_content table"> <div class="_content table">
<div><b>Misskey</b><span>v{{ version }}</span></div> <div><b>Misskey</b><span>v{{ version }}</span></div>
</div> </div>
</section> </section>
<mk-instance-stats style="margin-top: var(--margin);"/>
</div> </div>
</template> </template>
@ -28,6 +26,7 @@ import Vue from 'vue';
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import { version } from '../config'; import { version } from '../config';
import i18n from '../i18n'; import i18n from '../i18n';
import MkInstanceStats from '../components/instance-stats.vue';
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -38,10 +37,13 @@ export default Vue.extend({
}; };
}, },
components: {
MkInstanceStats
},
data() { data() {
return { return {
version, version,
stats: null,
serverInfo: null, serverInfo: null,
faInfoCircle faInfoCircle
} }
@ -52,12 +54,6 @@ export default Vue.extend({
return this.$store.state.instance.meta; return this.$store.state.instance.meta;
}, },
}, },
created() {
this.$root.api('stats').then(res => {
this.stats = res;
});
},
}); });
</script> </script>

View File

@ -8,7 +8,7 @@
<div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> <div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div>
<div class="_content"> <div class="_content">
<mfm :text="announcement.text"/> <mfm :text="announcement.text"/>
<img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt=""/> <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
</div> </div>
<div class="_footer" v-if="$store.getters.isSignedIn && !announcement.isRead"> <div class="_footer" v-if="$store.getters.isSignedIn && !announcement.isRead">
<mk-button @click="read(announcement)" primary><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button> <mk-button @click="read(announcement)" primary><fa :icon="faCheck"/> {{ $t('gotIt') }}</mk-button>

View File

@ -1,27 +1,40 @@
<template> <template>
<mk-pagination :pagination="pagination" #default="{items}" class="mk-follow-requests" ref="list"> <div>
<div class="user _panel" v-for="(req, i) in items" :key="req.id"> <portal to="icon"><fa :icon="faUserClock"/></portal>
<mk-avatar class="avatar" :user="req.follower"/> <portal to="title">{{ $t('followRequests') }}</portal>
<div class="body">
<div class="name"> <mk-pagination :pagination="pagination" class="mk-follow-requests" ref="list">
<router-link class="name" :to="req.follower | userPage" v-user-preview="req.follower.id"><mk-user-name :user="req.follower"/></router-link> <template #empty>
<p class="acct">@{{ req.follower | acct }}</p> <div class="tkdrhpxr">
<img src="https://xn--931a.moe/assets/info.png" class="_ghost"/>
<div>{{ $t('noFollowRequests') }}</div>
</div> </div>
<div class="description" v-if="req.follower.description" :title="req.follower.description"> </template>
<mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$store.state.i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> <template #default="{items}">
<div class="user _panel" v-for="req in items" :key="req.id">
<mk-avatar class="avatar" :user="req.follower"/>
<div class="body">
<div class="name">
<router-link class="name" :to="req.follower | userPage" v-user-preview="req.follower.id"><mk-user-name :user="req.follower"/></router-link>
<p class="acct">@{{ req.follower | acct }}</p>
</div>
<div class="description" v-if="req.follower.description" :title="req.follower.description">
<mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$store.state.i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/>
</div>
<div class="actions">
<button class="_button" @click="accept(req.follower)"><fa :icon="faCheck"/></button>
<button class="_button" @click="reject(req.follower)"><fa :icon="faTimes"/></button>
</div>
</div>
</div> </div>
<div class="actions"> </template>
<button class="_button" @click="accept(req.follower)"><fa :icon="faCheck"/></button> </mk-pagination>
<button class="_button" @click="reject(req.follower)"><fa :icon="faTimes"/></button> </div>
</div>
</div>
</div>
</mk-pagination>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; import { faUserClock, faCheck, faTimes } from '@fortawesome/free-solid-svg-icons';
import MkPagination from '../components/ui/pagination.vue'; import MkPagination from '../components/ui/pagination.vue';
export default Vue.extend({ export default Vue.extend({
@ -41,7 +54,7 @@ export default Vue.extend({
endpoint: 'following/requests/list', endpoint: 'following/requests/list',
limit: 10, limit: 10,
}, },
faCheck, faTimes faCheck, faTimes, faUserClock
}; };
}, },
@ -62,6 +75,18 @@ export default Vue.extend({
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-follow-requests { .mk-follow-requests {
.tkdrhpxr {
padding: 32px;
text-align: center;
> img {
vertical-align: bottom;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
}
> .user { > .user {
display: flex; display: flex;
padding: 16px; padding: 16px;

View File

@ -184,7 +184,7 @@ export default Vue.extend({
position: absolute; position: absolute;
top: 16px; top: 16px;
right: 8px; right: 8px;
color: var(--accent); color: var(--indicator);
font-size: 12px; font-size: 12px;
animation: blink 1s infinite; animation: blink 1s infinite;
} }

View File

@ -1,165 +1,58 @@
<template> <template>
<div v-if="meta" class="mk-instance-page"> <div v-if="meta" class="xhexznfu">
<portal to="icon"><fa :icon="faServer"/></portal> <portal to="icon"><fa :icon="faServer"/></portal>
<portal to="title">{{ $t('instance') }}</portal> <portal to="title">{{ $t('instance') }}</portal>
<section class="_card info"> <mk-instance-stats style="margin-bottom: var(--margin);"/>
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div>
<div class="_content"> <section class="_card chart">
<mk-input v-model="name">{{ $t('instanceName') }}</mk-input> <div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div>
<mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea> <div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
<mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input> <canvas ref="cpumem"></canvas>
<mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input>
<mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input>
<mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input>
<mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input>
</div> </div>
<div class="_footer"> <div class="_content" v-if="serverInfo">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button> <div class="table">
<div class="row">
<div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div>
</div>
<div class="row">
<div class="cell"><div class="label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div>
<div class="cell"><div class="label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
<div class="cell"><div class="label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
</div>
</div>
</div>
</section>
<section class="_card chart">
<div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div>
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
<canvas ref="disk"></canvas>
</div>
<div class="_content" v-if="serverInfo">
<div class="table">
<div class="row">
<div class="cell"><div class="label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div>
<div class="cell"><div class="label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
<div class="cell"><div class="label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
</div>
</div>
</div>
</section>
<section class="_card chart">
<div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div>
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
<canvas ref="net"></canvas>
</div>
<div class="_content" v-if="serverInfo">
<div class="table">
<div class="row">
<div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div>
</div>
</div>
</div> </div>
</section> </section>
<section class="_card info"> <section class="_card info">
<div class="_content">
<mk-input v-model="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</mk-input>
</div>
<div class="_content">
<mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch>
<mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch>
<mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info>
</div>
</section>
<section class="_card info">
<div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div>
<div class="_content">
<mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch>
<mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
<div class="_content">
<mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
<template v-if="enableRecaptcha">
<mk-info>{{ $t('recaptchaInfo') }}</mk-info>
<mk-info warn>{{ $t('recaptchaInfo2') }}</mk-info>
<mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
<mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
</template>
</div>
<div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
<header>{{ $t('preview') }}</header>
<div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div>
<div class="_content">
<mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></mk-switch>
<template v-if="enableServiceWorker">
<mk-info>{{ $t('vapidInfo') }}<br><code>npx web-push generate-vapid-keys</code></mk-info>
<mk-horizon-group inputs class="fit-bottom">
<mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Public key</mk-input>
<mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Private key</mk-input>
</mk-horizon-group>
</template>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div>
<div class="_content">
<mk-textarea v-model="pinnedUsers">
<template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template>
</mk-textarea>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
<div class="_content">
<mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch>
<mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch>
<mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
<mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div>
<div class="_content">
<mk-input :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input>
<mk-button primary @click="chooseProxyAccount">{{ $t('chooseProxyAccount') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div>
<div class="_content">
<mk-textarea v-model="blockedHosts">
<template #desc>{{ $t('blockedInstancesDescription') }}</template>
</mk-textarea>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
<div class="_content">
<header><fa :icon="faTwitter"/> Twitter</header>
<mk-switch v-model="enableTwitterIntegration">{{ $t('enable') }}</mk-switch>
<template v-if="enableTwitterIntegration">
<mk-info>Callback URL: {{ `${url}/api/tw/cb` }}</mk-info>
<mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Key</mk-input>
<mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Secret</mk-input>
</template>
</div>
<div class="_content">
<header><fa :icon="faGithub"/> GitHub</header>
<mk-switch v-model="enableGithubIntegration">{{ $t('enable') }}</mk-switch>
<template v-if="enableGithubIntegration">
<mk-info>Callback URL: {{ `${url}/api/gh/cb` }}</mk-info>
<mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input>
<mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input>
</template>
</div>
<div class="_content">
<header><fa :icon="faDiscord"/> Discord</header>
<mk-switch v-model="enableDiscordIntegration">{{ $t('enable') }}</mk-switch>
<template v-if="enableDiscordIntegration">
<mk-info>Callback URL: {{ `${url}/api/dc/cb` }}</mk-info>
<mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input>
<mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input>
</template>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card info">
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('instanceInfo') }}</div>
<div class="_content table" v-if="stats">
<div><b>{{ $t('users') }}</b><span>{{ stats.originalUsersCount | number }}</span></div>
<div><b>{{ $t('notes') }}</b><span>{{ stats.originalNotesCount | number }}</span></div>
</div>
<div class="_content table"> <div class="_content table">
<div><b>Misskey</b><span>v{{ version }}</span></div> <div><b>Misskey</b><span>v{{ version }}</span></div>
</div> </div>
@ -174,18 +67,19 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faPencilAlt, faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons'; import { faServer, faExchangeAlt, faMicrochip, faHdd } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import Chart from 'chart.js';
import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons'; import MkInstanceStats from '../../components/instance-stats.vue';
import MkButton from '../../components/ui/button.vue';
import MkInput from '../../components/ui/input.vue';
import MkTextarea from '../../components/ui/textarea.vue';
import MkSwitch from '../../components/ui/switch.vue';
import MkInfo from '../../components/ui/info.vue';
import MkUserSelect from '../../components/user-select.vue';
import { version, url } from '../../config'; import { version, url } from '../../config';
import i18n from '../../i18n'; import i18n from '../../i18n';
import getAcct from '../../../misc/acct/render';
const alpha = (hex, a) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -197,11 +91,7 @@ export default Vue.extend({
}, },
components: { components: {
MkButton, MkInstanceStats,
MkInput,
MkTextarea,
MkSwitch,
MkInfo,
}, },
data() { data() {
@ -210,41 +100,11 @@ export default Vue.extend({
url, url,
stats: null, stats: null,
serverInfo: null, serverInfo: null,
proxyAccount: null, connection: null,
proxyAccountId: null, memUsage: 0,
cacheRemoteFiles: false, chartCpuMem: null,
proxyRemoteFiles: false, chartNet: null,
localDriveCapacityMb: 0, faServer, faExchangeAlt, faMicrochip, faHdd
remoteDriveCapacityMb: 0,
blockedHosts: '',
pinnedUsers: '',
maintainerName: null,
maintainerEmail: null,
name: null,
description: null,
tosUrl: null,
bannerUrl: null,
iconUrl: null,
maxNoteTextLength: 0,
enableRegistration: false,
enableLocalTimeline: false,
enableGlobalTimeline: false,
enableRecaptcha: false,
recaptchaSiteKey: null,
recaptchaSecretKey: null,
enableServiceWorker: false,
swPublicKey: null,
swPrivateKey: null,
enableTwitterIntegration: false,
twitterConsumerKey: null,
twitterConsumerSecret: null,
enableGithubIntegration: false,
githubClientId: null,
githubClientSecret: null,
enableDiscordIntegration: false,
discordClientId: null,
discordClientSecret: null,
faPencilAlt, faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt
} }
}, },
@ -254,153 +114,313 @@ export default Vue.extend({
}, },
}, },
created() { mounted() {
this.name = this.meta.name; Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
this.description = this.meta.description;
this.tosUrl = this.meta.tosUrl;
this.bannerUrl = this.meta.bannerUrl;
this.iconUrl = this.meta.iconUrl;
this.maintainerName = this.meta.maintainerName;
this.maintainerEmail = this.meta.maintainerEmail;
this.maxNoteTextLength = this.meta.maxNoteTextLength;
this.enableRegistration = !this.meta.disableRegistration;
this.enableLocalTimeline = !this.meta.disableLocalTimeline;
this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
this.enableRecaptcha = this.meta.enableRecaptcha;
this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
this.proxyAccountId = this.meta.proxyAccountId;
this.cacheRemoteFiles = this.meta.cacheRemoteFiles;
this.proxyRemoteFiles = this.meta.proxyRemoteFiles;
this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb;
this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb;
this.blockedHosts = this.meta.blockedHosts.join('\n');
this.pinnedUsers = this.meta.pinnedUsers.join('\n');
this.enableServiceWorker = this.meta.enableServiceWorker;
this.swPublicKey = this.meta.swPublickey;
this.swPrivateKey = this.meta.swPrivateKey;
this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
this.twitterConsumerKey = this.meta.twitterConsumerKey;
this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
this.enableGithubIntegration = this.meta.enableGithubIntegration;
this.githubClientId = this.meta.githubClientId;
this.githubClientSecret = this.meta.githubClientSecret;
this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
this.discordClientId = this.meta.discordClientId;
this.discordClientSecret = this.meta.discordClientSecret;
if (this.proxyAccountId) { this.chartCpuMem = new Chart(this.$refs.cpumem, {
this.$root.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => { type: 'line',
this.proxyAccount = proxyAccount; data: {
}); labels: [],
} datasets: [{
label: 'CPU',
this.$root.api('admin/server-info').then(res => { pointRadius: 0,
this.serverInfo = res; lineTension: 0,
borderWidth: 2,
borderColor: '#86b300',
backgroundColor: alpha('#86b300', 0.1),
data: []
}, {
label: 'MEM (active)',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#935dbf',
backgroundColor: alpha('#935dbf', 0.02),
data: []
}, {
label: 'MEM (used)',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#935dbf',
borderDash: [5, 5],
fill: false,
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 0,
right: 0,
top: 8,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
xAxes: [{
gridLines: {
display: false
},
ticks: {
display: false
}
}],
yAxes: [{
position: 'right',
ticks: {
display: false,
max: 100
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
}
}
}); });
this.$root.api('stats').then(res => { this.chartNet = new Chart(this.$refs.net, {
this.stats = res; type: 'line',
data: {
labels: [],
datasets: [{
label: 'In',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#94a029',
backgroundColor: alpha('#94a029', 0.1),
data: []
}, {
label: 'Out',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#ff9156',
backgroundColor: alpha('#ff9156', 0.1),
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 0,
right: 0,
top: 8,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
xAxes: [{
gridLines: {
display: false
},
ticks: {
display: false
}
}],
yAxes: [{
position: 'right',
ticks: {
display: false,
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
}
}
});
this.chartDisk = new Chart(this.$refs.disk, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Read',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#94a029',
backgroundColor: alpha('#94a029', 0.1),
data: []
}, {
label: 'Write',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#ff9156',
backgroundColor: alpha('#ff9156', 0.1),
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 0,
right: 0,
top: 8,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
xAxes: [{
gridLines: {
display: false
},
ticks: {
display: false
}
}],
yAxes: [{
position: 'right',
ticks: {
display: false,
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
}
}
});
this.$root.api('admin/server-info', {}).then(res => {
this.serverInfo = res;
this.connection = this.$root.stream.useSharedConnection('serverStats');
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 150
});
}); });
}, },
mounted() { beforeDestroy() {
const renderRecaptchaPreview = () => { this.connection.off('stats', this.onStats);
if (!(window as any).grecaptcha) return; this.connection.off('statsLog', this.onStatsLog);
if (!this.$refs.recaptcha) return; this.connection.dispose();
if (!this.recaptchaSiteKey) return;
(window as any).grecaptcha.render(this.$refs.recaptcha, {
sitekey: this.recaptchaSiteKey
});
};
window.onRecaotchaLoad = () => {
renderRecaptchaPreview();
};
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
head.appendChild(script);
this.$watch('enableRecaptcha', () => {
renderRecaptchaPreview();
});
this.$watch('recaptchaSiteKey', () => {
renderRecaptchaPreview();
});
}, },
methods: { methods: {
addPinUser() { onStats(stats) {
this.$root.new(MkUserSelect, {}).$once('selected', user => { const cpu = (stats.cpu * 100).toFixed(0);
this.pinnedUsers = this.pinnedUsers.trim(); const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
this.pinnedUsers += '\n@' + getAcct(user); const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
this.pinnedUsers = this.pinnedUsers.trim(); this.memUsage = stats.mem.active;
});
this.chartCpuMem.data.labels.push('');
this.chartCpuMem.data.datasets[0].data.push(cpu);
this.chartCpuMem.data.datasets[1].data.push(memActive);
this.chartCpuMem.data.datasets[2].data.push(memUsed);
this.chartNet.data.labels.push('');
this.chartNet.data.datasets[0].data.push(stats.net.rx);
this.chartNet.data.datasets[1].data.push(stats.net.tx);
this.chartDisk.data.labels.push('');
this.chartDisk.data.datasets[0].data.push(stats.fs.r);
this.chartDisk.data.datasets[1].data.push(stats.fs.w);
if (this.chartCpuMem.data.datasets[0].data.length > 150) {
this.chartCpuMem.data.labels.shift();
this.chartCpuMem.data.datasets[0].data.shift();
this.chartCpuMem.data.datasets[1].data.shift();
this.chartCpuMem.data.datasets[2].data.shift();
this.chartNet.data.labels.shift();
this.chartNet.data.datasets[0].data.shift();
this.chartNet.data.datasets[1].data.shift();
this.chartDisk.data.labels.shift();
this.chartDisk.data.datasets[0].data.shift();
this.chartDisk.data.datasets[1].data.shift();
}
this.chartCpuMem.update();
this.chartNet.update();
this.chartDisk.update();
}, },
chooseProxyAccount() { onStatsLog(statsLog) {
this.$root.new(MkUserSelect, {}).$once('selected', user => { for (const stats of statsLog.reverse()) {
this.proxyAccount = user; this.onStats(stats);
this.proxyAccountId = user.id; }
this.save(true);
});
},
save(withDialog = false) {
this.$root.api('admin/update-meta', {
name: this.name,
description: this.description,
tosUrl: this.tosUrl,
bannerUrl: this.bannerUrl,
iconUrl: this.iconUrl,
maintainerName: this.maintainerName,
maintainerEmail: this.maintainerEmail,
maxNoteTextLength: this.maxNoteTextLength,
disableRegistration: !this.enableRegistration,
disableLocalTimeline: !this.enableLocalTimeline,
disableGlobalTimeline: !this.enableGlobalTimeline,
enableRecaptcha: this.enableRecaptcha,
recaptchaSiteKey: this.recaptchaSiteKey,
recaptchaSecretKey: this.recaptchaSecretKey,
proxyAccountId: this.proxyAccountId,
cacheRemoteFiles: this.cacheRemoteFiles,
proxyRemoteFiles: this.proxyRemoteFiles,
localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
blockedHosts: this.blockedHosts.split('\n') || [],
pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
enableServiceWorker: this.enableServiceWorker,
swPublicKey: this.swPublicKey,
swPrivateKey: this.swPrivateKey,
enableTwitterIntegration: this.enableTwitterIntegration,
twitterConsumerKey: this.twitterConsumerKey,
twitterConsumerSecret: this.twitterConsumerSecret,
enableGithubIntegration: this.enableGithubIntegration,
githubClientId: this.githubClientId,
githubClientSecret: this.githubClientSecret,
enableDiscordIntegration: this.enableDiscordIntegration,
discordClientId: this.discordClientId,
discordClientSecret: this.discordClientSecret,
}).then(() => {
this.$store.dispatch('instance/fetch');
if (withDialog) {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
} }
} }
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.mk-instance-page { .xhexznfu {
> .stats {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin: calc(0px - var(--margin) / 2);
margin-bottom: calc(var(--margin) / 2);
> div {
flex: 1 0 213px;
margin: calc(var(--margin) / 2);
box-sizing: border-box;
padding: 16px;
}
}
> .chart {
> ._content {
> .table {
> .row {
display: flex;
&:not(:last-child) {
margin-bottom: 16px;
@media (max-width: 500px) {
margin-bottom: 8px;
}
}
> .cell {
flex: 1;
> .label {
font-size: 80%;
opacity: 0.7;
> .icon {
margin-right: 4px;
display: none;
}
}
}
}
}
}
}
> .info { > .info {
> .table { > .table {
> div { > div {

View File

@ -1,381 +0,0 @@
<template>
<div class="mk-instance-monitor">
<section class="_card">
<div class="_title"><fa :icon="faMicrochip"/> {{ $t('cpuAndMemory') }}</div>
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
<canvas ref="cpumem"></canvas>
</div>
<div class="_content" v-if="serverInfo">
<div class="table">
<div class="row">
<div class="cell"><div class="label">CPU</div>{{ serverInfo.cpu.model }}</div>
</div>
<div class="row">
<div class="cell"><div class="label">MEM total</div>{{ serverInfo.mem.total | bytes }}</div>
<div class="cell"><div class="label">MEM used</div>{{ memUsage | bytes }} ({{ (memUsage / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
<div class="cell"><div class="label">MEM free</div>{{ serverInfo.mem.total - memUsage | bytes }} ({{ ((serverInfo.mem.total - memUsage) / serverInfo.mem.total * 100).toFixed(0) }}%)</div>
</div>
</div>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faHdd"/> {{ $t('disk') }}</div>
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
<canvas ref="disk"></canvas>
</div>
<div class="_content" v-if="serverInfo">
<div class="table">
<div class="row">
<div class="cell"><div class="label">Disk total</div>{{ serverInfo.fs.total | bytes }}</div>
<div class="cell"><div class="label">Disk used</div>{{ serverInfo.fs.used | bytes }} ({{ (serverInfo.fs.used / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
<div class="cell"><div class="label">Disk free</div>{{ serverInfo.fs.total - serverInfo.fs.used | bytes }} ({{ ((serverInfo.fs.total - serverInfo.fs.used) / serverInfo.fs.total * 100).toFixed(0) }}%)</div>
</div>
</div>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faExchangeAlt"/> {{ $t('network') }}</div>
<div class="_content" style="margin-top: -8px; margin-bottom: -12px;">
<canvas ref="net"></canvas>
</div>
<div class="_content" v-if="serverInfo">
<div class="table">
<div class="row">
<div class="cell"><div class="label">Interface</div>{{ serverInfo.net.interface }}</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faTachometerAlt, faExchangeAlt, faMicrochip, faHdd } from '@fortawesome/free-solid-svg-icons';
import Chart from 'chart.js';
import i18n from '../../i18n';
const alpha = (hex, a) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!;
const r = parseInt(result[1], 16);
const g = parseInt(result[2], 16);
const b = parseInt(result[3], 16);
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
export default Vue.extend({
i18n,
metaInfo() {
return {
title: `${this.$t('monitor')} | ${this.$t('instance')}`
};
},
components: {
},
data() {
return {
connection: null,
serverInfo: null,
memUsage: 0,
chartCpuMem: null,
chartNet: null,
faTachometerAlt, faExchangeAlt, faMicrochip, faHdd
}
},
mounted() {
Chart.defaults.global.defaultFontColor = getComputedStyle(document.documentElement).getPropertyValue('--fg');
this.chartCpuMem = new Chart(this.$refs.cpumem, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'CPU',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#86b300',
backgroundColor: alpha('#86b300', 0.1),
data: []
}, {
label: 'MEM (active)',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#935dbf',
backgroundColor: alpha('#935dbf', 0.02),
data: []
}, {
label: 'MEM (used)',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#935dbf',
borderDash: [5, 5],
fill: false,
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 0,
right: 0,
top: 8,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
xAxes: [{
gridLines: {
display: false
},
ticks: {
display: false
}
}],
yAxes: [{
position: 'right',
ticks: {
display: false,
max: 100
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
}
}
});
this.chartNet = new Chart(this.$refs.net, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'In',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#94a029',
backgroundColor: alpha('#94a029', 0.1),
data: []
}, {
label: 'Out',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#ff9156',
backgroundColor: alpha('#ff9156', 0.1),
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 0,
right: 0,
top: 8,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
xAxes: [{
gridLines: {
display: false
},
ticks: {
display: false
}
}],
yAxes: [{
position: 'right',
ticks: {
display: false,
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
}
}
});
this.chartDisk = new Chart(this.$refs.disk, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Read',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#94a029',
backgroundColor: alpha('#94a029', 0.1),
data: []
}, {
label: 'Write',
pointRadius: 0,
lineTension: 0,
borderWidth: 2,
borderColor: '#ff9156',
backgroundColor: alpha('#ff9156', 0.1),
data: []
}]
},
options: {
aspectRatio: 3,
layout: {
padding: {
left: 0,
right: 0,
top: 8,
bottom: 0
}
},
legend: {
position: 'bottom',
labels: {
boxWidth: 16,
}
},
scales: {
xAxes: [{
gridLines: {
display: false
},
ticks: {
display: false
}
}],
yAxes: [{
position: 'right',
ticks: {
display: false,
}
}]
},
tooltips: {
intersect: false,
mode: 'index',
}
}
});
this.$root.api('admin/server-info', {}).then(res => {
this.serverInfo = res;
this.connection = this.$root.stream.useSharedConnection('serverStats');
this.connection.on('stats', this.onStats);
this.connection.on('statsLog', this.onStatsLog);
this.connection.send('requestLog', {
id: Math.random().toString().substr(2, 8),
length: 150
});
});
},
beforeDestroy() {
this.connection.off('stats', this.onStats);
this.connection.off('statsLog', this.onStatsLog);
this.connection.dispose();
},
methods: {
onStats(stats) {
const cpu = (stats.cpu * 100).toFixed(0);
const memActive = (stats.mem.active / this.serverInfo.mem.total * 100).toFixed(0);
const memUsed = (stats.mem.used / this.serverInfo.mem.total * 100).toFixed(0);
this.memUsage = stats.mem.active;
this.chartCpuMem.data.labels.push('');
this.chartCpuMem.data.datasets[0].data.push(cpu);
this.chartCpuMem.data.datasets[1].data.push(memActive);
this.chartCpuMem.data.datasets[2].data.push(memUsed);
this.chartNet.data.labels.push('');
this.chartNet.data.datasets[0].data.push(stats.net.rx);
this.chartNet.data.datasets[1].data.push(stats.net.tx);
this.chartDisk.data.labels.push('');
this.chartDisk.data.datasets[0].data.push(stats.fs.r);
this.chartDisk.data.datasets[1].data.push(stats.fs.w);
if (this.chartCpuMem.data.datasets[0].data.length > 150) {
this.chartCpuMem.data.labels.shift();
this.chartCpuMem.data.datasets[0].data.shift();
this.chartCpuMem.data.datasets[1].data.shift();
this.chartCpuMem.data.datasets[2].data.shift();
this.chartNet.data.labels.shift();
this.chartNet.data.datasets[0].data.shift();
this.chartNet.data.datasets[1].data.shift();
this.chartDisk.data.labels.shift();
this.chartDisk.data.datasets[0].data.shift();
this.chartDisk.data.datasets[1].data.shift();
}
this.chartCpuMem.update();
this.chartNet.update();
this.chartDisk.update();
},
onStatsLog(statsLog) {
for (const stats of statsLog.reverse()) {
this.onStats(stats);
}
}
}
});
</script>
<style lang="scss" scoped>
.mk-instance-monitor {
> section {
> ._content {
> .table {
> .row {
display: flex;
&:not(:last-child) {
margin-bottom: 16px;
@media (max-width: 500px) {
margin-bottom: 8px;
}
}
> .cell {
flex: 1;
> .label {
font-size: 80%;
opacity: 0.7;
> .icon {
margin-right: 4px;
display: none;
}
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,370 @@
<template>
<div v-if="meta">
<portal to="icon"><fa :icon="faCog"/></portal>
<portal to="title">{{ $t('settings') }}</portal>
<section class="_card info">
<div class="_title"><fa :icon="faInfoCircle"/> {{ $t('basicInfo') }}</div>
<div class="_content">
<mk-input v-model="name">{{ $t('instanceName') }}</mk-input>
<mk-textarea v-model="description">{{ $t('instanceDescription') }}</mk-textarea>
<mk-input v-model="iconUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('iconUrl') }}</mk-input>
<mk-input v-model="bannerUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</mk-input>
<mk-input v-model="tosUrl"><template #icon><fa :icon="faLink"/></template>{{ $t('tosUrl') }}</mk-input>
<mk-input v-model="maintainerName">{{ $t('maintainerName') }}</mk-input>
<mk-input v-model="maintainerEmail" type="email"><template #icon><fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</mk-input>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card info">
<div class="_content">
<mk-input v-model="maxNoteTextLength" type="number" :save="() => save()" style="margin:0;"><template #icon><fa :icon="faPencilAlt"/></template>{{ $t('maxNoteTextLength') }}</mk-input>
</div>
<div class="_content">
<mk-switch v-model="enableLocalTimeline" @change="save()">{{ $t('enableLocalTimeline') }}</mk-switch>
<mk-switch v-model="enableGlobalTimeline" @change="save()">{{ $t('enableGlobalTimeline') }}</mk-switch>
<mk-info>{{ $t('disablingTimelinesInfo') }}</mk-info>
</div>
</section>
<section class="_card info">
<div class="_title"><fa :icon="faUser"/> {{ $t('registration') }}</div>
<div class="_content">
<mk-switch v-model="enableRegistration" @change="save()">{{ $t('enableRegistration') }}</mk-switch>
<mk-button v-if="!enableRegistration" @click="invite">{{ $t('invite') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faShieldAlt"/> {{ $t('recaptcha') }}</div>
<div class="_content">
<mk-switch v-model="enableRecaptcha">{{ $t('enableRecaptcha') }}</mk-switch>
<template v-if="enableRecaptcha">
<mk-input v-model="recaptchaSiteKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSiteKey') }}</mk-input>
<mk-input v-model="recaptchaSecretKey" :disabled="!enableRecaptcha"><template #icon><fa :icon="faKey"/></template>{{ $t('recaptchaSecretKey') }}</mk-input>
</template>
</div>
<div class="_content" v-if="enableRecaptcha && recaptchaSiteKey">
<header>{{ $t('preview') }}</header>
<div ref="recaptcha" style="margin: 16px 0 0 0;" :key="recaptchaSiteKey"></div>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faBolt"/> {{ $t('serviceworker') }}</div>
<div class="_content">
<mk-switch v-model="enableServiceWorker">{{ $t('enableServiceworker') }}<template #desc>{{ $t('serviceworkerInfo') }}</template></mk-switch>
<template v-if="enableServiceWorker">
<mk-horizon-group inputs class="fit-bottom">
<mk-input v-model="swPublicKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Public key</mk-input>
<mk-input v-model="swPrivateKey" :disabled="!enableServiceWorker"><template #icon><fa :icon="faKey"/></template>Private key</mk-input>
</mk-horizon-group>
</template>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faThumbtack"/> {{ $t('pinnedUsers') }}</div>
<div class="_content">
<mk-textarea v-model="pinnedUsers">
<template #desc>{{ $t('pinnedUsersDescription') }} <button class="_textButton" @click="addPinUser">{{ $t('addUser') }}</button></template>
</mk-textarea>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faCloud"/> {{ $t('files') }}</div>
<div class="_content">
<mk-switch v-model="cacheRemoteFiles">{{ $t('cacheRemoteFiles') }}<template #desc>{{ $t('cacheRemoteFilesDescription') }}</template></mk-switch>
<mk-switch v-model="proxyRemoteFiles">{{ $t('proxyRemoteFiles') }}<template #desc>{{ $t('proxyRemoteFilesDescription') }}</template></mk-switch>
<mk-input v-model="localDriveCapacityMb" type="number">{{ $t('driveCapacityPerLocalAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
<mk-input v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" style="margin-bottom: 0;">{{ $t('driveCapacityPerRemoteAccount') }}<template #suffix>MB</template><template #desc>{{ $t('inMb') }}</template></mk-input>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faGhost"/> {{ $t('proxyAccount') }}</div>
<div class="_content">
<mk-input :value="proxyAccount ? proxyAccount.username : null" style="margin: 0;" disabled><template #prefix>@</template>{{ $t('proxyAccount') }}<template #desc>{{ $t('proxyAccountDescription') }}</template></mk-input>
<mk-button primary @click="chooseProxyAccount">{{ $t('chooseProxyAccount') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faBan"/> {{ $t('blockedInstances') }}</div>
<div class="_content">
<mk-textarea v-model="blockedHosts">
<template #desc>{{ $t('blockedInstancesDescription') }}</template>
</mk-textarea>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
<section class="_card">
<div class="_title"><fa :icon="faShareAlt"/> {{ $t('integration') }}</div>
<div class="_content">
<header><fa :icon="faTwitter"/> Twitter</header>
<mk-switch v-model="enableTwitterIntegration">{{ $t('enable') }}</mk-switch>
<template v-if="enableTwitterIntegration">
<mk-info>Callback URL: {{ `${url}/api/tw/cb` }}</mk-info>
<mk-input v-model="twitterConsumerKey" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Key</mk-input>
<mk-input v-model="twitterConsumerSecret" :disabled="!enableTwitterIntegration"><template #icon><fa :icon="faKey"/></template>Consumer Secret</mk-input>
</template>
</div>
<div class="_content">
<header><fa :icon="faGithub"/> GitHub</header>
<mk-switch v-model="enableGithubIntegration">{{ $t('enable') }}</mk-switch>
<template v-if="enableGithubIntegration">
<mk-info>Callback URL: {{ `${url}/api/gh/cb` }}</mk-info>
<mk-input v-model="githubClientId" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input>
<mk-input v-model="githubClientSecret" :disabled="!enableGithubIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input>
</template>
</div>
<div class="_content">
<header><fa :icon="faDiscord"/> Discord</header>
<mk-switch v-model="enableDiscordIntegration">{{ $t('enable') }}</mk-switch>
<template v-if="enableDiscordIntegration">
<mk-info>Callback URL: {{ `${url}/api/dc/cb` }}</mk-info>
<mk-input v-model="discordClientId" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client ID</mk-input>
<mk-input v-model="discordClientSecret" :disabled="!enableDiscordIntegration"><template #icon><fa :icon="faKey"/></template>Client Secret</mk-input>
</template>
</div>
<div class="_footer">
<mk-button primary @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
</div>
</section>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faPencilAlt, faShareAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faThumbtack, faUser, faShieldAlt, faKey, faBolt } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faEnvelope } from '@fortawesome/free-regular-svg-icons';
import { faTwitter, faDiscord, faGithub } from '@fortawesome/free-brands-svg-icons';
import MkButton from '../../components/ui/button.vue';
import MkInput from '../../components/ui/input.vue';
import MkTextarea from '../../components/ui/textarea.vue';
import MkSwitch from '../../components/ui/switch.vue';
import MkInfo from '../../components/ui/info.vue';
import MkUserSelect from '../../components/user-select.vue';
import { url } from '../../config';
import i18n from '../../i18n';
import getAcct from '../../../misc/acct/render';
export default Vue.extend({
i18n,
metaInfo() {
return {
title: this.$t('instance') as string
};
},
components: {
MkButton,
MkInput,
MkTextarea,
MkSwitch,
MkInfo,
},
data() {
return {
url,
proxyAccount: null,
proxyAccountId: null,
cacheRemoteFiles: false,
proxyRemoteFiles: false,
localDriveCapacityMb: 0,
remoteDriveCapacityMb: 0,
blockedHosts: '',
pinnedUsers: '',
maintainerName: null,
maintainerEmail: null,
name: null,
description: null,
tosUrl: null,
bannerUrl: null,
iconUrl: null,
maxNoteTextLength: 0,
enableRegistration: false,
enableLocalTimeline: false,
enableGlobalTimeline: false,
enableRecaptcha: false,
recaptchaSiteKey: null,
recaptchaSecretKey: null,
enableServiceWorker: false,
swPublicKey: null,
swPrivateKey: null,
enableTwitterIntegration: false,
twitterConsumerKey: null,
twitterConsumerSecret: null,
enableGithubIntegration: false,
githubClientId: null,
githubClientSecret: null,
enableDiscordIntegration: false,
discordClientId: null,
discordClientSecret: null,
faPencilAlt, faTwitter, faDiscord, faGithub, faShareAlt, faTrashAlt, faGhost, faCog, faPlus, faCloud, faInfoCircle, faBan, faSave, faServer, faLink, faEnvelope, faThumbtack, faUser, faShieldAlt, faKey, faBolt
}
},
computed: {
meta() {
return this.$store.state.instance.meta;
},
},
created() {
this.name = this.meta.name;
this.description = this.meta.description;
this.tosUrl = this.meta.tosUrl;
this.bannerUrl = this.meta.bannerUrl;
this.iconUrl = this.meta.iconUrl;
this.maintainerName = this.meta.maintainerName;
this.maintainerEmail = this.meta.maintainerEmail;
this.maxNoteTextLength = this.meta.maxNoteTextLength;
this.enableRegistration = !this.meta.disableRegistration;
this.enableLocalTimeline = !this.meta.disableLocalTimeline;
this.enableGlobalTimeline = !this.meta.disableGlobalTimeline;
this.enableRecaptcha = this.meta.enableRecaptcha;
this.recaptchaSiteKey = this.meta.recaptchaSiteKey;
this.recaptchaSecretKey = this.meta.recaptchaSecretKey;
this.proxyAccountId = this.meta.proxyAccountId;
this.cacheRemoteFiles = this.meta.cacheRemoteFiles;
this.proxyRemoteFiles = this.meta.proxyRemoteFiles;
this.localDriveCapacityMb = this.meta.driveCapacityPerLocalUserMb;
this.remoteDriveCapacityMb = this.meta.driveCapacityPerRemoteUserMb;
this.blockedHosts = this.meta.blockedHosts.join('\n');
this.pinnedUsers = this.meta.pinnedUsers.join('\n');
this.enableServiceWorker = this.meta.enableServiceWorker;
this.swPublicKey = this.meta.swPublickey;
this.swPrivateKey = this.meta.swPrivateKey;
this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
this.twitterConsumerKey = this.meta.twitterConsumerKey;
this.twitterConsumerSecret = this.meta.twitterConsumerSecret;
this.enableGithubIntegration = this.meta.enableGithubIntegration;
this.githubClientId = this.meta.githubClientId;
this.githubClientSecret = this.meta.githubClientSecret;
this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
this.discordClientId = this.meta.discordClientId;
this.discordClientSecret = this.meta.discordClientSecret;
if (this.proxyAccountId) {
this.$root.api('users/show', { userId: this.proxyAccountId }).then(proxyAccount => {
this.proxyAccount = proxyAccount;
});
}
},
mounted() {
const renderRecaptchaPreview = () => {
if (!(window as any).grecaptcha) return;
if (!this.$refs.recaptcha) return;
if (!this.recaptchaSiteKey) return;
(window as any).grecaptcha.render(this.$refs.recaptcha, {
sitekey: this.recaptchaSiteKey
});
};
window.onRecaotchaLoad = () => {
renderRecaptchaPreview();
};
const head = document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.setAttribute('src', 'https://www.google.com/recaptcha/api.js?onload=onRecaotchaLoad');
head.appendChild(script);
this.$watch('enableRecaptcha', () => {
renderRecaptchaPreview();
});
this.$watch('recaptchaSiteKey', () => {
renderRecaptchaPreview();
});
},
methods: {
addPinUser() {
this.$root.new(MkUserSelect, {}).$once('selected', user => {
this.pinnedUsers = this.pinnedUsers.trim();
this.pinnedUsers += '\n@' + getAcct(user);
this.pinnedUsers = this.pinnedUsers.trim();
});
},
chooseProxyAccount() {
this.$root.new(MkUserSelect, {}).$once('selected', user => {
this.proxyAccount = user;
this.proxyAccountId = user.id;
this.save(true);
});
},
save(withDialog = false) {
this.$root.api('admin/update-meta', {
name: this.name,
description: this.description,
tosUrl: this.tosUrl,
bannerUrl: this.bannerUrl,
iconUrl: this.iconUrl,
maintainerName: this.maintainerName,
maintainerEmail: this.maintainerEmail,
maxNoteTextLength: this.maxNoteTextLength,
disableRegistration: !this.enableRegistration,
disableLocalTimeline: !this.enableLocalTimeline,
disableGlobalTimeline: !this.enableGlobalTimeline,
enableRecaptcha: this.enableRecaptcha,
recaptchaSiteKey: this.recaptchaSiteKey,
recaptchaSecretKey: this.recaptchaSecretKey,
proxyAccountId: this.proxyAccountId,
cacheRemoteFiles: this.cacheRemoteFiles,
proxyRemoteFiles: this.proxyRemoteFiles,
localDriveCapacityMb: parseInt(this.localDriveCapacityMb, 10),
remoteDriveCapacityMb: parseInt(this.remoteDriveCapacityMb, 10),
blockedHosts: this.blockedHosts.split('\n') || [],
pinnedUsers: this.pinnedUsers ? this.pinnedUsers.split('\n') : [],
enableServiceWorker: this.enableServiceWorker,
swPublicKey: this.swPublicKey,
swPrivateKey: this.swPrivateKey,
enableTwitterIntegration: this.enableTwitterIntegration,
twitterConsumerKey: this.twitterConsumerKey,
twitterConsumerSecret: this.twitterConsumerSecret,
enableGithubIntegration: this.enableGithubIntegration,
githubClientId: this.githubClientId,
githubClientSecret: this.githubClientSecret,
enableDiscordIntegration: this.enableDiscordIntegration,
discordClientId: this.discordClientId,
discordClientSecret: this.discordClientSecret,
}).then(() => {
this.$store.dispatch('instance/fetch');
if (withDialog) {
this.$root.dialog({
type: 'success',
iconOnly: true, autoClose: true
});
}
}).catch(e => {
this.$root.dialog({
type: 'error',
text: e
});
});
}
}
});
</script>

View File

@ -32,7 +32,7 @@
</router-link> </router-link>
</sequential-entrance> </sequential-entrance>
<div class="no-history" v-if="!fetching && messages.length == 0"> <div class="no-history" v-if="!fetching && messages.length == 0">
<img src="https://xn--931a.moe/assets/info.png" alt="" class="_ghost"/> <img src="https://xn--931a.moe/assets/info.png" class="_ghost"/>
<div>{{ $t('noHistory') }}</div> <div>{{ $t('noHistory') }}</div>
</div> </div>
<mk-loading v-if="fetching"/> <mk-loading v-if="fetching"/>

View File

@ -0,0 +1,109 @@
<template>
<div>
<portal to="icon"><fa :icon="faCog"/></portal>
<portal to="title">{{ $t('accountSettings') }}</portal>
<x-profile-setting/>
<x-privacy-setting/>
<x-reaction-setting/>
<section class="_card">
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
<div class="_content">
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
{{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template>
</mk-switch>
<mk-switch v-model="$store.state.i.injectFeaturedNote" @change="onChangeInjectFeaturedNote">
{{ $t('showFeaturedNotesInTimeline') }}
</mk-switch>
</div>
<div class="_content">
<mk-button @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</mk-button>
<mk-button @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</mk-button>
<mk-button @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</mk-button>
</div>
</section>
<x-import-export/>
<x-drive/>
<x-mute-block/>
<x-security/>
<x-2fa/>
<x-integration/>
<x-api/>
<mk-button @click="$root.signout()" primary style="margin: var(--margin) auto;">{{ $t('logout') }}</mk-button>
</div>
</template>
<script lang="ts">
import Vue from 'vue';
import { faCog } from '@fortawesome/free-solid-svg-icons';
import XProfileSetting from './profile.vue';
import XPrivacySetting from './privacy.vue';
import XImportExport from './import-export.vue';
import XDrive from './drive.vue';
import XReactionSetting from './reaction.vue';
import XMuteBlock from './mute-block.vue';
import XSecurity from './security.vue';
import X2fa from './2fa.vue';
import XIntegration from './integration.vue';
import XApi from './api.vue';
import MkButton from '../../components/ui/button.vue';
import MkSwitch from '../../components/ui/switch.vue';
export default Vue.extend({
metaInfo() {
return {
title: this.$t('settings') as string
};
},
components: {
XProfileSetting,
XPrivacySetting,
XImportExport,
XDrive,
XReactionSetting,
XMuteBlock,
XSecurity,
X2fa,
XIntegration,
XApi,
MkButton,
MkSwitch,
},
data() {
return {
faCog
}
},
methods: {
onChangeAutoWatch(v) {
this.$root.api('i/update', {
autoWatch: v
});
},
onChangeInjectFeaturedNote(v) {
this.$root.api('i/update', {
injectFeaturedNote: v
});
},
readAllUnreadNotes() {
this.$root.api('i/read_all_unread_notes');
},
readAllMessagingMessages() {
this.$root.api('i/read_all_messaging_messages');
},
readAllNotifications() {
this.$root.api('notifications/mark_all_as_read');
},
}
});
</script>

View File

@ -5,7 +5,7 @@
<section class="_card"> <section class="_card">
<div class="_content"> <div class="_content">
<img src="https://xn--931a.moe/assets/not-found.png" alt="" class="_ghost"/> <img src="https://xn--931a.moe/assets/not-found.png" class="_ghost"/>
<div>{{ $t('notFoundDescription') }}</div> <div>{{ $t('notFoundDescription') }}</div>
</div> </div>
</section> </section>

View File

@ -3,10 +3,20 @@
<portal to="avatar" v-if="note"><mk-avatar class="avatar" :user="note.user" :disable-preview="true"/></portal> <portal to="avatar" v-if="note"><mk-avatar class="avatar" :user="note.user" :disable-preview="true"/></portal>
<portal to="title" v-if="note">{{ $t('noteOf', { user: note.user.name }) }}</portal> <portal to="title" v-if="note">{{ $t('noteOf', { user: note.user.name }) }}</portal>
<transition name="zoom" mode="out-in"> <transition :name="$store.state.device.animation ? 'zoom' : ''" mode="out-in">
<x-note v-if="note" :note="note" :key="note.id" :detail="true"/> <div v-if="note">
<div v-else-if="error"> <mk-button v-if="hasNext && !showNext" @click="showNext = true" primary style="margin: 0 auto var(--margin) auto;"><fa :icon="faChevronUp"/></mk-button>
<mk-error @retry="fetch()"/> <x-notes v-if="showNext" ref="next" :pagination="next"/>
<hr v-if="showNext"/>
<x-note :note="note" :key="note.id" :detail="true"/>
<div v-if="error">
<mk-error @retry="fetch()"/>
</div>
<mk-button v-if="hasPrev && !showPrev" @click="showPrev = true" primary style="margin: var(--margin) auto 0 auto;"><fa :icon="faChevronDown"/></mk-button>
<hr v-if="showPrev"/>
<x-notes v-if="showPrev" ref="prev" :pagination="prev" style="margin-top: var(--margin);"/>
</div> </div>
</transition> </transition>
</div> </div>
@ -14,9 +24,12 @@
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faChevronUp, faChevronDown } from '@fortawesome/free-solid-svg-icons';
import i18n from '../i18n'; import i18n from '../i18n';
import Progress from '../scripts/loading'; import Progress from '../scripts/loading';
import XNote from '../components/note.vue'; import XNote from '../components/note.vue';
import XNotes from '../components/notes.vue';
import MkButton from '../components/ui/button.vue';
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -26,12 +39,36 @@ export default Vue.extend({
}; };
}, },
components: { components: {
XNote XNote,
XNotes,
MkButton,
}, },
data() { data() {
return { return {
note: null, note: null,
hasPrev: false,
hasNext: false,
showPrev: false,
showNext: false,
error: null, error: null,
prev: {
endpoint: 'users/notes',
limit: 10,
params: init => ({
userId: this.note.userId,
untilId: this.note.id,
})
},
next: {
reversed: true,
endpoint: 'users/notes',
limit: 10,
params: init => ({
userId: this.note.userId,
sinceId: this.note.id,
})
},
faChevronUp, faChevronDown
}; };
}, },
watch: { watch: {
@ -46,7 +83,22 @@ export default Vue.extend({
this.$root.api('notes/show', { this.$root.api('notes/show', {
noteId: this.$route.params.note noteId: this.$route.params.note
}).then(note => { }).then(note => {
this.note = note; Promise.all([
this.$root.api('users/notes', {
userId: note.userId,
untilId: note.id,
limit: 1,
}),
this.$root.api('users/notes', {
userId: note.userId,
sinceId: note.id,
limit: 1,
}),
]).then(([prev, next]) => {
this.hasPrev = prev.length !== 0;
this.hasNext = next.length !== 0;
this.note = note;
});
}).catch(e => { }).catch(e => {
this.error = e; this.error = e;
}).finally(() => { }).finally(() => {

View File

@ -1,177 +0,0 @@
<template>
<section class="_card">
<div class="_title"><fa :icon="faCog"/> {{ $t('general') }}</div>
<div class="_content">
<mk-input type="file" @change="onWallpaperChange">
<span>{{ $t('wallpaper') }}</span>
<template #icon><fa :icon="faImage"/></template>
<template #desc v-if="wallpaperUploading">{{ $t('uploading') }}<mk-ellipsis/></template>
</mk-input>
<mk-button primary :disabled="$store.state.settings.wallpaper == null" @click="delWallpaper()">{{ $t('removeWallpaper') }}</mk-button>
</div>
<div class="_content">
<mk-switch v-model="autoReload">
{{ $t('autoReloadWhenDisconnected') }}
</mk-switch>
<mk-switch v-model="$store.state.i.autoWatch" @change="onChangeAutoWatch">
{{ $t('autoNoteWatch') }}<template #desc>{{ $t('autoNoteWatchDescription') }}</template>
</mk-switch>
</div>
<div class="_content">
<mk-button @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</mk-button>
<mk-button @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</mk-button>
<mk-button @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</mk-button>
</div>
<div class="_content">
<mk-switch v-model="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</mk-switch>
<mk-switch v-model="reduceAnimation">{{ $t('reduceUiAnimation') }}</mk-switch>
<mk-switch v-model="useOsNativeEmojis">
{{ $t('useOsNativeEmojis') }}
<template #desc><mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template>
</mk-switch>
</div>
<div class="_content">
<mk-select v-model="lang">
<template #label>{{ $t('uiLanguage') }}</template>
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
</mk-select>
</div>
<div class="_content">
<div>{{ $t('fontSize') }}</div>
<mk-radio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></mk-radio>
<mk-radio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></mk-radio>
<mk-radio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></mk-radio>
<mk-radio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></mk-radio>
</div>
</section>
</template>
<script lang="ts">
import Vue from 'vue';
import { faImage, faCog } from '@fortawesome/free-solid-svg-icons';
import MkInput from '../../components/ui/input.vue';
import MkButton from '../../components/ui/button.vue';
import MkSwitch from '../../components/ui/switch.vue';
import MkSelect from '../../components/ui/select.vue';
import MkRadio from '../../components/ui/radio.vue';
import i18n from '../../i18n';
import { apiUrl, langs } from '../../config';
export default Vue.extend({
i18n,
components: {
MkInput,
MkButton,
MkSwitch,
MkSelect,
MkRadio,
},
data() {
return {
langs,
lang: localStorage.getItem('lang'),
fontSize: localStorage.getItem('fontSize'),
wallpaperUploading: false,
faImage, faCog
}
},
computed: {
wallpaper: {
get() { return this.$store.state.settings.wallpaper; },
set(value) { this.$store.dispatch('settings/set', { key: 'wallpaper', value }); }
},
autoReload: {
get() { return this.$store.state.device.autoReload; },
set(value) { this.$store.commit('device/set', { key: 'autoReload', value }); }
},
reduceAnimation: {
get() { return !this.$store.state.device.animation; },
set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); }
},
disableAnimatedMfm: {
get() { return !this.$store.state.device.animatedMfm; },
set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); }
},
useOsNativeEmojis: {
get() { return this.$store.state.device.useOsNativeEmojis; },
set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); }
},
},
watch: {
lang() {
localStorage.setItem('lang', this.lang);
localStorage.removeItem('locale');
location.reload();
},
fontSize() {
if (this.fontSize == null) {
localStorage.removeItem('fontSize');
} else {
localStorage.setItem('fontSize', this.fontSize);
}
location.reload();
}
},
methods: {
onWallpaperChange([file]) {
this.wallpaperUploading = true;
const data = new FormData();
data.append('file', file);
data.append('i', this.$store.state.i.token);
fetch(apiUrl + '/drive/files/create', {
method: 'POST',
body: data
})
.then(response => response.json())
.then(f => {
this.wallpaper = f.url;
this.wallpaperUploading = false;
document.documentElement.style.backgroundImage = `url(${this.$store.state.settings.wallpaper})`;
})
.catch(e => {
this.wallpaperUploading = false;
this.$root.dialog({
type: 'error',
text: e
});
});
},
delWallpaper() {
this.wallpaper = null;
document.documentElement.style.backgroundImage = 'none';
},
onChangeAutoWatch(v) {
this.$root.api('i/update', {
autoWatch: v
});
},
readAllUnreadNotes() {
this.$root.api('i/read_all_unread_notes');
},
readAllMessagingMessages() {
this.$root.api('i/read_all_messaging_messages');
},
readAllNotifications() {
this.$root.api('notifications/mark_all_as_read');
}
}
});
</script>

View File

@ -1,44 +1,61 @@
<template> <template>
<div class="mk-settings-page"> <div>
<portal to="icon"><fa :icon="faCog"/></portal> <portal to="icon"><fa :icon="faCog"/></portal>
<portal to="title">{{ $t('settings') }}</portal> <portal to="title">{{ $t('clinetSettings') }}</portal>
<x-profile-setting/>
<x-privacy-setting/>
<x-reaction-setting/>
<x-theme/> <x-theme/>
<x-import-export/>
<x-drive/>
<x-general/>
<x-mute-block/>
<x-security/>
<x-2fa/>
<x-integration/>
<x-api/>
<mk-button @click="cacheClear()" primary class="cacheClear">{{ $t('cacheClear') }}</mk-button> <section class="_card">
<mk-button @click="$root.signout()" primary class="logout">{{ $t('logout') }}</mk-button> <div class="_title"><fa :icon="faCog"/> {{ $t('accessibility') }}</div>
<div class="_content">
<mk-switch v-model="autoReload">
{{ $t('autoReloadWhenDisconnected') }}
</mk-switch>
</div>
<div class="_content">
<mk-switch v-model="imageNewTab">{{ $t('openImageInNewTab') }}</mk-switch>
<mk-switch v-model="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</mk-switch>
<mk-switch v-model="reduceAnimation">{{ $t('reduceUiAnimation') }}</mk-switch>
<mk-switch v-model="useOsNativeEmojis">
{{ $t('useOsNativeEmojis') }}
<template #desc><mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template>
</mk-switch>
</div>
<div class="_content">
<mk-select v-model="lang">
<template #label>{{ $t('uiLanguage') }}</template>
<option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option>
</mk-select>
</div>
<div class="_content">
<div>{{ $t('fontSize') }}</div>
<mk-radio v-model="fontSize" value="small"><span style="font-size: 14px;">Aa</span></mk-radio>
<mk-radio v-model="fontSize" :value="null"><span style="font-size: 16px;">Aa</span></mk-radio>
<mk-radio v-model="fontSize" value="large"><span style="font-size: 18px;">Aa</span></mk-radio>
<mk-radio v-model="fontSize" value="veryLarge"><span style="font-size: 20px;">Aa</span></mk-radio>
</div>
</section>
<mk-button @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</mk-button>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faCog } from '@fortawesome/free-solid-svg-icons'; import { faImage, faCog } from '@fortawesome/free-solid-svg-icons';
import XProfileSetting from './profile.vue'; import MkInput from '../../components/ui/input.vue';
import XPrivacySetting from './privacy.vue';
import XImportExport from './import-export.vue';
import XDrive from './drive.vue';
import XGeneral from './general.vue';
import XReactionSetting from './reaction.vue';
import XMuteBlock from './mute-block.vue';
import XSecurity from './security.vue';
import XTheme from './theme.vue';
import X2fa from './2fa.vue';
import XIntegration from './integration.vue';
import XApi from './api.vue';
import MkButton from '../../components/ui/button.vue'; import MkButton from '../../components/ui/button.vue';
import MkSwitch from '../../components/ui/switch.vue';
import MkSelect from '../../components/ui/select.vue';
import MkRadio from '../../components/ui/radio.vue';
import XTheme from './theme.vue';
import i18n from '../../i18n';
import { langs } from '../../config';
export default Vue.extend({ export default Vue.extend({
i18n,
metaInfo() { metaInfo() {
return { return {
title: this.$t('settings') as string title: this.$t('settings') as string
@ -46,27 +63,67 @@ export default Vue.extend({
}, },
components: { components: {
XProfileSetting,
XPrivacySetting,
XImportExport,
XDrive,
XGeneral,
XReactionSetting,
XMuteBlock,
XSecurity,
XTheme, XTheme,
X2fa, MkInput,
XIntegration,
XApi,
MkButton, MkButton,
MkSwitch,
MkSelect,
MkRadio,
}, },
data() { data() {
return { return {
faCog langs,
lang: localStorage.getItem('lang'),
fontSize: localStorage.getItem('fontSize'),
faImage, faCog
} }
}, },
computed: {
autoReload: {
get() { return this.$store.state.device.autoReload; },
set(value) { this.$store.commit('device/set', { key: 'autoReload', value }); }
},
reduceAnimation: {
get() { return !this.$store.state.device.animation; },
set(value) { this.$store.commit('device/set', { key: 'animation', value: !value }); }
},
disableAnimatedMfm: {
get() { return !this.$store.state.device.animatedMfm; },
set(value) { this.$store.commit('device/set', { key: 'animatedMfm', value: !value }); }
},
useOsNativeEmojis: {
get() { return this.$store.state.device.useOsNativeEmojis; },
set(value) { this.$store.commit('device/set', { key: 'useOsNativeEmojis', value }); }
},
imageNewTab: {
get() { return this.$store.state.device.imageNewTab; },
set(value) { this.$store.commit('device/set', { key: 'imageNewTab', value }); }
},
},
watch: {
lang() {
localStorage.setItem('lang', this.lang);
localStorage.removeItem('locale');
location.reload();
},
fontSize() {
if (this.fontSize == null) {
localStorage.removeItem('fontSize');
} else {
localStorage.setItem('fontSize', this.fontSize);
}
location.reload();
},
},
methods: { methods: {
cacheClear() { cacheClear() {
// Clear cache (service worker) // Clear cache (service worker)
@ -86,12 +143,3 @@ export default Vue.extend({
} }
}); });
</script> </script>
<style lang="scss" scoped>
.mk-settings-page {
> .logout,
> .cacheClear {
margin: 8px auto;
}
}
</style>

View File

@ -12,6 +12,10 @@
</optgroup> </optgroup>
</mk-select> </mk-select>
</div> </div>
<div class="_content">
<mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button>
<mk-button primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</mk-button>
</div>
</section> </section>
</template> </template>
@ -23,6 +27,7 @@ import MkButton from '../../components/ui/button.vue';
import MkSelect from '../../components/ui/select.vue'; import MkSelect from '../../components/ui/select.vue';
import i18n from '../../i18n'; import i18n from '../../i18n';
import { Theme, builtinThemes, applyTheme } from '../../theme'; import { Theme, builtinThemes, applyTheme } from '../../theme';
import { selectFile } from '../../scripts/select-file';
export default Vue.extend({ export default Vue.extend({
i18n, i18n,
@ -35,7 +40,7 @@ export default Vue.extend({
data() { data() {
return { return {
wallpaperUploading: false, wallpaper: localStorage.getItem('wallpaper'),
faPalette faPalette
} }
}, },
@ -66,11 +71,25 @@ export default Vue.extend({
watch: { watch: {
theme() { theme() {
applyTheme(this.themes.find(x => x.id === this.theme)); applyTheme(this.themes.find(x => x.id === this.theme));
},
wallpaper() {
if (this.wallpaper == null) {
localStorage.removeItem('wallpaper');
} else {
localStorage.setItem('wallpaper', this.wallpaper);
}
location.reload();
} }
}, },
methods: { methods: {
setWallpaper(e) {
selectFile(this, e.currentTarget || e.target, null, false).then(file => {
this.wallpaper = file.url;
});
},
} }
}); });
</script> </script>

View File

@ -1,9 +1,15 @@
<template> <template>
<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/> <div>
<portal to="icon"><fa :icon="faHashtag"/></portal>
<portal to="title">{{ $route.params.tag }}</portal>
<x-notes ref="notes" :pagination="pagination" @before="before" @after="after"/>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import Vue from 'vue'; import Vue from 'vue';
import { faHashtag } from '@fortawesome/free-solid-svg-icons';
import Progress from '../scripts/loading'; import Progress from '../scripts/loading';
import XNotes from '../components/notes.vue'; import XNotes from '../components/notes.vue';
@ -26,7 +32,8 @@ export default Vue.extend({
params: () => ({ params: () => ({
tag: this.$route.params.tag, tag: this.$route.params.tag,
}) })
} },
faHashtag
}; };
}, },

View File

@ -13,8 +13,8 @@
<mk-user-name class="name" :user="user" :nowrap="true"/> <mk-user-name class="name" :user="user" :nowrap="true"/>
<div class="bottom"> <div class="bottom">
<span class="username"><mk-acct :user="user" :detail="true" /></span> <span class="username"><mk-acct :user="user" :detail="true" /></span>
<span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span> <span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><fa :icon="faBookmark"/></span>
<span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')"><fa :icon="farBookmark"/></span> <span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><fa :icon="farBookmark"/></span>
<span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span> <span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span>
<span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span> <span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span>
</div> </div>
@ -30,8 +30,8 @@
<mk-user-name :user="user" :nowrap="false" class="name"/> <mk-user-name :user="user" :nowrap="false" class="name"/>
<div class="bottom"> <div class="bottom">
<span class="username"><mk-acct :user="user" :detail="true" /></span> <span class="username"><mk-acct :user="user" :detail="true" /></span>
<span v-if="user.isAdmin" :title="$t('isAdmin')"><fa :icon="faBookmark"/></span> <span v-if="user.isAdmin" :title="$t('isAdmin')" style="color: var(--badge);"><fa :icon="faBookmark"/></span>
<span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')"><fa :icon="farBookmark"/></span> <span v-if="!user.isAdmin && user.isModerator" :title="$t('isModerator')" style="color: var(--badge);"><fa :icon="farBookmark"/></span>
<span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span> <span v-if="user.isLocked" :title="$t('isLocked')"><fa :icon="faLock"/></span>
<span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span> <span v-if="user.isBot" :title="$t('isBot')"><fa :icon="faRobot"/></span>
</div> </div>
@ -250,6 +250,7 @@ export default Vue.extend({
background-size: cover; background-size: cover;
background-position: center; background-position: center;
box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset;
will-change: background-position;
} }
> .fade { > .fade {

View File

@ -38,20 +38,20 @@ export const router = new VueRouter({
{ path: '/my/pages', name: 'pages', component: page('pages') }, { path: '/my/pages', name: 'pages', component: page('pages') },
{ path: '/my/pages/new', component: page('page-editor/page-editor') }, { path: '/my/pages/new', component: page('page-editor/page-editor') },
{ path: '/my/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) }, { path: '/my/pages/edit/:pageId', component: page('page-editor/page-editor'), props: route => ({ initPageId: route.params.pageId }) },
{ path: '/my/settings', component: page('settings/index') }, { path: '/my/settings', component: page('my-settings/index') },
{ path: '/my/follow-requests', component: page('follow-requests') }, { path: '/my/follow-requests', component: page('follow-requests') },
{ path: '/my/lists', component: page('my-lists/index') }, { path: '/my/lists', component: page('my-lists/index') },
{ path: '/my/lists/:list', component: page('my-lists/list') }, { path: '/my/lists/:list', component: page('my-lists/list') },
{ path: '/my/groups', component: page('my-groups/index') }, { path: '/my/groups', component: page('my-groups/index') },
{ path: '/my/groups/:group', component: page('my-groups/group') }, { path: '/my/groups/:group', component: page('my-groups/group') },
{ path: '/my/antennas', component: page('my-antennas/index') }, { path: '/my/antennas', component: page('my-antennas/index') },
{ path: '/settings', component: page('settings/index') },
{ path: '/instance', component: page('instance/index') }, { path: '/instance', component: page('instance/index') },
{ path: '/instance/emojis', component: page('instance/emojis') }, { path: '/instance/emojis', component: page('instance/emojis') },
{ path: '/instance/users', component: page('instance/users') }, { path: '/instance/users', component: page('instance/users') },
{ path: '/instance/files', component: page('instance/files') }, { path: '/instance/files', component: page('instance/files') },
{ path: '/instance/monitor', component: page('instance/monitor') },
{ path: '/instance/queue', component: page('instance/queue') }, { path: '/instance/queue', component: page('instance/queue') },
{ path: '/instance/stats', component: page('instance/stats') }, { path: '/instance/settings', component: page('instance/settings') },
{ path: '/instance/federation', component: page('instance/federation') }, { path: '/instance/federation', component: page('instance/federation') },
{ path: '/instance/announcements', component: page('instance/announcements') }, { path: '/instance/announcements', component: page('instance/announcements') },
{ path: '/notes/:note', name: 'note', component: page('note') }, { path: '/notes/:note', name: 'note', component: page('note') },

View File

@ -1,32 +1,7 @@
import Vue from 'vue'; import Vue from 'vue';
import { getScrollPosition, onScrollTop } from './scroll';
function getScrollContainer(el: Element | null): Element | null { const SECOND_FETCH_LIMIT = 30;
if (el == null || el.tagName === 'BODY') return null;
const style = window.getComputedStyle(el);
if (style.getPropertyValue('overflow') === 'auto') {
return el;
} else {
return getScrollContainer(el.parentElement);
}
}
function getScrollPosition(el: Element | null): number {
const container = getScrollContainer(el);
return container == null ? window.scrollY : container.scrollTop;
}
function onScrollTop(el, cb) {
const container = getScrollContainer(el) || window;
const onScroll = ev => {
if (!document.body.contains(el)) return;
const pos = getScrollPosition(el);
if (pos === 0) {
cb();
container.removeEventListener('scroll', onscroll);
}
};
container.addEventListener('scroll', onScroll, { passive: true });
}
export default (opts) => ({ export default (opts) => ({
data() { data() {
@ -89,18 +64,18 @@ export default (opts) => ({
if (params && params.then) params = await params; if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await this.$root.api(endpoint, { await this.$root.api(endpoint, {
...params,
limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1, limit: this.pagination.noPaging ? (this.pagination.limit || 10) : (this.pagination.limit || 10) + 1,
...params }).then(items => {
}).then(x => { if (!this.pagination.noPaging && (items.length > (this.pagination.limit || 10))) {
if (!this.pagination.noPaging && (x.length === (this.pagination.limit || 10) + 1)) { items.pop();
x.pop(); this.items = this.pagination.reversed ? [...items].reverse() : items;
this.items = x;
this.more = true; this.more = true;
} else { } else {
this.items = x; this.items = this.pagination.reversed ? [...items].reverse() : items;
this.more = false; this.more = false;
} }
this.offset = x.length; this.offset = items.length;
this.inited = true; this.inited = true;
this.fetching = false; this.fetching = false;
if (opts.after) opts.after(this, null); if (opts.after) opts.after(this, null);
@ -118,23 +93,25 @@ export default (opts) => ({
if (params && params.then) params = await params; if (params && params.then) params = await params;
const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint; const endpoint = typeof this.pagination.endpoint === 'function' ? this.pagination.endpoint() : this.pagination.endpoint;
await this.$root.api(endpoint, { await this.$root.api(endpoint, {
limit: (this.pagination.limit || 10) + 1, ...params,
limit: SECOND_FETCH_LIMIT + 1,
...(this.pagination.offsetMode ? { ...(this.pagination.offsetMode ? {
offset: this.offset, offset: this.offset,
} : this.pagination.reversed ? {
sinceId: this.items[0].id,
} : { } : {
untilId: this.items[this.items.length - 1].id, untilId: this.items[this.items.length - 1].id,
}), }),
...params }).then(items => {
}).then(x => { if (items.length > SECOND_FETCH_LIMIT) {
if (x.length === (this.pagination.limit || 10) + 1) { items.pop();
x.pop(); this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.items = this.items.concat(x);
this.more = true; this.more = true;
} else { } else {
this.items = this.items.concat(x); this.items = this.pagination.reversed ? [...items].reverse().concat(this.items) : this.items.concat(items);
this.more = false; this.more = false;
} }
this.offset += x.length; this.offset += items.length;
this.moreFetching = false; this.moreFetching = false;
}, e => { }, e => {
this.moreFetching = false; this.moreFetching = false;

View File

@ -0,0 +1,27 @@
export function getScrollContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
const style = window.getComputedStyle(el);
if (style.getPropertyValue('overflow') === 'auto') {
return el;
} else {
return getScrollContainer(el.parentElement);
}
}
export function getScrollPosition(el: Element | null): number {
const container = getScrollContainer(el);
return container == null ? window.scrollY : container.scrollTop;
}
export function onScrollTop(el: Element, cb) {
const container = getScrollContainer(el) || window;
const onScroll = ev => {
if (!document.body.contains(el)) return;
const pos = getScrollPosition(el);
if (pos === 0) {
cb();
container.removeEventListener('scroll', onscroll);
}
};
container.addEventListener('scroll', onScroll, { passive: true });
}

View File

@ -13,7 +13,6 @@ const defaultSettings = {
defaultNoteLocalOnly: false, defaultNoteLocalOnly: false,
uploadFolder: null, uploadFolder: null,
pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
wallpaper: null,
memo: null, memo: null,
reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
}; };
@ -39,6 +38,7 @@ const defaultDeviceSettings = {
theme: 'light', theme: 'light',
animation: true, animation: true,
animatedMfm: true, animatedMfm: true,
imageNewTab: false,
userData: {}, userData: {},
}; };

View File

@ -128,6 +128,13 @@ a {
} }
} }
hr {
margin: var(--margin) 0 var(--margin) 0;
border: none;
height: 1px;
background: var(--divider);
}
#nprogress { #nprogress {
pointer-events: none; pointer-events: none;
position: absolute; position: absolute;

View File

@ -9,8 +9,8 @@ export type Theme = {
props: { [key: string]: string }; props: { [key: string]: string };
}; };
export const lightTheme: Theme = require('./themes/light.json5'); export const lightTheme: Theme = require('./themes/_light.json5');
export const darkTheme: Theme = require('./themes/dark.json5'); export const darkTheme: Theme = require('./themes/_dark.json5');
export const builtinThemes = [ export const builtinThemes = [
lightTheme, lightTheme,
@ -52,7 +52,7 @@ export function applyTheme(theme: Theme, persist = true) {
for (const tag of document.head.children) { for (const tag of document.head.children) {
if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') {
tag.setAttribute('content', props['accent']); tag.setAttribute('content', props['html']);
break; break;
} }
} }

View File

@ -15,11 +15,14 @@
bg: '#000', bg: '#000',
fg: '#c7d1d8', fg: '#c7d1d8',
fgHighlighted: ':lighten<3<@fg', fgHighlighted: ':lighten<3<@fg',
html: '@bg',
indicator: '@accent',
panel: '#111213', panel: '#111213',
shadow: 'rgba(0, 0, 0, 0.1)', shadow: 'rgba(0, 0, 0, 0.1)',
header: 'rgba(20, 20, 20, 0.75)', header: 'rgba(20, 20, 20, 0.75)',
navBg: '@panel', navBg: '@panel',
navFg: '@fg', navFg: '@fg',
navHoverFg: ':lighten<17<@fg',
navActive: '@accent', navActive: '@accent',
navIndicator: '@accent', navIndicator: '@accent',
link: '#44a4c1', link: '#44a4c1',
@ -46,6 +49,8 @@
inputBorder: '#959da2', inputBorder: '#959da2',
listItemHoverBg: 'rgba(255, 255, 255, 0.03)', listItemHoverBg: 'rgba(255, 255, 255, 0.03)',
driveFolderBg: ':alpha<0.3<@accent', driveFolderBg: ':alpha<0.3<@accent',
wallpaperOverlay: 'rgba(0, 0, 0, 0.5)',
badge: '#31b1ce',
bonzsgfz: ':alpha<0<@bg', bonzsgfz: ':alpha<0<@bg',
pcncwizz: ':darken<2<@panel', pcncwizz: ':darken<2<@panel',
vocsgcxy: 'rgba(0, 0, 0, 0.5)', vocsgcxy: 'rgba(0, 0, 0, 0.5)',

View File

@ -15,11 +15,14 @@
bg: '#fafafa', bg: '#fafafa',
fg: '#5c6a73', fg: '#5c6a73',
fgHighlighted: ':darken<3<@fg', fgHighlighted: ':darken<3<@fg',
html: '@bg',
indicator: '@accent',
panel: '#fff', panel: '#fff',
shadow: 'rgba(0, 0, 0, 0.1)', shadow: 'rgba(0, 0, 0, 0.1)',
header: 'rgba(255, 255, 255, 0.75)', header: 'rgba(255, 255, 255, 0.75)',
navBg: '@panel', navBg: '@panel',
navFg: '@fg', navFg: '@fg',
navHoverFg: ':darken<17<@fg',
navActive: '@accent', navActive: '@accent',
navIndicator: '@accent', navIndicator: '@accent',
link: '#44a4c1', link: '#44a4c1',
@ -46,6 +49,8 @@
inputBorder: '#dae0e4', inputBorder: '#dae0e4',
listItemHoverBg: 'rgba(0, 0, 0, 0.03)', listItemHoverBg: 'rgba(0, 0, 0, 0.03)',
driveFolderBg: ':alpha<0.3<@accent', driveFolderBg: ':alpha<0.3<@accent',
wallpaperOverlay: 'rgba(255, 255, 255, 0.5)',
badge: '#31b1ce',
bonzsgfz: ':alpha<0<@bg', bonzsgfz: ':alpha<0<@bg',
pcncwizz: ':darken<2<@panel', pcncwizz: ':darken<2<@panel',
vocsgcxy: 'rgba(255, 255, 255, 0.5)', vocsgcxy: 'rgba(255, 255, 255, 0.5)',

View File

@ -12,6 +12,7 @@
panel: '#1f1d30', panel: '#1f1d30',
bg: '#0f0e17', bg: '#0f0e17',
fg: '#b1bee3', fg: '#b1bee3',
html: '@accent',
renote: '@accent', renote: '@accent',
}, },
} }

View File

@ -0,0 +1,84 @@
<template>
<svg viewBox="0 0 21 7">
<rect v-for="record in data" class="day"
width="1" height="1"
:x="record.x" :y="record.date.weekday"
rx="1" ry="1"
fill="transparent">
<title>{{ record.date.year }}/{{ record.date.month + 1 }}/{{ record.date.day }}</title>
</rect>
<rect v-for="record in data" class="day"
:width="record.v" :height="record.v"
:x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)"
rx="1" ry="1"
:fill="record.color"
style="pointer-events: none;"/>
<rect class="today"
width="1" height="1"
:x="data[0].x" :y="data[0].date.weekday"
rx="1" ry="1"
fill="none"
stroke-width="0.1"
stroke="#f73520"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
export default Vue.extend({
props: ['data'],
created() {
for (const d of this.data) {
d.total = d.notes + d.replies + d.renotes;
}
const peak = Math.max.apply(null, this.data.map(d => d.total));
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
let x = 20;
this.data.slice().forEach((d, i) => {
d.x = x;
const date = new Date(year, month, day - i);
d.date = {
year: date.getFullYear(),
month: date.getMonth(),
day: date.getDate(),
weekday: date.getDay()
};
d.v = peak == 0 ? 0 : d.total / (peak / 2);
if (d.v > 1) d.v = 1;
const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170;
const cs = d.v * 100;
const cl = 15 + ((1 - d.v) * 80);
d.color = `hsl(${ch}, ${cs}%, ${cl}%)`;
if (d.date.weekday == 0) x--;
});
}
});
</script>
<style lang="scss" scoped>
svg {
display: block;
padding: 16px;
width: 100%;
box-sizing: border-box;
> rect {
transform-origin: center;
&.day {
&:hover {
fill: rgba(#000, 0.05);
}
}
}
}
</style>

View File

@ -0,0 +1,108 @@
<template>
<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" @mousedown.prevent="onMousedown">
<polyline
:points="pointsNote"
fill="none"
stroke-width="1"
stroke="#41ddde"/>
<polyline
:points="pointsReply"
fill="none"
stroke-width="1"
stroke="#f7796c"/>
<polyline
:points="pointsRenote"
fill="none"
stroke-width="1"
stroke="#a1de41"/>
<polyline
:points="pointsTotal"
fill="none"
stroke-width="1"
stroke="#555"
stroke-dasharray="2 2"/>
</svg>
</template>
<script lang="ts">
import Vue from 'vue';
import i18n from '../i18n';
function dragListen(fn) {
window.addEventListener('mousemove', fn);
window.addEventListener('mouseleave', dragClear.bind(null, fn));
window.addEventListener('mouseup', dragClear.bind(null, fn));
}
function dragClear(fn) {
window.removeEventListener('mousemove', fn);
window.removeEventListener('mouseleave', dragClear);
window.removeEventListener('mouseup', dragClear);
}
export default Vue.extend({
i18n,
props: ['data'],
data() {
return {
viewBoxX: 147,
viewBoxY: 60,
zoom: 1,
pos: 0,
pointsNote: null,
pointsReply: null,
pointsRenote: null,
pointsTotal: null
};
},
created() {
for (const d of this.data) {
d.total = d.notes + d.replies + d.renotes;
}
this.render();
},
methods: {
render() {
const peak = Math.max.apply(null, this.data.map(d => d.total));
if (peak != 0) {
const data = this.data.slice().reverse();
this.pointsNote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.notes / peak)) * this.viewBoxY}`).join(' ');
this.pointsReply = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' ');
this.pointsRenote = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.renotes / peak)) * this.viewBoxY}`).join(' ');
this.pointsTotal = data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' ');
}
},
onMousedown(e) {
const clickX = e.clientX;
const clickY = e.clientY;
const baseZoom = this.zoom;
const basePos = this.pos;
// 動かした時
dragListen(me => {
let moveLeft = me.clientX - clickX;
let moveTop = me.clientY - clickY;
this.zoom = baseZoom + (-moveTop / 20);
this.pos = basePos + moveLeft;
if (this.zoom < 1) this.zoom = 1;
if (this.pos > 0) this.pos = 0;
if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX);
this.render();
});
}
}
});
</script>
<style lang="scss" scoped>
svg {
display: block;
padding: 16px;
width: 100%;
box-sizing: border-box;
cursor: all-scroll;
}
</style>

View File

@ -0,0 +1,80 @@
<template>
<div>
<mk-container :show-header="props.design === 0" :naked="props.design === 2">
<template #header><fa :icon="faChartBar"/>{{ $t('_widgets.activity') }}</template>
<template #func><button @click="toggleView()" class="_button"><fa :icon="faSort"/></button></template>
<div>
<mk-loading v-if="fetching"/>
<template v-else>
<x-calendar v-show="props.view === 0" :data="[].concat(activity)"/>
<x-chart v-show="props.view === 1" :data="[].concat(activity)"/>
</template>
</div>
</mk-container>
</div>
</template>
<script lang="ts">
import { faChartBar, faSort } from '@fortawesome/free-solid-svg-icons';
import MkContainer from '../components/ui/container.vue';
import define from './define';
import i18n from '../i18n';
import XCalendar from './activity.calendar.vue';
import XChart from './activity.chart.vue';
export default define({
name: 'activity',
props: () => ({
design: 0,
view: 0
})
}).extend({
i18n,
components: {
MkContainer,
XCalendar,
XChart,
},
data() {
return {
fetching: true,
activity: null,
faChartBar, faSort
};
},
mounted() {
this.$root.api('charts/user/notes', {
userId: this.$store.state.i.id,
span: 'day',
limit: 7 * 21
}).then(activity => {
this.activity = activity.diffs.normal.map((_, i) => ({
total: activity.diffs.normal[i] + activity.diffs.reply[i] + activity.diffs.renote[i],
notes: activity.diffs.normal[i],
replies: activity.diffs.reply[i],
renotes: activity.diffs.renote[i]
}));
this.fetching = false;
});
},
methods: {
func() {
if (this.props.design == 2) {
this.props.design = 0;
} else {
this.props.design++;
}
this.save();
},
toggleView() {
if (this.props.view == 1) {
this.props.view = 0;
} else {
this.props.view++;
}
this.save();
}
}
});
</script>

View File

@ -7,3 +7,5 @@ Vue.component('mkw-calendar', () => import('./calendar.vue').then(m => m.default
Vue.component('mkw-rss', () => import('./rss.vue').then(m => m.default)); Vue.component('mkw-rss', () => import('./rss.vue').then(m => m.default));
Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default)); Vue.component('mkw-trends', () => import('./trends.vue').then(m => m.default));
Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default)); Vue.component('mkw-clock', () => import('./clock.vue').then(m => m.default));
Vue.component('mkw-activity', () => import('./activity.vue').then(m => m.default));
Vue.component('mkw-photos', () => import('./photos.vue').then(m => m.default));

View File

@ -83,7 +83,6 @@ export default define({
} }
.tl { .tl {
height: 100%;
background: var(--bg); background: var(--bg);
} }
} }

View File

@ -0,0 +1,116 @@
<template>
<div>
<mk-container :show-header="props.design === 0" :naked="props.design === 2" :class="$style.root" :data-melt="props.design == 2">
<template #header><fa :icon="faCamera"/>{{ $t('_widgets.photos') }}</template>
<div class="">
<mk-loading v-if="fetching"/>
<div v-else :class="$style.stream">
<div v-for="(image, i) in images" :key="i"
:class="$style.img"
:style="`background-image: url(${thumbnail(image)})`"
></div>
</div>
</div>
</mk-container>
</div>
</template>
<script lang="ts">
import { faCamera } from '@fortawesome/free-solid-svg-icons';
import MkContainer from '../components/ui/container.vue';
import define from './define';
import i18n from '../i18n';
import { getStaticImageUrl } from '../scripts/get-static-image-url';
export default define({
name: 'photos',
props: () => ({
design: 0,
})
}).extend({
i18n,
components: {
MkContainer,
},
data() {
return {
images: [],
fetching: true,
connection: null,
faCamera
};
},
mounted() {
this.connection = this.$root.stream.useSharedConnection('main');
this.connection.on('driveFileCreated', this.onDriveFileCreated);
this.$root.api('drive/stream', {
type: 'image/*',
limit: 9
}).then(images => {
this.images = images;
this.fetching = false;
});
},
beforeDestroy() {
this.connection.dispose();
},
methods: {
onDriveFileCreated(file) {
if (/^image\/.+$/.test(file.type)) {
this.images.unshift(file);
if (this.images.length > 9) this.images.pop();
}
},
func() {
if (this.props.design == 2) {
this.props.design = 0;
} else {
this.props.design++;
}
this.save();
},
thumbnail(image: any): string {
return this.$store.state.device.disableShowingAnimatedImages
? getStaticImageUrl(image.thumbnailUrl)
: image.thumbnailUrl;
},
}
});
</script>
<style lang="scss" module>
.root[data-melt] {
.stream {
padding: 0;
}
.img {
border: solid 4px transparent;
border-radius: 8px;
}
}
.stream {
display: flex;
justify-content: center;
flex-wrap: wrap;
padding: 8px;
.img {
flex: 1 1 33%;
width: 33%;
height: 80px;
box-sizing: border-box;
background-position: center center;
background-size: cover;
background-clip: content-box;
border: solid 2px transparent;
border-radius: 4px;
}
}
</style>

View File

@ -150,7 +150,6 @@ export default define({
} }
.tl { .tl {
height: 100%;
padding: 8px; padding: 8px;
background: var(--bg); background: var(--bg);
box-sizing: border-box; box-sizing: border-box;

View File

@ -79,10 +79,7 @@ export default define({
display: flex; display: flex;
align-items: center; align-items: center;
padding: 14px 16px; padding: 14px 16px;
border-bottom: solid 1px var(--divider);
&:not(:last-child) {
border-bottom: solid 1px var(--divider);
}
> .tag { > .tag {
flex: 1; flex: 1;

View File

@ -55,6 +55,8 @@ import { Clip } from '../models/entities/clip';
import { ClipNote } from '../models/entities/clip-note'; import { ClipNote } from '../models/entities/clip-note';
import { Antenna } from '../models/entities/antenna'; import { Antenna } from '../models/entities/antenna';
import { AntennaNote } from '../models/entities/antenna-note'; import { AntennaNote } from '../models/entities/antenna-note';
import { PromoNote } from '../models/entities/promo-note';
import { PromoRead } from '../models/entities/promo-read';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -140,6 +142,8 @@ export const entities = [
ClipNote, ClipNote,
Antenna, Antenna,
AntennaNote, AntennaNote,
PromoNote,
PromoRead,
ReversiGame, ReversiGame,
ReversiMatching, ReversiMatching,
...charts as any ...charts as any

View File

@ -0,0 +1,28 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, OneToOne } from 'typeorm';
import { Note } from './note';
import { User } from './user';
import { id } from '../id';
@Entity()
export class PromoNote {
@PrimaryColumn(id())
public noteId: Note['id'];
@OneToOne(type => Note, {
onDelete: 'CASCADE'
})
@JoinColumn()
public note: Note | null;
@Column('timestamp with time zone')
public expiresAt: Date;
//#region Denormalized fields
@Index()
@Column({
...id(),
comment: '[Denormalized]'
})
public userId: User['id'];
//#endregion
}

View File

@ -0,0 +1,35 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { Note } from './note';
import { User } from './user';
import { id } from '../id';
@Entity()
@Index(['userId', 'noteId'], { unique: true })
export class PromoRead {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone', {
comment: 'The created date of the PromoRead.'
})
public createdAt: Date;
@Index()
@Column(id())
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE'
})
@JoinColumn()
public user: User | null;
@Column(id())
public noteId: Note['id'];
@ManyToOne(type => Note, {
onDelete: 'CASCADE'
})
@JoinColumn()
public note: Note | null;
}

View File

@ -125,6 +125,11 @@ export class UserProfile {
}) })
public carefulBot: boolean; public carefulBot: boolean;
@Column('boolean', {
default: true,
})
public injectFeaturedNote: boolean;
@Column({ @Column({
...id(), ...id(),
nullable: true nullable: true

View File

@ -50,6 +50,8 @@ import { ClipRepository } from './repositories/clip';
import { ClipNote } from './entities/clip-note'; import { ClipNote } from './entities/clip-note';
import { AntennaRepository } from './repositories/antenna'; import { AntennaRepository } from './repositories/antenna';
import { AntennaNote } from './entities/antenna-note'; import { AntennaNote } from './entities/antenna-note';
import { PromoNote } from './entities/promo-note';
import { PromoRead } from './entities/promo-read';
export const Announcements = getRepository(Announcement); export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead); export const AnnouncementReads = getRepository(AnnouncementRead);
@ -102,3 +104,5 @@ export const Clips = getCustomRepository(ClipRepository);
export const ClipNotes = getRepository(ClipNote); export const ClipNotes = getRepository(ClipNote);
export const Antennas = getCustomRepository(AntennaRepository); export const Antennas = getCustomRepository(AntennaRepository);
export const AntennaNotes = getRepository(AntennaNote); export const AntennaNotes = getRepository(AntennaNote);
export const PromoNotes = getRepository(PromoNote);
export const PromoReads = getRepository(PromoRead);

View File

@ -196,6 +196,8 @@ export class NoteRepository extends Repository<Note> {
renoteId: note.renoteId, renoteId: note.renoteId,
mentions: note.mentions.length > 0 ? note.mentions : undefined, mentions: note.mentions.length > 0 ? note.mentions : undefined,
uri: note.uri || undefined, uri: note.uri || undefined,
_featuredId_: (note as any)._featuredId_ || undefined,
_prId_: (note as any)._prId_ || undefined,
...(opts.detail ? { ...(opts.detail ? {
reply: note.replyId ? this.pack(note.replyId, meId, { reply: note.replyId ? this.pack(note.replyId, meId, {

View File

@ -125,6 +125,14 @@ export class UserRepository extends Repository<User> {
return count > 0; return count > 0;
} }
public async getHasPendingReceivedFollowRequest(userId: User['id']): Promise<boolean> {
const count = await FollowRequests.count({
followeeId: userId
});
return count > 0;
}
public async pack( public async pack(
src: User['id'] | User, src: User['id'] | User,
me?: User['id'] | User | null | undefined, me?: User['id'] | User | null | undefined,
@ -219,6 +227,7 @@ export class UserRepository extends Repository<User> {
avatarId: user.avatarId, avatarId: user.avatarId,
bannerId: user.bannerId, bannerId: user.bannerId,
autoWatch: profile!.autoWatch, autoWatch: profile!.autoWatch,
injectFeaturedNote: profile!.injectFeaturedNote,
alwaysMarkNsfw: profile!.alwaysMarkNsfw, alwaysMarkNsfw: profile!.alwaysMarkNsfw,
carefulBot: profile!.carefulBot, carefulBot: profile!.carefulBot,
autoAcceptFollowed: profile!.autoAcceptFollowed, autoAcceptFollowed: profile!.autoAcceptFollowed,
@ -226,9 +235,7 @@ export class UserRepository extends Repository<User> {
hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id),
hasUnreadNotification: this.getHasUnreadNotification(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id),
pendingReceivedFollowRequestsCount: FollowRequests.count({ hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
followeeId: user.id
}),
integrations: profile!.integrations, integrations: profile!.integrations,
} : {}), } : {}),

View File

@ -0,0 +1,27 @@
import { User } from '../../../models/entities/user';
import { Brackets, SelectQueryBuilder } from 'typeorm';
export function generateRepliesQuery(q: SelectQueryBuilder<any>, me?: User | null) {
if (me == null) {
q.andWhere(new Brackets(qb => { qb
.where(`note.replyId IS NULL`) // 返信ではない
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where(`note.replyId IS NOT NULL`)
.andWhere('note.replyUserId = note.userId');
}));
}));
} else {
q.andWhere(new Brackets(qb => { qb
.where(`note.replyId IS NULL`) // 返信ではない
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
.where(`note.replyId IS NOT NULL`)
.andWhere('note.userId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
.where(`note.replyId IS NOT NULL`)
.andWhere('note.replyUserId = note.userId');
}));
}));
}
}

View File

@ -2,7 +2,7 @@ import { User } from '../../../models/entities/user';
import { Followings } from '../../../models'; import { Followings } from '../../../models';
import { Brackets, SelectQueryBuilder } from 'typeorm'; import { Brackets, SelectQueryBuilder } from 'typeorm';
export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: User) { export function generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: User | null) {
if (me == null) { if (me == null) {
q.andWhere(new Brackets(qb => { qb q.andWhere(new Brackets(qb => { qb
.where(`note.visibility = 'public'`) .where(`note.visibility = 'public'`)

View File

@ -0,0 +1,45 @@
import rndstr from 'rndstr';
import { Note } from '../../../models/entities/note';
import { User } from '../../../models/entities/user';
import { Notes, UserProfiles } from '../../../models';
import { generateMuteQuery } from './generate-mute-query';
import { ensure } from '../../../prelude/ensure';
// TODO: リアクション、Renote、返信などをしたートは除外する
export async function injectFeatured(timeline: Note[], user?: User | null) {
if (timeline.length < 5) return;
if (user) {
const profile = await UserProfiles.findOne(user.id).then(ensure);
if (!profile.injectFeaturedNote) return;
}
const max = 30;
const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで
const query = Notes.createQueryBuilder('note')
.addSelect('note.score')
.where('note.userHost IS NULL')
.andWhere(`note.score > 0`)
.andWhere(`note.createdAt > :date`, { date: new Date(Date.now() - day) })
.andWhere(`note.visibility = 'public'`)
.leftJoinAndSelect('note.user', 'user');
if (user) generateMuteQuery(query, user);
const notes = await query
.orderBy('note.score', 'DESC')
.take(max)
.getMany();
if (notes.length === 0) return;
// Pick random one
const featured = notes[Math.floor(Math.random() * notes.length)];
(featured as any)._featuredId_ = rndstr('a-z0-9', 8);
// Inject featured
timeline.splice(3, 0, featured);
}

View File

@ -0,0 +1,35 @@
import rndstr from 'rndstr';
import { Note } from '../../../models/entities/note';
import { User } from '../../../models/entities/user';
import { PromoReads, PromoNotes, Notes, Users } from '../../../models';
import { ensure } from '../../../prelude/ensure';
export async function injectPromo(timeline: Note[], user?: User | null) {
if (timeline.length < 5) return;
// TODO: readやexpireフィルタはクエリ側でやる
const reads = user ? await PromoReads.find({
userId: user.id
}) : [];
let promos = await PromoNotes.find();
promos = promos.filter(n => n.expiresAt.getTime() > Date.now());
promos = promos.filter(n => !reads.map(r => r.noteId).includes(n.noteId));
if (promos.length === 0) return;
// Pick random promo
const promo = promos[Math.floor(Math.random() * promos.length)];
const note = await Notes.findOne(promo.noteId).then(ensure);
// Join
note.user = await Users.findOne(note.userId).then(ensure);
(note as any)._prId_ = rndstr('a-z0-9', 8);
// Inject promo
timeline.splice(3, 0, note);
}

View File

@ -88,7 +88,6 @@ export async function signup(username: User['username'], password: UserProfile['
await transactionalEntityManager.save(new UserProfile({ await transactionalEntityManager.save(new UserProfile({
userId: account.id, userId: account.id,
autoAcceptFollowed: true, autoAcceptFollowed: true,
autoWatch: false,
password: hash, password: hash,
})); }));

View File

@ -5,6 +5,7 @@ import { ApiError } from './error';
import { App } from '../../models/entities/app'; import { App } from '../../models/entities/app';
import { SchemaType } from '../../misc/schema'; import { SchemaType } from '../../misc/schema';
// TODO: defaultが設定されている場合はその型も考慮する
type Params<T extends IEndpointMeta> = { type Params<T extends IEndpointMeta> = {
[P in keyof T['params']]: NonNullable<T['params']>[P]['transform'] extends Function [P in keyof T['params']]: NonNullable<T['params']>[P]['transform'] extends Function
? ReturnType<NonNullable<T['params']>[P]['transform']> ? ReturnType<NonNullable<T['params']>[P]['transform']>
@ -14,12 +15,12 @@ type Params<T extends IEndpointMeta> = {
export type Response = Record<string, any> | void; export type Response = Record<string, any> | void;
type executor<T extends IEndpointMeta> = type executor<T extends IEndpointMeta> =
(params: Params<T>, user: ILocalUser, app: App, file?: any, cleanup?: Function) => (params: Params<T>, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, app: App, file?: any, cleanup?: Function) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>; Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>) export default function <T extends IEndpointMeta>(meta: T, cb: executor<T>)
: (params: any, user: ILocalUser, app: App, file?: any) => Promise<any> { : (params: any, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, app: App, file?: any) => Promise<any> {
return (params: any, user: ILocalUser, app: App, file?: any) => { return (params: any, user: T['requireCredential'] extends true ? ILocalUser : ILocalUser | null, app: App, file?: any) => {
function cleanup() { function cleanup() {
fs.unlink(file.path, () => {}); fs.unlink(file.path, () => {});
} }

View File

@ -7,7 +7,7 @@ import { makePaginationQuery } from '../../common/make-pagination-query';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
requireCredential: true, requireCredential: true as const,
requireModerator: true, requireModerator: true,
params: { params: {

View File

@ -6,7 +6,7 @@ import { genId } from '../../../../../misc/gen-id';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
requireCredential: true, requireCredential: true as const,
requireModerator: true, requireModerator: true,
params: { params: {

View File

@ -7,7 +7,7 @@ import { ApiError } from '../../../error';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
requireCredential: true, requireCredential: true as const,
requireModerator: true, requireModerator: true,
params: { params: {

View File

@ -7,7 +7,7 @@ import { makePaginationQuery } from '../../../common/make-pagination-query';
export const meta = { export const meta = {
tags: ['admin'], tags: ['admin'],
requireCredential: true, requireCredential: true as const,
requireModerator: true, requireModerator: true,
params: { params: {

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