From 014440850014ee86d766bb07467c2970b17a1fc6 Mon Sep 17 00:00:00 2001 From: syuilo <Syuilotan@yahoo.co.jp> Date: Wed, 25 Nov 2020 21:31:34 +0900 Subject: [PATCH] nanka iroiro (#6853) * wip * Update maps.ts * wip * wip * wip * wip * Update base.vue * wip * wip * wip * wip * Update link.vue * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update privacy.vue * wip * wip * wip * wip * Update range.vue * wip * wip * wip * wip * Update profile.vue * wip * Update a.vue * Update index.vue * wip * Update sidebar.vue * wip * wip * Update account-info.vue * Update a.vue * wip * wip * Update sounds.vue * wip * wip * wip * wip * wip * wip * wip * wip * Update account-info.vue * Update account-info.vue * wip * wip * wip * Update d-persimmon.json5 * wip --- locales/ja-JP.yml | 51 +- .../1605585339718-instance-pinned-pages.ts | 2 +- migration/1605965516823-instance-images.ts | 16 + migration/1606191203881-no-crawle.ts | 16 + src/client/assets/sounds/syuilo/kick.mp3 | Bin 0 -> 15672 bytes src/client/assets/sounds/syuilo/snare.mp3 | Bin 0 -> 26121 bytes src/client/cold-storage.ts | 34 + src/client/components/form-dialog.vue | 63 +- src/client/components/form/base.vue | 56 ++ src/client/components/form/button.vue | 81 +++ src/client/components/form/form.scss | 34 + src/client/components/form/group.vue | 42 ++ src/client/components/form/input.vue | 306 +++++++++ src/client/components/form/key-value-view.vue | 30 + src/client/components/form/link.vue | 90 +++ src/client/components/form/pagination.vue | 42 ++ src/client/components/form/radios.vue | 106 ++++ src/client/components/form/range.vue | 122 ++++ src/client/components/form/select.vue | 147 +++++ src/client/components/form/switch.vue | 132 ++++ src/client/components/form/textarea.vue | 136 ++++ src/client/components/form/tuple.vue | 36 ++ src/client/components/media-image.vue | 2 +- src/client/components/media-video.vue | 2 +- .../components/taskmanager.api-window.vue | 5 +- src/client/components/taskmanager.vue | 3 +- src/client/components/timeline.vue | 3 +- src/client/components/ui/range.vue | 4 +- src/client/components/ui/switch.vue | 6 +- src/client/components/ui/textarea.vue | 3 +- src/client/init.ts | 11 +- src/client/os.ts | 10 +- src/client/pages/announcements.vue | 2 +- src/client/pages/instance/settings.vue | 8 + src/client/pages/messaging/messaging-room.vue | 3 +- src/client/pages/reversi/game.board.vue | 13 +- .../settings/{security.2fa.vue => 2fa.vue} | 13 + src/client/pages/settings/account-info.vue | 185 ++++++ src/client/pages/settings/api.vue | 27 +- src/client/pages/{ => settings}/apps.vue | 60 +- src/client/pages/settings/deck.vue | 90 +++ src/client/pages/settings/email-address.vue | 71 +++ src/client/pages/settings/email.vue | 52 ++ src/client/pages/settings/general.vue | 211 +++---- src/client/pages/settings/index.vue | 149 ++--- src/client/pages/settings/notifications.vue | 30 +- src/client/pages/settings/other.vue | 53 +- src/client/pages/settings/privacy.vue | 56 +- src/client/pages/settings/profile.vue | 240 +++++--- src/client/pages/settings/reaction.vue | 69 +-- src/client/pages/settings/security.vue | 85 ++- src/client/pages/settings/sidebar.vue | 56 +- src/client/pages/settings/sounds.vue | 208 +++---- src/client/pages/settings/theme.install.vue | 106 ++++ src/client/pages/settings/theme.manage.vue | 103 ++++ src/client/pages/settings/theme.vue | 581 ++++++++---------- src/client/pages/settings/word-mute.vue | 48 +- src/client/pages/user/follow-list.vue | 2 +- src/client/pages/user/index.activity.vue | 18 +- src/client/pages/user/index.photos.vue | 42 +- src/client/pages/user/index.vue | 570 ++++++++--------- src/client/pages/welcome.entrance.vue | 28 - src/client/router.ts | 3 +- src/client/scripts/sound.ts | 24 + src/client/scripts/theme.ts | 13 +- src/client/store.ts | 10 +- src/client/style.scss | 6 +- src/client/themes/_dark.json5 | 1 + src/client/themes/_light.json5 | 1 + src/client/themes/d-battery-saver.json5 | 18 - src/client/themes/d-black.json5 | 24 +- src/client/themes/d-blue.json5 | 29 - .../themes/{d-red.json5 => d-dark.json5} | 14 +- src/client/themes/d-green.json5 | 29 - src/client/themes/d-persimmon.json5 | 12 +- src/client/themes/l-apricot.json5 | 2 +- src/client/themes/l-blue.json5 | 21 - src/client/themes/l-green.json5 | 21 - .../themes/{l-white.json5 => l-light.json5} | 2 +- src/client/themes/l-red.json5 | 21 - src/client/ui/_common_/common.vue | 5 +- src/client/ui/visitor.vue | 202 +----- src/client/ui/visitor/a.vue | 357 +++++++++++ src/client/ui/visitor/b.vue | 372 +++++++++++ src/client/widgets/digital-clock.vue | 3 +- src/games/reversi/maps.ts | 16 + src/models/entities/meta.ts | 14 +- src/models/entities/note-reaction.ts | 2 + src/models/entities/user-profile.ts | 6 + src/models/repositories/drive-file.ts | 8 +- src/models/repositories/user.ts | 1 + src/server/api/endpoints/admin/update-meta.ts | 16 + src/server/api/endpoints/drive.ts | 2 +- src/server/api/endpoints/i/update.ts | 8 + src/server/api/endpoints/meta.ts | 2 + src/server/api/endpoints/users/stats.ts | 144 +++++ src/server/index.ts | 10 +- src/server/web/index.ts | 6 + src/server/web/views/clip.pug | 3 + src/server/web/views/note.pug | 6 +- src/server/web/views/page.pug | 3 + src/server/web/views/user.pug | 6 +- src/services/chart/charts/classes/drive.ts | 4 +- src/services/chart/charts/classes/instance.ts | 2 +- .../chart/charts/classes/per-user-drive.ts | 2 +- src/services/drive/add-file.ts | 2 +- 106 files changed, 4489 insertions(+), 1734 deletions(-) create mode 100644 migration/1605965516823-instance-images.ts create mode 100644 migration/1606191203881-no-crawle.ts create mode 100644 src/client/assets/sounds/syuilo/kick.mp3 create mode 100644 src/client/assets/sounds/syuilo/snare.mp3 create mode 100644 src/client/cold-storage.ts create mode 100644 src/client/components/form/base.vue create mode 100644 src/client/components/form/button.vue create mode 100644 src/client/components/form/form.scss create mode 100644 src/client/components/form/group.vue create mode 100644 src/client/components/form/input.vue create mode 100644 src/client/components/form/key-value-view.vue create mode 100644 src/client/components/form/link.vue create mode 100644 src/client/components/form/pagination.vue create mode 100644 src/client/components/form/radios.vue create mode 100644 src/client/components/form/range.vue create mode 100644 src/client/components/form/select.vue create mode 100644 src/client/components/form/switch.vue create mode 100644 src/client/components/form/textarea.vue create mode 100644 src/client/components/form/tuple.vue rename src/client/pages/settings/{security.2fa.vue => 2fa.vue} (96%) create mode 100644 src/client/pages/settings/account-info.vue rename src/client/pages/{ => settings}/apps.vue (63%) create mode 100644 src/client/pages/settings/deck.vue create mode 100644 src/client/pages/settings/email-address.vue create mode 100644 src/client/pages/settings/email.vue create mode 100644 src/client/pages/settings/theme.install.vue create mode 100644 src/client/pages/settings/theme.manage.vue create mode 100644 src/client/scripts/sound.ts delete mode 100644 src/client/themes/d-battery-saver.json5 delete mode 100644 src/client/themes/d-blue.json5 rename src/client/themes/{d-red.json5 => d-dark.json5} (65%) delete mode 100644 src/client/themes/d-green.json5 delete mode 100644 src/client/themes/l-blue.json5 delete mode 100644 src/client/themes/l-green.json5 rename src/client/themes/{l-white.json5 => l-light.json5} (95%) delete mode 100644 src/client/themes/l-red.json5 create mode 100644 src/client/ui/visitor/a.vue create mode 100644 src/client/ui/visitor/b.vue create mode 100644 src/server/api/endpoints/users/stats.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8a4d346bc2..e99d9b5350 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -127,6 +127,7 @@ cacheRemoteFilesDescription: "この設定を無効にすると、リモート flagAsBot: "Botとして設定" flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。" flagAsCat: "Catとして設定" +flagAsCatDescription: "このアカウントが猫であることを示す場合は、このフラグをオンにします。" autoAcceptFollowed: "フォロー中ユーザーからのフォロリクを自動承認" addAcount: "アカウント追加" loginFailed: "ログインに失敗しました" @@ -440,6 +441,7 @@ useOsNativeEmojis: "OSネイティブの絵文字を使用" youHaveNoGroups: "グループがありません" joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループを作成してください。" noHistory: "履歴はありません" +signinHistory: "ログイン履歴" disableAnimatedMfm: "動きのあるMFMを無効にする" doing: "やっています" category: "カテゴリ" @@ -492,6 +494,7 @@ none: "なし" showInPage: "ページで表示" popout: "ポップアウト" volume: "音量" +masterVolume: "マスター音量" details: "詳細" chooseEmoji: "絵文字を選択" unableToProcess: "操作を完了できません" @@ -564,7 +567,8 @@ useStarForReactionFallback: "リアクション絵文字が不明な場合、代 emailConfig: "メールサーバー設定" enableEmail: "メール配信機能を有効化する" emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" -email: "メールアドレス" +email: "メール" +emailAddress: "メールアドレス" smtpConfig: "SMTP サーバーの設定" smtpHost: "ホスト" smtpPort: "ポート" @@ -596,6 +600,7 @@ regenerateLoginTokenDescription: "ログインに使用される内部トーク setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。" fileIdOrUrl: "ファイルIDまたはURL" chatOpenBehavior: "チャットを開くときの動作" +behavior: "動作" sample: "サンプル" abuseReports: "通報" reportAbuse: "通報" @@ -619,6 +624,42 @@ createNew: "新規作成" optional: "任意" createNewClip: "新しいクリップを作成" public: "パブリック" +i18nInfo: "Misskeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。" +manageAccessTokens: "アクセストークンの管理" +accountInfo: "アカウント情報" +notesCount: "ノートの数" +repliesCount: "返信した数" +renotesCount: "Renoteした数" +repliedCount: "返信された数" +renotedCount: "Renoteされた数" +followingCount: "フォロー数" +followersCount: "フォロワー数" +sentReactionsCount: "リアクションした数" +receivedReactionsCount: "リアクションされた数" +pollVotesCount: "アンケートに投票した数" +pollVotedCount: "アンケートに投票された数" +yes: "はい" +no: "いいえ" +driveFilesCount: "ドライブのファイル数" +driveUsage: "ドライブ使用量" +noCrawle: "クローラーによるインデックスを拒否" +noCrawleDescription: "検索エンジンにあなたのユーザーページ、ノート、Pagesなどのコンテンツを登録(インデックス)しないよう要請します。" +lockedAccountInfo: "フォローを承認制にしても、ノートの公開範囲を「フォロワー」にしない限り、誰でもあなたのノートを見ることができます。" +alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする" +loadRawImages: "添付画像のサムネイルをオリジナル画質にする" +disableShowingAnimatedImages: "アニメーション画像を再生しない" +verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。" +notSet: "未設定" +emailVerified: "メールアドレスが確認されました" +noteFavoritesCount: "お気に入りノートの数" +pageLikesCount: "Pageにいいねした数" +pageLikedCount: "Pageにいいねされた数" +reversiCount: "リバーシの対局数" + +_nsfw: + respect: "閲覧注意のメディアは隠す" + ignore: "閲覧注意のメディアを隠さない" + force: "常にメディアを隠す" _mfm: cheatSheet: "MFMチートシート" @@ -745,6 +786,8 @@ _theme: manage: "テーマの管理" code: "テーマコード" installed: "{name}をインストールしました" + installedThemes: "インストールされたテーマ" + builtinThemes: "標準のテーマ" alreadyInstalled: "そのテーマは既にインストールされています" invalid: "テーマの形式が間違っています" make: "テーマを作る" @@ -820,6 +863,8 @@ _sfx: chatBg: "チャット(バックグラウンド)" antenna: "アンテナ受信" channel: "チャンネル通知" + reversiPutBlack: "リバーシ: 黒が打ったとき" + reversiPutWhite: "リバーシ: 白が打ったとき" _ago: unknown: "謎" @@ -999,7 +1044,9 @@ _profile: username: "ユーザー名" description: "自己紹介" youCanIncludeHashtags: "ハッシュタグを含めることができます。" - metadata: "補足情報" + metadata: "追加情報" + metadataEdit: "追加情報を編集" + metadataDescription: "プロフィールに表として4つまでの追加情報を表示することができます。" metadataLabel: "ラベル" metadataContent: "内容" diff --git a/migration/1605585339718-instance-pinned-pages.ts b/migration/1605585339718-instance-pinned-pages.ts index 2f0ebab235..f593461306 100644 --- a/migration/1605585339718-instance-pinned-pages.ts +++ b/migration/1605585339718-instance-pinned-pages.ts @@ -4,7 +4,7 @@ export class instancePinnedPages1605585339718 implements MigrationInterface { name = 'instancePinnedPages1605585339718' public async up(queryRunner: QueryRunner): Promise<void> { - await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{"/announcements", "/featured", "/channels", "/pages", "/explore", "/games/reversi", "/about-misskey"}'::varchar[]`); + await queryRunner.query(`ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{"/featured", "/channels", "/explore", "/pages", "/about-misskey"}'::varchar[]`); } public async down(queryRunner: QueryRunner): Promise<void> { diff --git a/migration/1605965516823-instance-images.ts b/migration/1605965516823-instance-images.ts new file mode 100644 index 0000000000..bf8d408563 --- /dev/null +++ b/migration/1605965516823-instance-images.ts @@ -0,0 +1,16 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class instanceImages1605965516823 implements MigrationInterface { + name = 'instanceImages1605965516823' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "meta" ADD "backgroundImageUrl" character varying(512)`); + await queryRunner.query(`ALTER TABLE "meta" ADD "logoImageUrl" character varying(512)`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "logoImageUrl"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "backgroundImageUrl"`); + } + +} diff --git a/migration/1606191203881-no-crawle.ts b/migration/1606191203881-no-crawle.ts new file mode 100644 index 0000000000..accc8f8fe2 --- /dev/null +++ b/migration/1606191203881-no-crawle.ts @@ -0,0 +1,16 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class noCrawle1606191203881 implements MigrationInterface { + name = 'noCrawle1606191203881' + + public async up(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "noCrawle" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."noCrawle" IS 'Whether reject index by crawler.'`); + } + + public async down(queryRunner: QueryRunner): Promise<void> { + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."noCrawle" IS 'Whether reject index by crawler.'`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "noCrawle"`); + } + +} diff --git a/src/client/assets/sounds/syuilo/kick.mp3 b/src/client/assets/sounds/syuilo/kick.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4e0e72091c6b5092c1ee9f3f352dcd276a343a47 GIT binary patch literal 15672 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 literal 0 HcmV?d00001 diff --git a/src/client/assets/sounds/syuilo/snare.mp3 b/src/client/assets/sounds/syuilo/snare.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..9244189c2d5e5c0cf1064196e92b0843d5dacad1 GIT binary patch literal 26121 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 I|H}gZ16%VVdjJ3c literal 0 HcmV?d00001 diff --git a/src/client/cold-storage.ts b/src/client/cold-storage.ts new file mode 100644 index 0000000000..1bee2313fa --- /dev/null +++ b/src/client/cold-storage.ts @@ -0,0 +1,34 @@ +// 常にメモリにロードしておく必要がないような設定情報を保管するストレージ + +const PREFIX = 'miux:'; + +export const defaultDeviceSettings = { + sound_masterVolume: 0.3, + sound_note: { type: 'syuilo/down', volume: 1 }, + sound_noteMy: { type: 'syuilo/up', volume: 1 }, + sound_notification: { type: 'syuilo/pope2', volume: 1 }, + sound_chat: { type: 'syuilo/pope1', volume: 1 }, + sound_chatBg: { type: 'syuilo/waon', volume: 1 }, + sound_antenna: { type: 'syuilo/triple', volume: 1 }, + sound_channel: { type: 'syuilo/square-pico', volume: 1 }, + sound_reversiPutBlack: { type: 'syuilo/kick', volume: 0.3 }, + sound_reversiPutWhite: { type: 'syuilo/snare', volume: 0.3 }, +}; + +export const device = { + get<T extends keyof typeof defaultDeviceSettings>(key: T): typeof defaultDeviceSettings[T] { + // TODO: indexedDBにする + // ただしその際はnullチェックではなくキー存在チェックにしないとダメ + // (indexedDBはnullを保存できるため、ユーザーが意図してnullを格納した可能性がある) + const value = localStorage.getItem(PREFIX + key); + if (value == null) { + return defaultDeviceSettings[key]; + } else { + return JSON.parse(value); + } + }, + + set(key: keyof typeof defaultDeviceSettings, value: any): any { + localStorage.setItem(PREFIX + key, JSON.stringify(value)); + }, +}; diff --git a/src/client/components/form-dialog.vue b/src/client/components/form-dialog.vue index 0dc02258af..add6b230d3 100644 --- a/src/client/components/form-dialog.vue +++ b/src/client/components/form-dialog.vue @@ -1,6 +1,6 @@ <template> <XModalWindow ref="dialog" - :width="400" + :width="450" :can-close="false" :with-ok-button="true" :ok-button-disabled="false" @@ -12,42 +12,61 @@ <template #header> {{ title }} </template> - <div class="xkpnjxcv _section"> - <label v-for="item in Object.keys(form).filter(item => !form[item].hidden)" :key="item"> - <MkInput v-if="form[item].type === 'number'" v-model:value="values[item]" type="number" :step="form[item].step || 1"> + <FormBase class="xkpnjxcv"> + <template v-for="item in Object.keys(form).filter(item => !form[item].hidden)"> + <FormInput v-if="form[item].type === 'number'" v-model:value="values[item]" type="number" :step="form[item].step || 1"> <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </MkInput> - <MkInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model:value="values[item]" type="text"> + </FormInput> + <FormInput v-else-if="form[item].type === 'string' && !form[item].multiline" v-model:value="values[item]" type="text"> <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </MkInput> - <MkTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model:value="values[item]"> + </FormInput> + <FormTextarea v-else-if="form[item].type === 'string' && form[item].multiline" v-model:value="values[item]"> <span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </MkTextarea> - <MkSwitch v-else-if="form[item].type === 'boolean'" v-model:value="values[item]"> + </FormTextarea> + <FormSwitch v-else-if="form[item].type === 'boolean'" v-model:value="values[item]"> <span v-text="form[item].label || item"></span> <template v-if="form[item].description" #desc>{{ form[item].description }}</template> - </MkSwitch> - </label> - </div> + </FormSwitch> + <FormSelect v-else-if="form[item].type === 'enum'" v-model:value="values[item]"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span></template> + <option v-for="item in form[item].enum" :value="item.value" :key="item.value">{{ item.label }}</option> + </FormSelect> + <FormRange v-else-if="form[item].type === 'range'" v-model:value="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step"> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $t('optional') }})</span></template> + <template v-if="form[item].description" #desc>{{ form[item].description }}</template> + </FormRange> + <FormButton v-else-if="form[item].type === 'button'" @click="form[item].action($event, values)"> + <span v-text="form[item].content || item"></span> + </FormButton> + </template> + </FormBase> </XModalWindow> </template> <script lang="ts"> import { defineComponent } from 'vue'; import XModalWindow from '@/components/ui/modal-window.vue'; -import MkInput from './ui/input.vue'; -import MkTextarea from './ui/textarea.vue'; -import MkSwitch from './ui/switch.vue'; +import FormBase from './form/base.vue'; +import FormInput from './form/input.vue'; +import FormTextarea from './form/textarea.vue'; +import FormSwitch from './form/switch.vue'; +import FormSelect from './form/select.vue'; +import FormRange from './form/range.vue'; +import FormButton from './form/button.vue'; export default defineComponent({ components: { XModalWindow, - MkInput, - MkTextarea, - MkSwitch, + FormBase, + FormInput, + FormTextarea, + FormSwitch, + FormSelect, + FormRange, + FormButton, }, props: { @@ -95,12 +114,6 @@ export default defineComponent({ <style lang="scss" scoped> .xkpnjxcv { - > label { - display: block; - &:not(:last-child) { - margin-bottom: 32px; - } - } } </style> diff --git a/src/client/components/form/base.vue b/src/client/components/form/base.vue new file mode 100644 index 0000000000..249b49c675 --- /dev/null +++ b/src/client/components/form/base.vue @@ -0,0 +1,56 @@ +<template> +<div class="rbusrurv" :class="{ wide: forceWide }" v-size="{ max: [400] }"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + forceWide: { + type: Boolean, + required: false, + default: false, + } + } +}); +</script> + +<style lang="scss" scoped> +.rbusrurv { + line-height: 1.4em; + background: var(--bg); + padding: 32px; + + &:not(.wide).max-width_400px { + padding: 32px 0; + + > ::v-deep(*) { + ._formPanel { + border: solid 0.5px var(--divider); + border-radius: 0; + border-left: none; + border-right: none; + } + + ._form_group { + > * { + &:not(:first-child) { + &._formPanel, ._formPanel { + border-top: none; + } + } + + &:not(:last-child) { + &._formPanel, ._formPanel { + border-bottom: solid 0.5px var(--divider); + } + } + } + } + } + } +} +</style> diff --git a/src/client/components/form/button.vue b/src/client/components/form/button.vue new file mode 100644 index 0000000000..b4f0890945 --- /dev/null +++ b/src/client/components/form/button.vue @@ -0,0 +1,81 @@ +<template> +<div class="yzpgjkxe _formItem"> + <div class="_formLabel"><slot name="label"></slot></div> + <button class="main _button _formPanel _formClickable" :class="{ center, primary, danger }"> + <slot></slot> + <div class="suffix"> + <slot name="suffix"></slot> + <div class="icon"> + <slot name="suffixIcon"></slot> + </div> + </div> + </button> + <div class="_formCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './form.scss'; + +export default defineComponent({ + props: { + primary: { + type: Boolean, + required: false, + default: false, + }, + danger: { + type: Boolean, + required: false, + default: false, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + center: { + type: Boolean, + required: false, + default: true, + } + }, +}); +</script> + +<style lang="scss" scoped> +.yzpgjkxe { + > .main { + display: flex; + width: 100%; + box-sizing: border-box; + padding: 14px 16px; + text-align: left; + align-items: center; + + &.center { + display: block; + text-align: center; + } + + &.primary { + color: var(--accent); + } + + &.danger { + color: #ff2a2a; + } + + > .suffix { + display: inline-flex; + margin-left: auto; + opacity: 0.7; + + > .icon { + margin-left: 1em; + } + } + } +} +</style> diff --git a/src/client/components/form/form.scss b/src/client/components/form/form.scss new file mode 100644 index 0000000000..b541bf826d --- /dev/null +++ b/src/client/components/form/form.scss @@ -0,0 +1,34 @@ +._formPanel { + background: var(--panel); + border-radius: var(--radius); + + &._formClickable { + &:hover { + background: var(--panelHighlight); + } + } +} + +._formLabel { + font-size: 80%; + padding: 0 16px 8px 16px; + + &:empty { + display: none; + } +} + +._formCaption { + font-size: 80%; + padding: 8px 16px 0 16px; + + &:empty { + display: none; + } +} + +._formItem { + & + ._formItem { + margin-top: 24px; + } +} diff --git a/src/client/components/form/group.vue b/src/client/components/form/group.vue new file mode 100644 index 0000000000..d07852155a --- /dev/null +++ b/src/client/components/form/group.vue @@ -0,0 +1,42 @@ +<template> +<div class="vrtktovg _formItem" v-size="{ max: [500] }"> + <div class="_formLabel"><slot name="label"></slot></div> + <div class="main _form_group"> + <slot></slot> + </div> + <div class="_formCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ +}); +</script> + +<style lang="scss" scoped> +.vrtktovg { + > .main { + > ::v-deep(*) { + margin: 0; + + &:not(:first-child) { + &._formPanel, ._formPanel { + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + + &:not(:last-child) { + &._formPanel, ._formPanel { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + } + } + } +} +</style> diff --git a/src/client/components/form/input.vue b/src/client/components/form/input.vue new file mode 100644 index 0000000000..89551a5fc2 --- /dev/null +++ b/src/client/components/form/input.vue @@ -0,0 +1,306 @@ +<template> +<div class="ztzhwixg _formItem" :class="{ inline, disabled }"> + <div class="_formLabel"><slot></slot></div> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _formPanel"> + <div class="prefix" ref="prefixEl"><slot name="prefix"></slot></div> + <input v-if="debounce" ref="inputEl" + v-debounce="500" + :type="type" + v-model.lazy="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <input v-else ref="inputEl" + :type="type" + v-model="v" + :disabled="disabled" + :required="required" + :readonly="readonly" + :placeholder="placeholder" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="spellcheck" + :step="step" + @focus="focused = true" + @blur="focused = false" + @keydown="onKeydown($event)" + @input="onInput" + :list="id" + > + <datalist :id="id" v-if="datalist"> + <option v-for="data in datalist" :value="data"/> + </datalist> + <div class="suffix" ref="suffixEl"><slot name="suffix"></slot></div> + </div> + <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> + <div class="_formCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; +import debounce from 'v-debounce'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import './form.scss'; + +export default defineComponent({ + directives: { + debounce + }, + props: { + value: { + required: false + }, + type: { + type: String, + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + placeholder: { + type: String, + required: false + }, + autofocus: { + type: Boolean, + required: false, + default: false + }, + autocomplete: { + required: false + }, + spellcheck: { + required: false + }, + step: { + required: false + }, + debounce: { + required: false + }, + datalist: { + type: Array, + required: false, + }, + inline: { + type: Boolean, + required: false, + default: false + }, + save: { + type: Function, + required: false, + }, + }, + emits: ['change', 'keydown', 'enter'], + setup(props, context) { + const { value, type, autofocus } = toRefs(props); + const v = ref(value.value); + const id = Math.random().toString(); // TODO: uuid? + const focused = ref(false); + const changed = ref(false); + const invalid = ref(false); + const filled = computed(() => v.value !== '' && v.value != null); + const inputEl = ref(null); + const prefixEl = ref(null); + const suffixEl = ref(null); + + const focus = () => inputEl.value.focus(); + const onInput = (ev) => { + changed.value = true; + context.emit('change', ev); + }; + const onKeydown = (ev: KeyboardEvent) => { + context.emit('keydown', ev); + + if (ev.code === 'Enter') { + context.emit('enter'); + } + }; + + watch(value, newValue => { + v.value = newValue; + }); + + watch(v, newValue => { + if (type?.value === 'number') { + context.emit('update:value', parseFloat(newValue)); + } else { + context.emit('update:value', newValue); + } + + invalid.value = inputEl.value.validity.badInput; + }); + + onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + const clock = setInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100); + + onUnmounted(() => { + clearInterval(clock); + }); + }); + }); + + return { + id, + v, + focused, + invalid, + changed, + filled, + inputEl, + prefixEl, + suffixEl, + focus, + onInput, + onKeydown, + faExclamationCircle, + }; + }, +}); +</script> + +<style lang="scss" scoped> +.ztzhwixg { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + $height: 52px; + position: relative; + + > input { + display: block; + height: $height; + width: 100%; + margin: 0; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + line-height: $height; + color: var(--inputText); + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + box-sizing: border-box; + + &[type='file'] { + display: none; + } + } + + > .prefix, + > .suffix { + display: block; + position: absolute; + z-index: 1; + top: 0; + padding: 0 16px; + font-size: 1em; + line-height: $height; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: inline-block; + min-width: 16px; + max-width: 150px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + > .prefix { + left: 0; + padding-right: 8px; + } + + > .suffix { + right: 0; + padding-left: 8px; + } + } + + > .save { + margin: 6px 0 0 0; + font-size: 0.8em; + } + + &.inline { + display: inline-block; + margin: 0; + } + + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; + } + } +} +</style> diff --git a/src/client/components/form/key-value-view.vue b/src/client/components/form/key-value-view.vue new file mode 100644 index 0000000000..eadc675f89 --- /dev/null +++ b/src/client/components/form/key-value-view.vue @@ -0,0 +1,30 @@ +<template> +<div class="_formItem"> + <div class="_formPanel anocepby"> + <span class="key"><slot name="key"></slot></span> + <span class="value"><slot name="value"></slot></span> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './form.scss'; + +export default defineComponent({ + +}); +</script> + +<style lang="scss" scoped> +.anocepby { + display: flex; + align-items: center; + padding: 14px 16px; + + > .value { + margin-left: auto; + opacity: 0.7; + } +} +</style> diff --git a/src/client/components/form/link.vue b/src/client/components/form/link.vue new file mode 100644 index 0000000000..01c46e851a --- /dev/null +++ b/src/client/components/form/link.vue @@ -0,0 +1,90 @@ +<template> +<div class="qmfkfnzi _formItem"> + <a class="main _button _formPanel _formClickable" :href="to" target="_blank" v-if="external"> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <Fa :icon="faExternalLinkAlt" class="right"/> + </a> + <MkA class="main _button _formPanel _formClickable" :class="{ active }" :to="to" v-else> + <span class="icon"><slot name="icon"></slot></span> + <span class="text"><slot></slot></span> + <Fa :icon="faChevronRight" class="right"/> + </MkA> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faChevronRight, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'; +import './form.scss'; + +export default defineComponent({ + props: { + to: { + type: String, + required: true + }, + active: { + type: Boolean, + required: false + }, + external: { + type: Boolean, + required: false + }, + }, + data() { + return { + faChevronRight, faExternalLinkAlt + }; + } +}); +</script> + +<style lang="scss" scoped> +.qmfkfnzi { + > .main { + display: flex; + align-items: center; + width: 100%; + box-sizing: border-box; + padding: 14px 16px 14px 14px; + + &:hover { + text-decoration: none; + } + + &.active { + color: var(--accent); + } + + > .icon { + width: 32px; + margin-right: 2px; + flex-shrink: 0; + text-align: center; + opacity: 0.8; + + &:empty { + display: none; + + & + .text { + padding-left: 4px; + } + } + } + + > .text { + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 12px; + } + + > .right { + margin-left: auto; + opacity: 0.7; + } + } +} +</style> diff --git a/src/client/components/form/pagination.vue b/src/client/components/form/pagination.vue new file mode 100644 index 0000000000..7dcaedf9bf --- /dev/null +++ b/src/client/components/form/pagination.vue @@ -0,0 +1,42 @@ +<template> +<FormGroup class="uljviswt _formItem"> + <template #label><slot name="label"></slot></template> + <slot :items="items"></slot> + <div class="empty" v-if="empty" key="_empty_"> + <slot name="empty"></slot> + </div> + <FormButton v-show="more" class="button" @click="fetchMore" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }" primary> + <template v-if="!moreFetching">{{ $t('loadMore') }}</template> + <template v-if="moreFetching"><MkLoading inline/></template> + </FormButton> +</FormGroup> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import FormButton from './button.vue'; +import FormGroup from './group.vue'; +import paging from '@/scripts/paging'; + +export default defineComponent({ + components: { + FormButton, + FormGroup, + }, + + mixins: [ + paging({}), + ], + + props: { + pagination: { + required: true + }, + }, +}); +</script> + +<style lang="scss" scoped> +.uljviswt { +} +</style> diff --git a/src/client/components/form/radios.vue b/src/client/components/form/radios.vue new file mode 100644 index 0000000000..4c7f405cac --- /dev/null +++ b/src/client/components/form/radios.vue @@ -0,0 +1,106 @@ +<script lang="ts"> +import { defineComponent, h } from 'vue'; +import MkRadio from '@/components/ui/radio.vue'; +import './form.scss'; + +export default defineComponent({ + components: { + MkRadio + }, + props: { + modelValue: { + required: false + }, + }, + data() { + return { + value: this.modelValue, + } + }, + watch: { + value() { + this.$emit('update:modelValue', this.value); + } + }, + render() { + const label = this.$slots.desc(); + const options = this.$slots.default(); + + return h('div', { + class: 'cnklmpwm _formItem' + }, [ + h('div', { + class: '_formLabel', + }, label), + ...options.map(option => h('button', { + class: '_button _formPanel _formClickable', + key: option.props.value, + onClick: () => this.value = option.props.value, + }, [h('span', { + class: ['check', { checked: this.value === option.props.value }], + }), option.children])) + ]); + } +}); +</script> + +<style lang="scss"> +.cnklmpwm { + > button { + display: block; + width: 100%; + box-sizing: border-box; + padding: 14px 18px; + text-align: left; + + &:not(:first-of-type) { + border-top: none !important; + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &:not(:last-of-type) { + border-bottom: solid 0.5px var(--divider); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + + > .check { + display: inline-block; + vertical-align: bottom; + position: relative; + width: 20px; + height: 20px; + margin-right: 8px; + background: none; + border: 2px solid var(--inputBorder); + border-radius: 100%; + transition: inherit; + + &:after { + content: ""; + display: block; + position: absolute; + top: 3px; + right: 3px; + bottom: 3px; + left: 3px; + border-radius: 100%; + opacity: 0; + transform: scale(0); + transition: .4s cubic-bezier(.25,.8,.25,1); + } + + &.checked { + border-color: var(--accent); + + &:after { + background-color: var(--accent); + transform: scale(1); + opacity: 1; + } + } + } + } +} +</style> diff --git a/src/client/components/form/range.vue b/src/client/components/form/range.vue new file mode 100644 index 0000000000..3452184c55 --- /dev/null +++ b/src/client/components/form/range.vue @@ -0,0 +1,122 @@ +<template> +<div class="ifitouly _formItem" :class="{ focused, disabled }"> + <div class="_formLabel"><slot name="label"></slot></div> + <div class="_formPanel main"> + <input + type="range" + ref="input" + v-model="v" + :disabled="disabled" + :min="min" + :max="max" + :step="step" + @focus="focused = true" + @blur="focused = false" + @input="$emit('update:value', $event.target.value)" + /> + </div> + <div class="_formCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ + props: { + value: { + type: Number, + required: false, + default: 0 + }, + disabled: { + type: Boolean, + required: false, + default: false + }, + min: { + type: Number, + required: false, + default: 0 + }, + max: { + type: Number, + required: false, + default: 100 + }, + step: { + type: Number, + required: false, + default: 1 + }, + }, + data() { + return { + v: this.value, + focused: false + }; + }, + watch: { + value(v) { + this.v = parseFloat(v); + } + }, +}); +</script> + +<style lang="scss" scoped> +.ifitouly { + position: relative; + + > .main { + padding: 24px 16px; + + > input { + display: block; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: var(--X10); + height: 4px; + width: 100%; + box-sizing: border-box; + margin: 0; + outline: 0; + border: 0; + border-radius: 7px; + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + box-sizing: content-box; + } + + &::-moz-range-thumb { + -moz-appearance: none; + appearance: none; + cursor: pointer; + width: 20px; + height: 20px; + display: block; + border-radius: 50%; + border: none; + background: var(--accent); + box-shadow: 0 0 6px rgba(0, 0, 0, 0.3); + } + } + } +} +</style> diff --git a/src/client/components/form/select.vue b/src/client/components/form/select.vue new file mode 100644 index 0000000000..b865372f56 --- /dev/null +++ b/src/client/components/form/select.vue @@ -0,0 +1,147 @@ +<template> +<div class="yrtfrpux _formItem" :class="{ disabled, inline }"> + <div class="_formLabel"><slot name="label"></slot></div> + <div class="icon" ref="icon"><slot name="icon"></slot></div> + <div class="input _formPanel _formClickable" @click="focus"> + <div class="prefix" ref="prefix"><slot name="prefix"></slot></div> + <select ref="input" + v-model="v" + :required="required" + :disabled="disabled" + @focus="focused = true" + @blur="focused = false" + > + <slot></slot> + </select> + <div class="suffix"> + <Fa :icon="faChevronDown"/> + </div> + </div> + <div class="_formCaption"><slot name="caption"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import './form.scss'; + +export default defineComponent({ + props: { + value: { + required: false + }, + required: { + type: Boolean, + required: false + }, + disabled: { + type: Boolean, + required: false + }, + inline: { + type: Boolean, + required: false, + default: false + }, + }, + data() { + return { + faChevronDown, + }; + }, + computed: { + v: { + get() { + return this.value; + }, + set(v) { + this.$emit('update:value', v); + } + }, + }, + methods: { + focus() { + this.$refs.input.focus(); + } + } +}); +</script> + +<style lang="scss" scoped> +.yrtfrpux { + position: relative; + + > .icon { + position: absolute; + top: 0; + left: 0; + width: 24px; + text-align: center; + line-height: 32px; + + &:not(:empty) + .input { + margin-left: 28px; + } + } + + > .input { + display: flex; + position: relative; + + > select { + display: block; + flex: 1; + width: 100%; + padding: 0 16px; + font: inherit; + font-weight: normal; + font-size: 1em; + height: 52px; + background: none; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + appearance: none; + -webkit-appearance: none; + color: var(--fg); + + option, + optgroup { + color: var(--fg); + background: var(--bg); + } + } + + > .prefix, + > .suffix { + display: block; + align-self: center; + justify-self: center; + font-size: 1em; + line-height: 32px; + color: var(--inputLabel); + pointer-events: none; + + &:empty { + display: none; + } + + > * { + display: block; + min-width: 16px; + } + } + + > .prefix { + padding-right: 4px; + } + + > .suffix { + padding: 0 16px 0 0; + opacity: 0.7; + } + } +} +</style> diff --git a/src/client/components/form/switch.vue b/src/client/components/form/switch.vue new file mode 100644 index 0000000000..a2941c5996 --- /dev/null +++ b/src/client/components/form/switch.vue @@ -0,0 +1,132 @@ +<template> +<div class="ijnpvmgr _formItem"> + <div class="main _formPanel _formClickable" + :class="{ disabled, checked }" + :aria-checked="checked" + :aria-disabled="disabled" + @click.prevent="toggle" + > + <input + type="checkbox" + ref="input" + :disabled="disabled" + @keydown.enter="toggle" + > + <span class="button"> + <span></span> + </span> + <span class="label"> + <span><slot></slot></span> + </span> + </div> + <div class="_formCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './form.scss'; + +export default defineComponent({ + props: { + value: { + type: Boolean, + default: false + }, + disabled: { + type: Boolean, + default: false + } + }, + computed: { + checked(): boolean { + return this.value; + } + }, + methods: { + toggle() { + if (this.disabled) return; + this.$emit('update:value', !this.checked); + } + } +}); +</script> + +<style lang="scss" scoped> +.ijnpvmgr { + > .main { + position: relative; + display: flex; + padding: 16px; + cursor: pointer; + + > * { + user-select: none; + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--X10); + border-color: var(--X10); + + > * { + background-color: var(--accent); + transform: translateX(14px); + } + } + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-block; + flex-shrink: 0; + margin: 3px 0 0 0; + width: 34px; + height: 14px; + background: var(--X6); + outline: none; + border-radius: 14px; + transition: all 0.3s; + cursor: pointer; + + > * { + position: absolute; + top: -3px; + left: 0; + border-radius: 100%; + transition: background-color 0.3s, transform 0.3s; + width: 20px; + height: 20px; + background-color: #fff; + box-shadow: 0 2px 1px -1px rgba(#000, 0.2), 0 1px 1px 0 rgba(#000, 0.14), 0 1px 3px 0 rgba(#000, 0.12); + } + } + + > .label { + margin-left: 12px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + transition: inherit; + } + } + } +} +</style> diff --git a/src/client/components/form/textarea.vue b/src/client/components/form/textarea.vue new file mode 100644 index 0000000000..d84b48197a --- /dev/null +++ b/src/client/components/form/textarea.vue @@ -0,0 +1,136 @@ +<template> +<div class="rivhosbp _formItem" :class="{ tall, pre }"> + <div class="_formLabel"><slot></slot></div> + <div class="input _formPanel"> + <textarea ref="input" :class="{ code, _monospace: code }" + :value="value" + :required="required" + :readonly="readonly" + :pattern="pattern" + :autocomplete="autocomplete" + :spellcheck="!code" + @input="onInput" + @focus="focused = true" + @blur="focused = false" + ></textarea> + </div> + <button class="save _textButton" v-if="save && changed" @click="() => { changed = false; save(); }">{{ $t('save') }}</button> + <div class="_formCaption"><slot name="desc"></slot></div> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import './form.scss'; + +export default defineComponent({ + props: { + value: { + required: false + }, + required: { + type: Boolean, + required: false + }, + readonly: { + type: Boolean, + required: false + }, + pattern: { + type: String, + required: false + }, + autocomplete: { + type: String, + required: false + }, + code: { + type: Boolean, + required: false + }, + tall: { + type: Boolean, + required: false, + default: false + }, + pre: { + type: Boolean, + required: false, + default: false + }, + save: { + type: Function, + required: false, + }, + }, + data() { + return { + changed: false, + } + }, + methods: { + focus() { + this.$refs.input.focus(); + }, + onInput(ev) { + this.changed = true; + this.$emit('update:value', ev.target.value); + } + } +}); +</script> + +<style lang="scss" scoped> +.rivhosbp { + position: relative; + + > .input { + position: relative; + + > textarea { + display: block; + width: 100%; + min-width: 100%; + max-width: 100%; + min-height: 130px; + margin: 0; + padding: 16px; + box-sizing: border-box; + font: inherit; + font-weight: normal; + font-size: 1em; + background: transparent; + border: none; + border-radius: 0; + outline: none; + box-shadow: none; + color: var(--fg); + + &.code { + tab-size: 2; + } + } + } + + > .save { + margin: 6px 0 0 0; + font-size: 0.8em; + } + + &.tall { + > .input { + > textarea { + min-height: 200px; + } + } + } + + &.pre { + > .input { + > textarea { + white-space: pre; + } + } + } +} +</style> diff --git a/src/client/components/form/tuple.vue b/src/client/components/form/tuple.vue new file mode 100644 index 0000000000..6c8a22d189 --- /dev/null +++ b/src/client/components/form/tuple.vue @@ -0,0 +1,36 @@ +<template> +<div class="wthhikgt _formItem" v-size="{ max: [500] }"> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; + +export default defineComponent({ +}); +</script> + +<style lang="scss" scoped> +.wthhikgt { + position: relative; + display: flex; + + > ::v-deep(*) { + flex: 1; + margin: 0; + + &:not(:last-child) { + margin-right: 16px; + } + } + + &.max-width_500px { + display: block; + + > ::v-deep(*) { + margin: inherit; + } + } +} +</style> diff --git a/src/client/components/media-image.vue b/src/client/components/media-image.vue index 64e3efab31..a9d0023cc2 100644 --- a/src/client/components/media-image.vue +++ b/src/client/components/media-image.vue @@ -68,7 +68,7 @@ export default defineComponent({ created() { // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする this.$watch('image', () => { - this.hide = this.image.isSensitive && !this.$store.state.device.alwaysShowNsfw; + this.hide = (this.$store.state.device.nsfw === 'force') ? true : this.image.isSensitive && (this.$store.state.device.nsfw !== 'ignore'); if (this.image.blurhash) { this.color = extractAvgColorFromBlurhash(this.image.blurhash); } diff --git a/src/client/components/media-video.vue b/src/client/components/media-video.vue index 21faddf73b..3dfd60c87f 100644 --- a/src/client/components/media-video.vue +++ b/src/client/components/media-video.vue @@ -48,7 +48,7 @@ export default defineComponent({ } }, created() { - this.hide = this.video.isSensitive && !this.$store.state.device.alwaysShowNsfw; + this.hide = (this.$store.state.device.nsfw === 'force') ? true : this.video.isSensitive && (this.$store.state.device.nsfw !== 'ignore'); }, }); </script> diff --git a/src/client/components/taskmanager.api-window.vue b/src/client/components/taskmanager.api-window.vue index 0df3f75fa2..ec685462c9 100644 --- a/src/client/components/taskmanager.api-window.vue +++ b/src/client/components/taskmanager.api-window.vue @@ -14,8 +14,8 @@ <option value="res">Response</option> </MkTab> - <code v-if="tab === 'req'">{{ reqStr }}</code> - <code v-if="tab === 'res'">{{ resStr }}</code> + <code v-if="tab === 'req'" class="_monospace">{{ reqStr }}</code> + <code v-if="tab === 'res'" class="_monospace">{{ resStr }}</code> </div> </XWindow> </template> @@ -67,7 +67,6 @@ export default defineComponent({ font-size: 0.9em; tab-size: 2; white-space: pre; - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; } } </style> diff --git a/src/client/components/taskmanager.vue b/src/client/components/taskmanager.vue index 92c56442c3..1ed8c8bd5e 100644 --- a/src/client/components/taskmanager.vue +++ b/src/client/components/taskmanager.vue @@ -3,7 +3,7 @@ <template #header> <Fa :icon="faTerminal" style="margin-right: 0.5em;"/>Task Manager </template> - <div class="qljqmnzj"> + <div class="qljqmnzj _monospace"> <MkTab v-model:value="tab" style="border-bottom: solid 1px var(--divider);"> <option value="windows">Windows</option> <option value="stream">Stream</option> @@ -150,7 +150,6 @@ export default defineComponent({ display: flex; flex-direction: column; height: 100%; - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; > .content { flex: 1; diff --git a/src/client/components/timeline.vue b/src/client/components/timeline.vue index 930f47b1a5..df9424d8ed 100644 --- a/src/client/components/timeline.vue +++ b/src/client/components/timeline.vue @@ -6,6 +6,7 @@ import { defineComponent } from 'vue'; import XNotes from './notes.vue'; import * as os from '@/os'; +import * as sound from '@/scripts/sound'; export default defineComponent({ components: { @@ -65,7 +66,7 @@ export default defineComponent({ this.$emit('note'); if (this.sound) { - os.sound(note.userId === this.$store.state.i.id ? 'noteMy' : 'note'); + sound.play(note.userId === this.$store.state.i.id ? 'noteMy' : 'note'); } }; diff --git a/src/client/components/ui/range.vue b/src/client/components/ui/range.vue index c6e585cf50..4cfe66a8fc 100644 --- a/src/client/components/ui/range.vue +++ b/src/client/components/ui/range.vue @@ -1,7 +1,7 @@ <template> <div class="timctyfi" :class="{ focused, disabled }"> <div class="icon"><slot name="icon"></slot></div> - <span class="title"><slot name="title"></slot></span> + <span class="label"><slot name="label"></slot></span> <input type="range" ref="input" @@ -19,7 +19,7 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue';import * as os from '@/os'; +import { defineComponent } from 'vue'; export default defineComponent({ props: { diff --git a/src/client/components/ui/switch.vue b/src/client/components/ui/switch.vue index f738257232..762fba6d99 100644 --- a/src/client/components/ui/switch.vue +++ b/src/client/components/ui/switch.vue @@ -17,10 +17,8 @@ <span></span> </span> <span class="label"> - <span :aria-hidden="!checked"><slot></slot></span> - <p :aria-hidden="!checked"> - <slot name="desc"></slot> - </p> + <span><slot></slot></span> + <p><slot name="desc"></slot></p> </span> </div> </template> diff --git a/src/client/components/ui/textarea.vue b/src/client/components/ui/textarea.vue index 7d3250cc45..d49d7e8342 100644 --- a/src/client/components/ui/textarea.vue +++ b/src/client/components/ui/textarea.vue @@ -2,7 +2,7 @@ <div class="adhpbeos" :class="{ focused, filled, tall, pre }"> <div class="input"> <span class="label" ref="label"><slot></slot></span> - <textarea ref="input" :class="{ code }" + <textarea ref="input" :class="{ code, _monospace: code }" :value="value" :required="required" :readonly="readonly" @@ -166,7 +166,6 @@ export default defineComponent({ &.code { tab-size: 2; - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; } } } diff --git a/src/client/init.ts b/src/client/init.ts index cc97947c0a..9294733bbb 100644 --- a/src/client/init.ts +++ b/src/client/init.ts @@ -16,7 +16,8 @@ import { router } from './router'; import { applyTheme } from '@/scripts/theme'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; import { i18n, lang } from './i18n'; -import { stream, sound, isMobile, dialog } from '@/os'; +import { stream, isMobile, dialog } from '@/os'; +import * as sound from './scripts/sound'; console.info(`Misskey v${version}`); @@ -50,7 +51,7 @@ if (_DEV_) { document.addEventListener('touchend', () => {}, { passive: true }); if (localStorage.getItem('theme') == null) { - applyTheme(require('@/themes/l-white.json5')); + applyTheme(require('@/themes/l-light.json5')); } //#region SEE: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ @@ -307,7 +308,7 @@ if (store.getters.isSignedIn) { hasUnreadMessagingMessage: true }); - sound('chatBg'); + sound.play('chatBg'); }); main.on('readAllAntennas', () => { @@ -321,7 +322,7 @@ if (store.getters.isSignedIn) { hasUnreadAntenna: true }); - sound('antenna'); + sound.play('antenna'); }); main.on('readAllAnnouncements', () => { @@ -341,7 +342,7 @@ if (store.getters.isSignedIn) { hasUnreadChannel: true }); - sound('channel'); + sound.play('channel'); }); main.on('readAllAnnouncements', () => { diff --git a/src/client/os.ts b/src/client/os.ts index 88d445ebac..d43de4de44 100644 --- a/src/client/os.ts +++ b/src/client/os.ts @@ -6,6 +6,7 @@ import { apiUrl, debug } from '@/config'; import MkPostFormDialog from '@/components/post-form-dialog.vue'; import MkWaitingDialog from '@/components/waiting-dialog.vue'; import { resolve } from '@/router'; +import { device } from './cold-storage'; const ua = navigator.userAgent.toLowerCase(); export const isMobile = /mobile|iphone|ipad|android/.test(ua); @@ -344,15 +345,6 @@ export function post(props: Record<string, any>) { }); } -export function sound(type: string) { - if (store.state.device.sfxVolume === 0) return; - const sound = store.state.device['sfx' + type.substr(0, 1).toUpperCase() + type.substr(1)]; - if (sound == null) return; - const audio = new Audio(`/assets/sounds/${sound}.mp3`); - audio.volume = store.state.device.sfxVolume; - audio.play(); -} - export const deckGlobalEvents = new EventEmitter(); export const uploads = ref([]); diff --git a/src/client/pages/announcements.vue b/src/client/pages/announcements.vue index a202ec749f..50dc994025 100644 --- a/src/client/pages/announcements.vue +++ b/src/client/pages/announcements.vue @@ -1,6 +1,6 @@ <template> <div class="_section"> - <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content" ref="list"> + <MkPagination :pagination="pagination" #default="{items}" class="ruryvtyk _content"> <section class="_card announcement _vMargin" v-for="(announcement, i) in items" :key="announcement.id"> <div class="_title"><span v-if="$store.getters.isSignedIn && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> <div class="_content"> diff --git a/src/client/pages/instance/settings.vue b/src/client/pages/instance/settings.vue index 32a6a9595f..542c2942b9 100644 --- a/src/client/pages/instance/settings.vue +++ b/src/client/pages/instance/settings.vue @@ -7,6 +7,8 @@ <MkTextarea v-model:value="description">{{ $t('instanceDescription') }}</MkTextarea> <MkInput v-model:value="iconUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('iconUrl') }}</MkInput> <MkInput v-model:value="bannerUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('bannerUrl') }}</MkInput> + <MkInput v-model:value="backgroundImageUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('backgroundImageUrl') }}</MkInput> + <MkInput v-model:value="logoImageUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('logoImageUrl') }}</MkInput> <MkInput v-model:value="tosUrl"><template #icon><Fa :icon="faLink"/></template>{{ $t('tosUrl') }}</MkInput> <MkInput v-model:value="maintainerName">{{ $t('maintainerName') }}</MkInput> <MkInput v-model:value="maintainerEmail" type="email"><template #icon><Fa :icon="faEnvelope"/></template>{{ $t('maintainerEmail') }}</MkInput> @@ -292,6 +294,8 @@ export default defineComponent({ email: null, bannerUrl: null, iconUrl: null, + logoImageUrl: null, + backgroundImageUrl: null, maxNoteTextLength: 0, enableRegistration: false, enableLocalTimeline: false, @@ -345,6 +349,8 @@ export default defineComponent({ this.tosUrl = this.meta.tosUrl; this.bannerUrl = this.meta.bannerUrl; this.iconUrl = this.meta.iconUrl; + this.logoImageUrl = this.meta.logoImageUrl; + this.backgroundImageUrl = this.meta.backgroundImageUrl; this.enableEmail = this.meta.enableEmail; this.email = this.meta.email; this.maintainerName = this.meta.maintainerName; @@ -498,6 +504,8 @@ export default defineComponent({ tosUrl: this.tosUrl, bannerUrl: this.bannerUrl, iconUrl: this.iconUrl, + logoImageUrl: this.logoImageUrl, + backgroundImageUrl: this.backgroundImageUrl, maintainerName: this.maintainerName, maintainerEmail: this.maintainerEmail, maxNoteTextLength: this.maxNoteTextLength, diff --git a/src/client/pages/messaging/messaging-room.vue b/src/client/pages/messaging/messaging-room.vue index f414ccbaa4..d4331c1390 100644 --- a/src/client/pages/messaging/messaging-room.vue +++ b/src/client/pages/messaging/messaging-room.vue @@ -38,6 +38,7 @@ import parseAcct from '../../../misc/acct/parse'; import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; import * as os from '@/os'; import { popout } from '@/scripts/popout'; +import * as sound from '@/scripts/sound'; const Component = defineComponent({ components: { @@ -218,7 +219,7 @@ const Component = defineComponent({ }, onMessage(message) { - os.sound('chat'); + sound.play('chat'); const _isBottom = isBottom(this.$el, 64); diff --git a/src/client/pages/reversi/game.board.vue b/src/client/pages/reversi/game.board.vue index 6559396aca..302d7bc79c 100644 --- a/src/client/pages/reversi/game.board.vue +++ b/src/client/pages/reversi/game.board.vue @@ -94,6 +94,7 @@ import { url } from '@/config'; import MkButton from '@/components/ui/button.vue'; import { userPage } from '@/filters/user'; import * as os from '@/os'; +import * as sound from '@/scripts/sound'; export default defineComponent({ components: { @@ -245,11 +246,7 @@ export default defineComponent({ this.o.put(this.myColor, pos); // サウンドを再生する - if (this.$store.state.device.enableSounds) { - const sound = new Audio(`${url}/assets/reversi-put-me.mp3`); - sound.volume = this.$store.state.device.soundVolume; - sound.play(); - } + sound.play(this.myColor ? 'reversiPutBlack' : 'reversiPutWhite'); this.connection.send('set', { pos: pos @@ -268,10 +265,8 @@ export default defineComponent({ this.$forceUpdate(); // サウンドを再生する - if (this.$store.state.device.enableSounds && x.color != this.myColor) { - const sound = new Audio(`${url}/assets/reversi-put-you.mp3`); - sound.volume = this.$store.state.device.soundVolume; - sound.play(); + if (x.color !== this.myColor) { + sound.play(x.color ? 'reversiPutBlack' : 'reversiPutWhite'); } }, diff --git a/src/client/pages/settings/security.2fa.vue b/src/client/pages/settings/2fa.vue similarity index 96% rename from src/client/pages/settings/security.2fa.vue rename to src/client/pages/settings/2fa.vue index 22b3878445..dc6d12a40f 100644 --- a/src/client/pages/settings/security.2fa.vue +++ b/src/client/pages/settings/2fa.vue @@ -75,14 +75,25 @@ import MkButton from '@/components/ui/button.vue'; import MkInfo from '@/components/ui/info.vue'; import MkInput from '@/components/ui/input.vue'; import MkSwitch from '@/components/ui/switch.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; import * as os from '@/os'; export default defineComponent({ components: { + FormBase, MkButton, MkInfo, MkInput, MkSwitch }, + + emits: ['info'], + data() { return { + INFO: { + title: this.$t('twoStepAuthentication'), + icon: faLock + }, data: null, supportsCredentials: !!navigator.credentials, usePasswordLessLogin: this.$store.state.i.usePasswordLessLogin, @@ -92,6 +103,7 @@ export default defineComponent({ faLock }; }, + methods: { register() { os.dialog({ @@ -225,6 +237,7 @@ export default defineComponent({ }); }); }, + updatePasswordLessLogin() { os.api('i/2fa/password-less', { value: !!this.usePasswordLessLogin diff --git a/src/client/pages/settings/account-info.vue b/src/client/pages/settings/account-info.vue new file mode 100644 index 0000000000..c881b91535 --- /dev/null +++ b/src/client/pages/settings/account-info.vue @@ -0,0 +1,185 @@ +<template> +<FormBase> + <FormKeyValueView> + <template #key>ID</template> + <template #value><span class="_monospace">{{ $store.state.i.id }}</span></template> + </FormKeyValueView> + + <FormGroup> + <FormKeyValueView> + <template #key>{{ $t('registeredDate') }}</template> + <template #value><MkTime :time="$store.state.i.createdAt" mode="detail"/></template> + </FormKeyValueView> + </FormGroup> + + <FormGroup v-if="stats"> + <template #label>{{ $t('statistics') }}</template> + <FormKeyValueView> + <template #key>{{ $t('notesCount') }}</template> + <template #value>{{ number(stats.notesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('repliesCount') }}</template> + <template #value>{{ number(stats.repliesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('renotesCount') }}</template> + <template #value>{{ number(stats.renotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('repliedCount') }}</template> + <template #value>{{ number(stats.repliedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('renotedCount') }}</template> + <template #value>{{ number(stats.renotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('pollVotesCount') }}</template> + <template #value>{{ number(stats.pollVotesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('pollVotedCount') }}</template> + <template #value>{{ number(stats.pollVotedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('sentReactionsCount') }}</template> + <template #value>{{ number(stats.sentReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('receivedReactionsCount') }}</template> + <template #value>{{ number(stats.receivedReactionsCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('noteFavoritesCount') }}</template> + <template #value>{{ number(stats.noteFavoritesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('followingCount') }}</template> + <template #value>{{ number(stats.followingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('followingCount') }} ({{ $t('local') }})</template> + <template #value>{{ number(stats.localFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('followingCount') }} ({{ $t('remote') }})</template> + <template #value>{{ number(stats.remoteFollowingCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('followersCount') }}</template> + <template #value>{{ number(stats.followersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('followersCount') }} ({{ $t('local') }})</template> + <template #value>{{ number(stats.localFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('followersCount') }} ({{ $t('remote') }})</template> + <template #value>{{ number(stats.remoteFollowersCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('pageLikesCount') }}</template> + <template #value>{{ number(stats.pageLikesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('pageLikedCount') }}</template> + <template #value>{{ number(stats.pageLikedCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('driveFilesCount') }}</template> + <template #value>{{ number(stats.driveFilesCount) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('driveUsage') }}</template> + <template #value>{{ bytes(stats.driveUsage) }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>{{ $t('reversiCount') }}</template> + <template #value>{{ number(stats.reversiCount) }}</template> + </FormKeyValueView> + </FormGroup> + + <FormGroup> + <template #label>{{ $t('other') }}</template> + <FormKeyValueView> + <template #key>emailVerified</template> + <template #value>{{ $store.state.i.emailVerified ? $t('yes') : $t('no') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>twoFactorEnabled</template> + <template #value>{{ $store.state.i.twoFactorEnabled ? $t('yes') : $t('no') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>securityKeys</template> + <template #value>{{ $store.state.i.securityKeys ? $t('yes') : $t('no') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>usePasswordLessLogin</template> + <template #value>{{ $store.state.i.usePasswordLessLogin ? $t('yes') : $t('no') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isModerator</template> + <template #value>{{ $store.state.i.isModerator ? $t('yes') : $t('no') }}</template> + </FormKeyValueView> + <FormKeyValueView> + <template #key>isAdmin</template> + <template #value>{{ $store.state.i.isAdmin ? $t('yes') : $t('no') }}</template> + </FormKeyValueView> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineAsyncComponent, defineComponent } from 'vue'; +import { faInfoCircle } from '@fortawesome/free-solid-svg-icons'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/form/link.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; +import FormKeyValueView from '@/components/form/key-value-view.vue'; +import * as os from '@/os'; +import number from '@/filters/number'; +import bytes from '@/filters/bytes'; + +export default defineComponent({ + components: { + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, + FormKeyValueView, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$t('accountInfo'), + icon: faInfoCircle + }, + stats: null + } + }, + + mounted() { + this.$emit('info', this.INFO); + + os.api('users/stats', { + userId: this.$store.state.i.id + }).then(stats => { + this.stats = stats; + }); + }, + + methods: { + number, + bytes, + } +}); +</script> diff --git a/src/client/pages/settings/api.vue b/src/client/pages/settings/api.vue index f4cebbee36..8e5e4fbc66 100644 --- a/src/client/pages/settings/api.vue +++ b/src/client/pages/settings/api.vue @@ -1,26 +1,27 @@ <template> -<div> - <div class="_section"> - <div class="_content"> - <MkButton @click="generateToken">{{ $t('generateAccessToken') }}</MkButton> - </div> - </div> - <div class="_section"> - <MkA to="/api-console" :behavior="isDesktop ? 'window' : null">API console</MkA> - </div> -</div> +<FormBase> + <FormButton @click="generateToken" primary>{{ $t('generateAccessToken') }}</FormButton> + <FormLink to="/settings/apps">{{ $t('manageAccessTokens') }}</FormLink> + <FormLink to="/api-console" :behavior="isDesktop ? 'window' : null">API console</FormLink> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faKey } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '@/components/ui/button.vue'; -import MkInput from '@/components/ui/input.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/form/link.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; import * as os from '@/os'; export default defineComponent({ components: { - MkButton, MkInput + FormBase, + FormButton, + FormLink, }, emits: ['info'], diff --git a/src/client/pages/apps.vue b/src/client/pages/settings/apps.vue similarity index 63% rename from src/client/pages/apps.vue rename to src/client/pages/settings/apps.vue index f9dd0a3584..724a2e8d1f 100644 --- a/src/client/pages/apps.vue +++ b/src/client/pages/settings/apps.vue @@ -1,6 +1,6 @@ <template> -<div> - <MkPagination :pagination="pagination" class="bfomjevm" ref="list"> +<FormBase> + <FormPagination :pagination="pagination" ref="list"> <template #empty> <div class="_fullinfo"> <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> @@ -8,8 +8,8 @@ </div> </template> <template #default="{items}"> - <div class="token _panel" v-for="token in items" :key="token.id"> - <img class="icon" :src="token.iconUrl" alt=""/> + <div class="_formPanel bfomjevm" v-for="token in items" :key="token.id"> + <img class="icon" :src="token.iconUrl" alt="" v-if="token.iconUrl"/> <div class="body"> <div class="name">{{ token.name }}</div> <div class="description">{{ token.description }}</div> @@ -33,21 +33,29 @@ </div> </div> </template> - </MkPagination> -</div> + </FormPagination> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faTrashAlt, faPlug } from '@fortawesome/free-solid-svg-icons'; -import MkPagination from '@/components/ui/pagination.vue'; +import FormPagination from '@/components/form/pagination.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/form/link.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; import * as os from '@/os'; export default defineComponent({ components: { - MkPagination + FormBase, + FormPagination, }, + emits: ['info'], + data() { return { INFO: { @@ -65,6 +73,10 @@ export default defineComponent({ }; }, + mounted() { + this.$emit('info', this.INFO); + }, + methods: { revoke(token) { os.api('i/revoke-token', { tokenId: token.id }).then(() => { @@ -77,26 +89,24 @@ export default defineComponent({ <style lang="scss" scoped> .bfomjevm { - > .token { - display: flex; - padding: 16px; + display: flex; + padding: 16px; - > .icon { - display: block; - flex-shrink: 0; - margin: 0 12px 0 0; - width: 50px; - height: 50px; - border-radius: 8px; - } + > .icon { + display: block; + flex-shrink: 0; + margin: 0 12px 0 0; + width: 50px; + height: 50px; + border-radius: 8px; + } - > .body { - width: calc(100% - 62px); - position: relative; + > .body { + width: calc(100% - 62px); + position: relative; - > .name { - font-weight: bold; - } + > .name { + font-weight: bold; } } } diff --git a/src/client/pages/settings/deck.vue b/src/client/pages/settings/deck.vue new file mode 100644 index 0000000000..2eb2f60cba --- /dev/null +++ b/src/client/pages/settings/deck.vue @@ -0,0 +1,90 @@ +<template> +<FormBase> + + <section class="_card _vMargin"> + <div class="_title"><Fa :icon="faColumns"/> </div> + <div class="_content"> + <div>{{ $t('defaultNavigationBehaviour') }}</div> + <MkSwitch v-model:value="deckNavWindow">{{ $t('openInWindow') }}</MkSwitch> + </div> + <div class="_content"> + <MkSwitch v-model:value="deckAlwaysShowMainColumn"> + {{ $t('_deck.alwaysShowMainColumn') }} + </MkSwitch> + </div> + <div class="_content"> + <div>{{ $t('_deck.columnAlign') }}</div> + <MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio> + <MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio> + </div> + </section> + +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faImage, faCog, faColumns } from '@fortawesome/free-solid-svg-icons'; +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 MkRadios from '@/components/ui/radios.vue'; +import MkRange from '@/components/ui/range.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import { clientDb, set } from '@/db'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + MkButton, + MkSwitch, + MkSelect, + MkRadio, + MkRadios, + MkRange, + FormSwitch, + FormSelect, + FormRadios, + FormBase, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$t('deck'), + icon: faColumns + }, + faImage, faCog, + } + }, + + computed: { + deckNavWindow: { + get() { return this.$store.state.device.deckNavWindow; }, + set(value) { this.$store.commit('device/set', { key: 'deckNavWindow', value }); } + }, + + deckAlwaysShowMainColumn: { + get() { return this.$store.state.device.deckAlwaysShowMainColumn; }, + set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); } + }, + + deckColumnAlign: { + get() { return this.$store.state.device.deckColumnAlign; }, + set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } + }, + }, + + mounted() { + this.$emit('info', this.INFO); + }, +}); +</script> diff --git a/src/client/pages/settings/email-address.vue b/src/client/pages/settings/email-address.vue new file mode 100644 index 0000000000..7ff89d7910 --- /dev/null +++ b/src/client/pages/settings/email-address.vue @@ -0,0 +1,71 @@ +<template> +<FormBase> + <FormGroup> + <FormInput v-model:value="emailAddress" type="email"> + {{ $t('emailAddress') }} + <template #desc v-if="$store.state.i.email && !$store.state.i.emailVerified">{{ $t('verificationEmailSent') }}</template> + <template #desc v-else-if="emailAddress === $store.state.i.email && $store.state.i.emailVerified">{{ $t('emailVerified') }}</template> + </FormInput> + </FormGroup> + <FormButton @click="save" primary>{{ $t('save') }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCog } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import FormButton from '@/components/form/button.vue'; +import FormInput from '@/components/form/input.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + FormBase, + FormInput, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$t('emailAddress'), + icon: faEnvelope + }, + emailAddress: null, + code: null, + faCog + } + }, + + created() { + this.emailAddress = this.$store.state.i.email; + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + save() { + os.dialog({ + title: this.$t('password'), + input: { + type: 'password' + } + }).then(({ canceled, result: password }) => { + if (canceled) return; + os.api('i/update-email', { + password: password, + email: this.emailAddress, + }); + }); + } + } +}); +</script> diff --git a/src/client/pages/settings/email.vue b/src/client/pages/settings/email.vue new file mode 100644 index 0000000000..f72ee29a97 --- /dev/null +++ b/src/client/pages/settings/email.vue @@ -0,0 +1,52 @@ +<template> +<FormBase> + <FormGroup> + <template #label>{{ $t('emailAddress') }}</template> + <FormLink to="/settings/email/address"> + <template v-if="$store.state.i.email && !$store.state.i.emailVerified" #icon><Fa :icon="faExclamationTriangle" style="color: var(--warn);"/></template> + <template v-else-if="$store.state.i.email && $store.state.i.emailVerified" #icon><Fa :icon="faCheck" style="color: var(--success);"/></template> + {{ $store.state.i.email || $t('notSet') }} + </FormLink> + </FormGroup> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faCog, faExclamationTriangle, faCheck } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons'; +import FormButton from '@/components/form/button.vue'; +import FormLink from '@/components/form/link.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + FormBase, + FormLink, + FormButton, + FormGroup, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$t('email'), + icon: faEnvelope + }, + faCog, faExclamationTriangle, faCheck + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + + } +}); +</script> diff --git a/src/client/pages/settings/general.vue b/src/client/pages/settings/general.vue index c88c573ae6..7c2905fdeb 100644 --- a/src/client/pages/settings/general.vue +++ b/src/client/pages/settings/general.vue @@ -1,109 +1,110 @@ <template> -<div class=""> - <section class="_card _vMargin"> - <div class="_title"><Fa :icon="faCog"/> {{ $t('general') }}</div> - <div class="_content"> - <MkRadios v-model="serverDisconnectedBehavior"> - <template #desc>{{ $t('whenServerDisconnected') }}</template> - <option value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</option> - <option value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</option> - <option value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</option> - </MkRadios> - <MkSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</MkSwitch> - <MkSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</MkSwitch> - <MkSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</MkSwitch> - <MkSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</MkSwitch> - <MkSelect v-model:value="lang"> - <template #label>{{ $t('uiLanguage') }}</template> - <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> - </MkSelect> - </div> - </section> +<FormBase> + <FormSwitch v-model:value="showFixedPostForm">{{ $t('showFixedPostForm') }}</FormSwitch> - <section class="_card _vMargin"> - <div class="_title"><Fa :icon="faCog"/> {{ $t('defaultNavigationBehaviour') }}</div> - <div class="_content"> - <MkSwitch v-model:value="defaultSideView">{{ $t('openInSideView') }}</MkSwitch> - </div> - <div class="_content"> - <MkRadios v-model="chatOpenBehavior"> - <template #desc>{{ $t('chatOpenBehavior') }}</template> - <option value="page">{{ $t('showInPage') }}</option> - <option value="window">{{ $t('openInWindow') }}</option> - <option value="popout">{{ $t('popout') }}</option> - </MkRadios> - </div> - </section> + <FormSelect v-model:value="lang"> + <template #label>{{ $t('uiLanguage') }}</template> + <option v-for="x in langs" :value="x[0]" :key="x[0]">{{ x[1] }}</option> + <template #caption> + <i18n-t keypath="i18nInfo" tag="span"> + <template #link> + <MkLink url="https://crowdin.com/project/misskey">Crowdin</MkLink> + </template> + </i18n-t> + </template> + </FormSelect> - <section class="_card _vMargin"> - <div class="_title"><Fa :icon="faCog"/> {{ $t('appearance') }}</div> - <div class="_content"> - <MkSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</MkSwitch> - <MkSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</MkSwitch> - <MkSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</MkSwitch> - <MkSwitch v-model:value="useOsNativeEmojis"> - {{ $t('useOsNativeEmojis') }} - <template #desc><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></template> - </MkSwitch> - <MkRadios v-model="fontSize"> - <template #desc>{{ $t('fontSize') }}</template> - <option value="small"><span style="font-size: 14px;">Aa</span></option> - <option :value="null"><span style="font-size: 16px;">Aa</span></option> - <option value="large"><span style="font-size: 18px;">Aa</span></option> - <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option> - </MkRadios> - <MkRadios v-model="instanceTicker"> - <template #desc>{{ $t('instanceTicker') }}</template> - <option value="none">{{ $t('_instanceTicker.none') }}</option> - <option value="remote">{{ $t('_instanceTicker.remote') }}</option> - <option value="always">{{ $t('_instanceTicker.always') }}</option> - </MkRadios> - </div> - </section> + <FormGroup> + <template #label>{{ $t('behavior') }}</template> + <FormSwitch v-model:value="imageNewTab">{{ $t('openImageInNewTab') }}</FormSwitch> + <FormSwitch v-model:value="enableInfiniteScroll">{{ $t('enableInfiniteScroll') }}</FormSwitch> + <FormSwitch v-model:value="disablePagesScript">{{ $t('disablePagesScript') }}</FormSwitch> + </FormGroup> - <section class="_card _vMargin"> - <div class="_title"><Fa :icon="faColumns"/> {{ $t('deck') }}</div> - <div class="_content"> - <div>{{ $t('defaultNavigationBehaviour') }}</div> - <MkSwitch v-model:value="deckNavWindow">{{ $t('openInWindow') }}</MkSwitch> - </div> - <div class="_content"> - <MkSwitch v-model:value="deckAlwaysShowMainColumn"> - {{ $t('_deck.alwaysShowMainColumn') }} - </MkSwitch> - </div> - <div class="_content"> - <div>{{ $t('_deck.columnAlign') }}</div> - <MkRadio v-model="deckColumnAlign" value="left">{{ $t('left') }}</MkRadio> - <MkRadio v-model="deckColumnAlign" value="center">{{ $t('center') }}</MkRadio> - </div> - </section> + <FormSelect v-model:value="serverDisconnectedBehavior"> + <template #label>{{ $t('whenServerDisconnected') }}</template> + <option value="reload">{{ $t('_serverDisconnectedBehavior.reload') }}</option> + <option value="dialog">{{ $t('_serverDisconnectedBehavior.dialog') }}</option> + <option value="quiet">{{ $t('_serverDisconnectedBehavior.quiet') }}</option> + </FormSelect> - <MkButton @click="cacheClear()" primary style="margin: var(--margin) auto;">{{ $t('cacheClear') }}</MkButton> -</div> + <FormGroup> + <template #label>{{ $t('appearance') }}</template> + <FormSwitch v-model:value="disableAnimatedMfm">{{ $t('disableAnimatedMfm') }}</FormSwitch> + <FormSwitch v-model:value="reduceAnimation">{{ $t('reduceUiAnimation') }}</FormSwitch> + <FormSwitch v-model:value="useBlurEffectForModal">{{ $t('useBlurEffectForModal') }}</FormSwitch> + <FormSwitch v-model:value="useOsNativeEmojis">{{ $t('useOsNativeEmojis') }} + <div><Mfm text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> + </FormSwitch> + <FormSwitch v-model:value="loadRawImages">{{ $t('loadRawImages') }}</FormSwitch> + <FormSwitch v-model:value="disableShowingAnimatedImages">{{ $t('disableShowingAnimatedImages') }}</FormSwitch> + </FormGroup> + + <FormRadios v-model="fontSize"> + <template #desc>{{ $t('fontSize') }}</template> + <option value="small"><span style="font-size: 14px;">Aa</span></option> + <option :value="null"><span style="font-size: 16px;">Aa</span></option> + <option value="large"><span style="font-size: 18px;">Aa</span></option> + <option value="veryLarge"><span style="font-size: 20px;">Aa</span></option> + </FormRadios> + + <FormSelect v-model:value="instanceTicker"> + <template #label>{{ $t('instanceTicker') }}</template> + <option value="none">{{ $t('_instanceTicker.none') }}</option> + <option value="remote">{{ $t('_instanceTicker.remote') }}</option> + <option value="always">{{ $t('_instanceTicker.always') }}</option> + </FormSelect> + + <FormSelect v-model:value="nsfw"> + <template #label>{{ $t('nsfw') }}</template> + <option value="respect">{{ $t('_nsfw.respect') }}</option> + <option value="ignore">{{ $t('_nsfw.ignore') }}</option> + <option value="force">{{ $t('_nsfw.force') }}</option> + </FormSelect> + + <FormGroup> + <template #label>{{ $t('defaultNavigationBehaviour') }}</template> + <FormSwitch v-model:value="defaultSideView">{{ $t('openInSideView') }}</FormSwitch> + </FormGroup> + + <FormSelect v-model:value="chatOpenBehavior"> + <template #label>{{ $t('chatOpenBehavior') }}</template> + <option value="page">{{ $t('showInPage') }}</option> + <option value="window">{{ $t('openInWindow') }}</option> + <option value="popout">{{ $t('popout') }}</option> + </FormSelect> + + <FormLink to="/settings/deck">{{ $t('deck') }}</FormLink> + + <FormButton @click="cacheClear()" danger>{{ $t('cacheClear') }}</FormButton> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faImage, faCog, faColumns, faCogs } from '@fortawesome/free-solid-svg-icons'; -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 MkRadios from '@/components/ui/radios.vue'; -import MkRange from '@/components/ui/range.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/form/button.vue'; +import MkLink from '@/components/link.vue'; import { langs } from '@/config'; import { clientDb, set } from '@/db'; import * as os from '@/os'; export default defineComponent({ components: { - MkButton, - MkSwitch, - MkSelect, - MkRadio, - MkRadios, - MkRange, + MkLink, + FormSwitch, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, }, emits: ['info'], @@ -167,11 +168,6 @@ export default defineComponent({ set(value) { this.$store.commit('device/set', { key: 'defaultSideView', value }); } }, - deckNavWindow: { - get() { return this.$store.state.device.deckNavWindow; }, - set(value) { this.$store.commit('device/set', { key: 'deckNavWindow', value }); } - }, - chatOpenBehavior: { get() { return this.$store.state.device.chatOpenBehavior; }, set(value) { this.$store.commit('device/set', { key: 'chatOpenBehavior', value }); } @@ -182,20 +178,25 @@ export default defineComponent({ set(value) { this.$store.commit('device/set', { key: 'instanceTicker', value }); } }, + loadRawImages: { + get() { return this.$store.state.device.loadRawImages; }, + set(value) { this.$store.commit('device/set', { key: 'loadRawImages', value }); } + }, + + disableShowingAnimatedImages: { + get() { return this.$store.state.device.disableShowingAnimatedImages; }, + set(value) { this.$store.commit('device/set', { key: 'disableShowingAnimatedImages', value }); } + }, + + nsfw: { + get() { return this.$store.state.device.nsfw; }, + set(value) { this.$store.commit('device/set', { key: 'nsfw', value }); } + }, + enableInfiniteScroll: { get() { return this.$store.state.device.enableInfiniteScroll; }, set(value) { this.$store.commit('device/set', { key: 'enableInfiniteScroll', value }); } }, - - deckAlwaysShowMainColumn: { - get() { return this.$store.state.device.deckAlwaysShowMainColumn; }, - set(value) { this.$store.commit('device/set', { key: 'deckAlwaysShowMainColumn', value }); } - }, - - deckColumnAlign: { - get() { return this.$store.state.device.deckColumnAlign; }, - set(value) { this.$store.commit('device/set', { key: 'deckColumnAlign', value }); } - }, }, watch: { diff --git a/src/client/pages/settings/index.vue b/src/client/pages/settings/index.vue index 5451c8616b..a42a4614cc 100644 --- a/src/client/pages/settings/index.vue +++ b/src/client/pages/settings/index.vue @@ -1,35 +1,36 @@ <template> <div class="vvcocwet" :class="{ wide: !narrow }" ref="el"> - <div class="nav" v-if="!narrow || page == null"> - <div class="menu"> - <div class="label">{{ $t('basicSettings') }}</div> - <MkA class="item" :class="{ active: page === 'profile' }" replace to="/settings/profile"><Fa :icon="faUser" fixed-width class="icon"/>{{ $t('profile') }}</MkA> - <MkA class="item" :class="{ active: page === 'privacy' }" replace to="/settings/privacy"><Fa :icon="faLockOpen" fixed-width class="icon"/>{{ $t('privacy') }}</MkA> - <MkA class="item" :class="{ active: page === 'reaction' }" replace to="/settings/reaction"><Fa :icon="faLaugh" fixed-width class="icon"/>{{ $t('reaction') }}</MkA> - <MkA class="item" :class="{ active: page === 'notifications' }" replace to="/settings/notifications"><Fa :icon="faBell" fixed-width class="icon"/>{{ $t('notifications') }}</MkA> - <MkA class="item" :class="{ active: page === 'integration' }" replace to="/settings/integration"><Fa :icon="faShareAlt" fixed-width class="icon"/>{{ $t('integration') }}</MkA> - <MkA class="item" :class="{ active: page === 'security' }" replace to="/settings/security"><Fa :icon="faLock" fixed-width class="icon"/>{{ $t('security') }}</MkA> - </div> - <div class="menu"> - <div class="label">{{ $t('clientSettings') }}</div> - <MkA class="item" :class="{ active: page === 'general' }" replace to="/settings/general"><Fa :icon="faCogs" fixed-width class="icon"/>{{ $t('general') }}</MkA> - <MkA class="item" :class="{ active: page === 'theme' }" replace to="/settings/theme"><Fa :icon="faPalette" fixed-width class="icon"/>{{ $t('theme') }}</MkA> - <MkA class="item" :class="{ active: page === 'sidebar' }" replace to="/settings/sidebar"><Fa :icon="faListUl" fixed-width class="icon"/>{{ $t('sidebar') }}</MkA> - <MkA class="item" :class="{ active: page === 'sounds' }" replace to="/settings/sounds"><Fa :icon="faMusic" fixed-width class="icon"/>{{ $t('sounds') }}</MkA> - <MkA class="item" :class="{ active: page === 'plugins' }" replace to="/settings/plugins"><Fa :icon="faPlug" fixed-width class="icon"/>{{ $t('plugins') }}</MkA> - </div> - <div class="menu"> - <div class="label">{{ $t('otherSettings') }}</div> - <MkA class="item" :class="{ active: page === 'import-export' }" replace to="/settings/import-export"><Fa :icon="faBoxes" fixed-width class="icon"/>{{ $t('importAndExport') }}</MkA> - <MkA class="item" :class="{ active: page === 'mute-block' }" replace to="/settings/mute-block"><Fa :icon="faBan" fixed-width class="icon"/>{{ $t('muteAndBlock') }}</MkA> - <MkA class="item" :class="{ active: page === 'word-mute' }" replace to="/settings/word-mute"><Fa :icon="faCommentSlash" fixed-width class="icon"/>{{ $t('wordMute') }}</MkA> - <MkA class="item" :class="{ active: page === 'api' }" replace to="/settings/api"><Fa :icon="faKey" fixed-width class="icon"/>API</MkA> - <MkA class="item" :class="{ active: page === 'other' }" replace to="/settings/other"><Fa :icon="faEllipsisH" fixed-width class="icon"/>{{ $t('other') }}</MkA> - </div> - <div class="menu"> - <button class="_button item" @click="logout">{{ $t('logout') }}</button> - </div> - </div> + <FormBase class="nav" v-if="!narrow || page == null" :force-wide="!narrow"> + <FormGroup> + <template #label>{{ $t('basicSettings') }}</template> + <FormLink :active="page === 'profile'" replace to="/settings/profile"><template #icon><Fa :icon="faUser"/></template>{{ $t('profile') }}</FormLink> + <FormLink :active="page === 'privacy'" replace to="/settings/privacy"><template #icon><Fa :icon="faLockOpen"/></template>{{ $t('privacy') }}</FormLink> + <FormLink :active="page === 'reaction'" replace to="/settings/reaction"><template #icon><Fa :icon="faLaugh"/></template>{{ $t('reaction') }}</FormLink> + <FormLink :active="page === 'notifications'" replace to="/settings/notifications"><template #icon><Fa :icon="faBell"/></template>{{ $t('notifications') }}</FormLink> + <FormLink :active="page === 'email'" replace to="/settings/email"><template #icon><Fa :icon="faEnvelope"/></template>{{ $t('email') }}</FormLink> + <FormLink :active="page === 'integration'" replace to="/settings/integration"><template #icon><Fa :icon="faShareAlt"/></template>{{ $t('integration') }}</FormLink> + <FormLink :active="page === 'security'" replace to="/settings/security"><template #icon><Fa :icon="faLock"/></template>{{ $t('security') }}</FormLink> + </FormGroup> + <FormGroup> + <template #label>{{ $t('clientSettings') }}</template> + <FormLink :active="page === 'general'" replace to="/settings/general"><template #icon><Fa :icon="faCogs"/></template>{{ $t('general') }}</FormLink> + <FormLink :active="page === 'theme'" replace to="/settings/theme"><template #icon><Fa :icon="faPalette"/></template>{{ $t('theme') }}</FormLink> + <FormLink :active="page === 'sidebar'" replace to="/settings/sidebar"><template #icon><Fa :icon="faListUl"/></template>{{ $t('sidebar') }}</FormLink> + <FormLink :active="page === 'sounds'" replace to="/settings/sounds"><template #icon><Fa :icon="faMusic"/></template>{{ $t('sounds') }}</FormLink> + <FormLink :active="page === 'plugins'" replace to="/settings/plugins"><template #icon><Fa :icon="faPlug"/></template>{{ $t('plugins') }}</FormLink> + </FormGroup> + <FormGroup> + <template #label>{{ $t('otherSettings') }}</template> + <FormLink :active="page === 'import-export'" replace to="/settings/import-export"><template #icon><Fa :icon="faBoxes"/></template>{{ $t('importAndExport') }}</FormLink> + <FormLink :active="page === 'mute-block'" replace to="/settings/mute-block"><template #icon><Fa :icon="faBan"/></template>{{ $t('muteAndBlock') }}</FormLink> + <FormLink :active="page === 'word-mute'" replace to="/settings/word-mute"><template #icon><Fa :icon="faCommentSlash"/></template>{{ $t('wordMute') }}</FormLink> + <FormLink :active="page === 'api'" replace to="/settings/api"><template #icon><Fa :icon="faKey"/></template>API</FormLink> + <FormLink :active="page === 'other'" replace to="/settings/other"><template #icon><Fa :icon="faEllipsisH"/></template>{{ $t('other') }}</FormLink> + </FormGroup> + <FormGroup> + <FormButton @click="logout" danger>{{ $t('logout') }}</FormButton> + </FormGroup> + </FormBase> <div class="main"> <component :is="component" @info="onInfo"/> </div> @@ -37,13 +38,25 @@ </template> <script lang="ts"> -import { computed, defineAsyncComponent, defineComponent, onMounted, ref } from 'vue'; +import { computed, defineAsyncComponent, defineComponent, nextTick, onMounted, ref, watch } from 'vue'; import { faCog, faPalette, faPlug, faUser, faListUl, faLock, faCommentSlash, faMusic, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes } from '@fortawesome/free-solid-svg-icons'; -import { faLaugh, faBell } from '@fortawesome/free-regular-svg-icons'; +import { faLaugh, faBell, faEnvelope } from '@fortawesome/free-regular-svg-icons'; import { store } from '@/store'; import { i18n } from '@/i18n'; +import FormLink from '@/components/form/link.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormBase from '@/components/form/base.vue'; +import FormButton from '@/components/form/button.vue'; +import { scroll } from '../../scripts/scroll'; export default defineComponent({ + components: { + FormBase, + FormLink, + FormGroup, + FormButton, + }, + props: { page: { type: String, @@ -72,21 +85,35 @@ export default defineComponent({ case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue')); case 'integration': return defineAsyncComponent(() => import('./integration.vue')); case 'security': return defineAsyncComponent(() => import('./security.vue')); + case '2fa': return defineAsyncComponent(() => import('./2fa.vue')); case 'api': return defineAsyncComponent(() => import('./api.vue')); + case 'apps': return defineAsyncComponent(() => import('./apps.vue')); case 'other': return defineAsyncComponent(() => import('./other.vue')); case 'general': return defineAsyncComponent(() => import('./general.vue')); + case 'email': return defineAsyncComponent(() => import('./email.vue')); + case 'email/address': return defineAsyncComponent(() => import('./email-address.vue')); case 'theme': return defineAsyncComponent(() => import('./theme.vue')); + case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); + case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); case 'sidebar': return defineAsyncComponent(() => import('./sidebar.vue')); case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); + case 'deck': return defineAsyncComponent(() => import('./deck.vue')); case 'plugins': return defineAsyncComponent(() => import('./plugins.vue')); case 'import-export': return defineAsyncComponent(() => import('./import-export.vue')); + case 'account-info': return defineAsyncComponent(() => import('./account-info.vue')); case 'regedit': return defineAsyncComponent(() => import('./regedit.vue')); default: return null; } }); + watch(component, () => { + nextTick(() => { + scroll(el.value, 0); + }); + }); + onMounted(() => { - narrow.value = el.value.offsetWidth < 650; + narrow.value = el.value.offsetWidth < 1025; }); return { @@ -100,7 +127,7 @@ export default defineComponent({ store.dispatch('logout'); location.href = '/'; }, - faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes, + faPalette, faPlug, faUser, faListUl, faLock, faLaugh, faCommentSlash, faMusic, faBell, faCogs, faEllipsisH, faBan, faShareAlt, faLockOpen, faKey, faBoxes, faEnvelope, }; }, }); @@ -108,63 +135,19 @@ export default defineComponent({ <style lang="scss" scoped> .vvcocwet { - > .nav { - > .menu { - margin: 16px 0; - - > .label { - padding: 8px 32px; - font-size: 80%; - opacity: 0.7; - } - - > .item { - display: block; - width: 100%; - box-sizing: border-box; - padding: 0 32px; - line-height: 40px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - //background: var(--panel); - //border-bottom: solid 1px var(--divider); - transition: padding 0.2s ease, color 0.1s ease; - - &:first-of-type { - //border-top: solid 1px var(--divider); - } - - &.active { - color: var(--accent); - padding-left: 42px; - } - - &:hover { - text-decoration: none; - padding-left: 42px; - } - - > .icon { - margin-right: 0.5em; - } - } - } - } - &.wide { display: flex; + max-width: 1100px; + margin: 0 auto; > .nav { - width: 30%; - max-width: 300px; - font-size: 0.95em; - border-right: solid 1px var(--divider); + width: 32%; + box-sizing: border-box; + border-right: solid 0.5px var(--divider); } > .main { flex: 1; - padding: 32px; --baseContentWidth: 100%; ::v-deep(._section) { diff --git a/src/client/pages/settings/notifications.vue b/src/client/pages/settings/notifications.vue index ff0c276398..d26a11ef78 100644 --- a/src/client/pages/settings/notifications.vue +++ b/src/client/pages/settings/notifications.vue @@ -1,29 +1,31 @@ <template> -<div> - <div class="_section"> - <MkButton full primary @click="configure"><Fa :icon="faCog"/> {{ $t('notificationSetting') }}</MkButton> - </div> - <div class="_section"> - <MkButton full @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</MkButton> - <MkButton full @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</MkButton> - <MkButton full @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</MkButton> - </div> -</div> +<FormBase> + <FormLink @click="configure">{{ $t('notificationSetting') }}</FormLink> + <FormGroup> + <FormButton @click="readAllNotifications">{{ $t('markAsReadAllNotifications') }}</FormButton> + <FormButton @click="readAllUnreadNotes">{{ $t('markAsReadAllUnreadNotes') }}</FormButton> + <FormButton @click="readAllMessagingMessages">{{ $t('markAsReadAllTalkMessages') }}</FormButton> + </FormGroup> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faCog } from '@fortawesome/free-solid-svg-icons'; import { faBell } from '@fortawesome/free-regular-svg-icons'; -import MkButton from '@/components/ui/button.vue'; -import MkSwitch from '@/components/ui/switch.vue'; +import FormButton from '@/components/form/button.vue'; +import FormLink from '@/components/form/link.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; import { notificationTypes } from '../../../types'; import * as os from '@/os'; export default defineComponent({ components: { - MkButton, - MkSwitch, + FormBase, + FormLink, + FormButton, + FormGroup, }, emits: ['info'], diff --git a/src/client/pages/settings/other.vue b/src/client/pages/settings/other.vue index 9c44d1b4f4..b3bab0e232 100644 --- a/src/client/pages/settings/other.vue +++ b/src/client/pages/settings/other.vue @@ -1,40 +1,43 @@ <template> -<div> - <div class="_section"> - <div class="_card"> - <div class="_content"> - <MkSwitch v-model:value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote"> - {{ $t('showFeaturedNotesInTimeline') }} - </MkSwitch> - </div> - </div> - </div> - <div class="_section"> - <MkSwitch v-model:value="debug" @update:value="changeDebug"> +<FormBase> + <FormSwitch :value="$store.state.i.injectFeaturedNote" @update:value="onChangeInjectFeaturedNote"> + {{ $t('showFeaturedNotesInTimeline') }} + </FormSwitch> + + <FormLink to="/settings/account-info">{{ $t('accountInfo') }}</FormLink> + + <FormGroup> + <FormSwitch v-model:value="debug" @update:value="changeDebug"> DEBUG MODE - </MkSwitch> - <div v-if="debug"> - <MkA to="/settings/regedit">RegEdit</MkA> - <MkButton @click="taskmanager">Task Manager</MkButton> - </div> - </div> -</div> + </FormSwitch> + <template v-if="debug"> + <FormLink to="/settings/regedit">RegEdit</FormLink> + <FormButton @click="taskmanager">Task Manager</FormButton> + </template> + </FormGroup> +</FormBase> </template> <script lang="ts"> import { defineAsyncComponent, defineComponent } from 'vue'; import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'; -import MkSelect from '@/components/ui/select.vue'; -import MkSwitch from '@/components/ui/switch.vue'; -import MkButton from '@/components/ui/button.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormLink from '@/components/form/link.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; import * as os from '@/os'; import { debug } from '@/config'; export default defineComponent({ components: { - MkSelect, - MkSwitch, - MkButton, + FormBase, + FormSelect, + FormSwitch, + FormButton, + FormLink, + FormGroup, }, emits: ['info'], diff --git a/src/client/pages/settings/privacy.vue b/src/client/pages/settings/privacy.vue index 27a949836a..09db077502 100644 --- a/src/client/pages/settings/privacy.vue +++ b/src/client/pages/settings/privacy.vue @@ -1,36 +1,43 @@ <template> -<div class="_section"> - <div class="_card"> - <div class="_content"> - <MkSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</MkSwitch> - <MkSwitch v-model:value="autoAcceptFollowed" v-if="isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</MkSwitch> - </div> - <div class="_content"> - <MkSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</MkSwitch> - <MkSelect v-model:value="defaultNoteVisibility" style="margin-bottom: 8px;" v-if="!rememberNoteVisibility"> - <template #label>{{ $t('defaultNoteVisibility') }}</template> - <option value="public">{{ $t('_visibility.public') }}</option> - <option value="home">{{ $t('_visibility.home') }}</option> - <option value="followers">{{ $t('_visibility.followers') }}</option> - <option value="specified">{{ $t('_visibility.specified') }}</option> - </MkSelect> - <MkSwitch v-model:value="defaultNoteLocalOnly" v-if="!rememberNoteVisibility">{{ $t('_visibility.localOnly') }}</MkSwitch> - </div> - </div> -</div> +<FormBase> + <FormGroup> + <FormSwitch v-model:value="isLocked" @update:value="save()">{{ $t('makeFollowManuallyApprove') }}</FormSwitch> + <FormSwitch v-model:value="autoAcceptFollowed" :disabled="!isLocked" @update:value="save()">{{ $t('autoAcceptFollowed') }}</FormSwitch> + <template #caption>{{ $t('lockedAccountInfo') }}</template> + </FormGroup> + <FormSwitch v-model:value="noCrawle" @update:value="save()"> + {{ $t('noCrawle') }} + <template #desc>{{ $t('noCrawleDescription') }}</template> + </FormSwitch> + <FormSwitch v-model:value="rememberNoteVisibility" @update:value="save()">{{ $t('rememberNoteVisibility') }}</FormSwitch> + <FormGroup v-if="!rememberNoteVisibility"> + <template #label>{{ $t('defaultNoteVisibility') }}</template> + <FormSelect v-model:value="defaultNoteVisibility"> + <option value="public">{{ $t('_visibility.public') }}</option> + <option value="home">{{ $t('_visibility.home') }}</option> + <option value="followers">{{ $t('_visibility.followers') }}</option> + <option value="specified">{{ $t('_visibility.specified') }}</option> + </FormSelect> + <FormSwitch v-model:value="defaultNoteLocalOnly">{{ $t('_visibility.localOnly') }}</FormSwitch> + </FormGroup> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faLockOpen } from '@fortawesome/free-solid-svg-icons'; -import MkSelect from '@/components/ui/select.vue'; -import MkSwitch from '@/components/ui/switch.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; import * as os from '@/os'; export default defineComponent({ components: { - MkSelect, - MkSwitch, + FormBase, + FormSelect, + FormGroup, + FormSwitch, }, emits: ['info'], @@ -43,6 +50,7 @@ export default defineComponent({ }, isLocked: false, autoAcceptFollowed: false, + noCrawle: false, } }, @@ -66,6 +74,7 @@ export default defineComponent({ created() { this.isLocked = this.$store.state.i.isLocked; this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed; + this.noCrawle = this.$store.state.i.noCrawle; }, mounted() { @@ -77,6 +86,7 @@ export default defineComponent({ os.api('i/update', { isLocked: !!this.isLocked, autoAcceptFollowed: !!this.autoAcceptFollowed, + noCrawle: !!this.noCrawle, }); } } diff --git a/src/client/pages/settings/profile.vue b/src/client/pages/settings/profile.vue index 6a523e08cf..4fc4783c49 100644 --- a/src/client/pages/settings/profile.vue +++ b/src/client/pages/settings/profile.vue @@ -1,79 +1,67 @@ <template> -<div class="_section"> - <div class="llvierxe _card"> - <div class="_title"><Fa :icon="faUser"/> {{ $t('profile') }}<small style="display: block; font-weight: normal; opacity: 0.6;">@{{ $store.state.i.username }}@{{ host }}</small></div> - <div class="_content"> - <div class="header" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner"> - <MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/> - </div> - - <MkInput v-model:value="name" :max="30"> - <span>{{ $t('_profile.name') }}</span> - </MkInput> - - <MkTextarea v-model:value="description" :max="500"> - <span>{{ $t('_profile.description') }}</span> - <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> - </MkTextarea> - - <MkInput v-model:value="location"> - <span>{{ $t('location') }}</span> - <template #prefix><Fa :icon="faMapMarkerAlt"/></template> - </MkInput> - - <MkInput v-model:value="birthday" type="date"> - <template #title>{{ $t('birthday') }}</template> - <template #prefix><Fa :icon="faBirthdayCake"/></template> - </MkInput> - - <details class="fields"> - <summary>{{ $t('_profile.metadata') }}</summary> - <div class="row"> - <MkInput v-model:value="fieldName0">{{ $t('_profile.metadataLabel') }}</MkInput> - <MkInput v-model:value="fieldValue0">{{ $t('_profile.metadataContent') }}</MkInput> - </div> - <div class="row"> - <MkInput v-model:value="fieldName1">{{ $t('_profile.metadataLabel') }}</MkInput> - <MkInput v-model:value="fieldValue1">{{ $t('_profile.metadataContent') }}</MkInput> - </div> - <div class="row"> - <MkInput v-model:value="fieldName2">{{ $t('_profile.metadataLabel') }}</MkInput> - <MkInput v-model:value="fieldValue2">{{ $t('_profile.metadataContent') }}</MkInput> - </div> - <div class="row"> - <MkInput v-model:value="fieldName3">{{ $t('_profile.metadataLabel') }}</MkInput> - <MkInput v-model:value="fieldValue3">{{ $t('_profile.metadataContent') }}</MkInput> - </div> - </details> - - <MkSwitch v-model:value="isBot">{{ $t('flagAsBot') }}</MkSwitch> - <MkSwitch v-model:value="isCat">{{ $t('flagAsCat') }}</MkSwitch> - </div> - <div class="_footer"> - <MkButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> - </div> +<FormBase class="llvierxe"> + <div class="header _formItem" :style="{ backgroundImage: $store.state.i.bannerUrl ? `url(${ $store.state.i.bannerUrl })` : null }" @click="changeBanner"> + <MkAvatar class="avatar" :user="$store.state.i" :disable-preview="true" :disable-link="true" @click.stop="changeAvatar"/> </div> -</div> + + <FormInput v-model:value="name" :max="30"> + <span>{{ $t('_profile.name') }}</span> + </FormInput> + + <FormTextarea v-model:value="description" :max="500"> + <span>{{ $t('_profile.description') }}</span> + <template #desc>{{ $t('_profile.youCanIncludeHashtags') }}</template> + </FormTextarea> + + <FormInput v-model:value="location"> + <span>{{ $t('location') }}</span> + <template #prefix><Fa :icon="faMapMarkerAlt"/></template> + </FormInput> + + <FormInput v-model:value="birthday" type="date"> + <span>{{ $t('birthday') }}</span> + <template #prefix><Fa :icon="faBirthdayCake"/></template> + </FormInput> + + <FormGroup> + <FormButton @click="editMetadata" primary>{{ $t('_profile.metadataEdit') }}</FormButton> + <template #caption>{{ $t('_profile.metadataDescription') }}</template> + </FormGroup> + + <FormSwitch v-model:value="isCat">{{ $t('flagAsCat') }}<template #desc>{{ $t('flagAsCatDescription') }}</template></FormSwitch> + + <FormSwitch v-model:value="isBot">{{ $t('flagAsBot') }}<template #desc>{{ $t('flagAsBotDescription') }}</template></FormSwitch> + + <FormSwitch v-model:value="alwaysMarkNsfw">{{ $t('alwaysMarkSensitive') }}</FormSwitch> + + <FormButton @click="save(true)" primary><Fa :icon="faSave"/> {{ $t('save') }}</FormButton> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake } from '@fortawesome/free-solid-svg-icons'; import { faSave } from '@fortawesome/free-regular-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 FormButton from '@/components/form/button.vue'; +import FormInput from '@/components/form/input.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormTuple from '@/components/form/tuple.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; import { host } from '@/config'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; export default defineComponent({ components: { - MkButton, - MkInput, - MkTextarea, - MkSwitch, + FormButton, + FormInput, + FormTextarea, + FormSwitch, + FormTuple, + FormBase, + FormGroup, }, emits: ['info'], @@ -101,6 +89,7 @@ export default defineComponent({ bannerId: null, isBot: false, isCat: false, + alwaysMarkNsfw: false, saving: false, faSave, faUnlockAlt, faCogs, faUser, faMapMarkerAlt, faBirthdayCake } @@ -115,6 +104,7 @@ export default defineComponent({ this.bannerId = this.$store.state.i.bannerId; this.isBot = this.$store.state.i.isBot; this.isCat = this.$store.state.i.isCat; + this.alwaysMarkNsfw = this.$store.state.i.alwaysMarkNsfw; this.fieldName0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].name : null; this.fieldValue0 = this.$store.state.i.fields[0] ? this.$store.state.i.fields[0].value : null; @@ -147,7 +137,60 @@ export default defineComponent({ }); }, - save(notify) { + async editMetadata() { + const { canceled, result } = await os.form(this.$t('_profile.metadata'), { + fieldName0: { + type: 'string', + label: this.$t('_profile.metadataLabel') + ' 1', + default: this.fieldName0, + }, + fieldValue0: { + type: 'string', + label: this.$t('_profile.metadataContent') + ' 1', + default: this.fieldValue0, + }, + fieldName1: { + type: 'string', + label: this.$t('_profile.metadataLabel') + ' 2', + default: this.fieldName1, + }, + fieldValue1: { + type: 'string', + label: this.$t('_profile.metadataContent') + ' 2', + default: this.fieldValue1, + }, + fieldName2: { + type: 'string', + label: this.$t('_profile.metadataLabel') + ' 3', + default: this.fieldName2, + }, + fieldValue2: { + type: 'string', + label: this.$t('_profile.metadataContent') + ' 3', + default: this.fieldValue2, + }, + fieldName3: { + type: 'string', + label: this.$t('_profile.metadataLabel') + ' 4', + default: this.fieldName3, + }, + fieldValue3: { + type: 'string', + label: this.$t('_profile.metadataContent') + ' 4', + default: this.fieldValue3, + }, + }); + if (canceled) return; + + this.fieldName0 = result.fieldName0; + this.fieldValue0 = result.fieldValue0; + this.fieldName1 = result.fieldName1; + this.fieldValue1 = result.fieldValue1; + this.fieldName2 = result.fieldName2; + this.fieldValue2 = result.fieldValue2; + this.fieldName3 = result.fieldName3; + this.fieldValue3 = result.fieldValue3; + const fields = [ { name: this.fieldName0, value: this.fieldValue0 }, { name: this.fieldName1, value: this.fieldValue1 }, @@ -155,6 +198,19 @@ export default defineComponent({ { name: this.fieldName3, value: this.fieldValue3 }, ]; + os.api('i/update', { + fields, + }).then(i => { + os.success(); + }).catch(err => { + os.dialog({ + type: 'error', + text: err.id + }); + }); + }, + + save(notify) { this.saving = true; os.api('i/update', { @@ -162,9 +218,9 @@ export default defineComponent({ description: this.description || null, location: this.location || null, birthday: this.birthday || null, - fields, isBot: !!this.isBot, isCat: !!this.isCat, + alwaysMarkNsfw: !!this.alwaysMarkNsfw, }).then(i => { this.saving = false; this.$store.state.i.avatarId = i.avatarId; @@ -189,41 +245,29 @@ export default defineComponent({ <style lang="scss" scoped> .llvierxe { - > ._content { - > .header { - position: relative; - height: 150px; - overflow: hidden; - background-size: cover; - background-position: center; - border-radius: 5px; - border: solid 1px var(--divider); - box-sizing: border-box; + > .header { + position: relative; + height: 150px; + overflow: hidden; + background-size: cover; + background-position: center; + border-radius: 5px; + border: solid 1px var(--divider); + box-sizing: border-box; + cursor: pointer; + + > .avatar { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: block; + width: 72px; + height: 72px; + margin: auto; cursor: pointer; - - > .avatar { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: block; - width: 72px; - height: 72px; - margin: auto; - cursor: pointer; - box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5); - } - } - - > .fields { - > .row { - > * { - display: inline-block; - width: 50%; - margin-bottom: 0; - } - } + box-shadow: 0 0 0 6px rgba(0, 0, 0, 0.5); } } } diff --git a/src/client/pages/settings/reaction.vue b/src/client/pages/settings/reaction.vue index 88de091441..75dae29068 100644 --- a/src/client/pages/settings/reaction.vue +++ b/src/client/pages/settings/reaction.vue @@ -1,9 +1,8 @@ <template> -<div class="_section"> - <div class="_card"> - <div class="_title"><Fa :icon="faLaugh"/> {{ $t('reaction') }}</div> - <div class="_content"> - <div class="_caption" style="padding: 0 8px 8px 8px;">{{ $t('reactionSettingDescription') }}</div> +<FormBase> + <div class="_formItem"> + <div class="_formLabel">{{ $t('reactionSettingDescription') }}</div> + <div class="_formPanel"> <XDraggable class="zoaiodol" :list="reactions" animation="150" delay="100" delay-on-touch-only="true"> <button class="_button item" v-for="reaction in reactions" :key="reaction" @click="remove(reaction, $event)"> <MkEmoji :emoji="reaction" :normal="true"/> @@ -12,26 +11,25 @@ <button>a</button> </template> </XDraggable> - <div class="_caption" style="padding: 8px;">{{ $t('reactionSettingDescription2') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></div> - <MkRadios v-model="reactionPickerWidth"> - <template #desc>{{ $t('width') }}</template> - <option :value="1">{{ $t('small') }}</option> - <option :value="2">{{ $t('medium') }}</option> - <option :value="3">{{ $t('large') }}</option> - </MkRadios> - <MkRadios v-model="reactionPickerHeight"> - <template #desc>{{ $t('height') }}</template> - <option :value="1">{{ $t('small') }}</option> - <option :value="2">{{ $t('medium') }}</option> - <option :value="3">{{ $t('large') }}</option> - </MkRadios> - </div> - <div class="_footer"> - <MkButton inline @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton> - <MkButton inline @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</MkButton> </div> + <div class="_formCaption">{{ $t('reactionSettingDescription2') }} <button class="_textButton" @click="chooseEmoji">{{ $t('chooseEmoji') }}</button></div> </div> -</div> + + <FormRadios v-model="reactionPickerWidth"> + <template #desc>{{ $t('width') }}</template> + <option :value="1">{{ $t('small') }}</option> + <option :value="2">{{ $t('medium') }}</option> + <option :value="3">{{ $t('large') }}</option> + </FormRadios> + <FormRadios v-model="reactionPickerHeight"> + <template #desc>{{ $t('height') }}</template> + <option :value="1">{{ $t('small') }}</option> + <option :value="2">{{ $t('medium') }}</option> + <option :value="3">{{ $t('large') }}</option> + </FormRadios> + <FormButton @click="preview"><Fa :icon="faEye"/> {{ $t('preview') }}</FormButton> + <FormButton danger @click="setDefault"><Fa :icon="faUndo"/> {{ $t('default') }}</FormButton> +</FormBase> </template> <script lang="ts"> @@ -39,20 +37,19 @@ import { defineComponent } from 'vue'; import { faLaugh, faSave, faEye } from '@fortawesome/free-regular-svg-icons'; import { faUndo } from '@fortawesome/free-solid-svg-icons'; import { VueDraggableNext } from 'vue-draggable-next'; -import MkInput from '@/components/ui/input.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkSwitch from '@/components/ui/switch.vue'; -import MkRadios from '@/components/ui/radios.vue'; -import { emojiRegexWithCustom } from '../../../misc/emoji-regex'; +import FormInput from '@/components/form/input.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormButton from '@/components/form/button.vue'; import { defaultSettings } from '@/store'; import * as os from '@/os'; export default defineComponent({ components: { - MkInput, - MkButton, - MkSwitch, - MkRadios, + FormInput, + FormButton, + FormBase, + FormRadios, XDraggable: VueDraggableNext, }, @@ -62,7 +59,11 @@ export default defineComponent({ return { INFO: { title: this.$t('reaction'), - icon: faLaugh + icon: faLaugh, + action: { + icon: faEye, + handler: this.preview + } }, reactions: JSON.parse(JSON.stringify(this.$store.state.settings.reactions)), faLaugh, faSave, faEye, faUndo @@ -144,8 +145,6 @@ export default defineComponent({ <style lang="scss" scoped> .zoaiodol { - border: solid 1px var(--divider); - border-radius: var(--radius); padding: 16px; > .item { diff --git a/src/client/pages/settings/security.vue b/src/client/pages/settings/security.vue index 98863679c4..8d84d08f78 100644 --- a/src/client/pages/settings/security.vue +++ b/src/client/pages/settings/security.vue @@ -1,29 +1,45 @@ <template> -<div> - <div class="_section"> - <X2fa/> - </div> - <div class="_section"> - <MkButton primary @click="change()" full>{{ $t('changePassword') }}</MkButton> - </div> - <div class="_section"> - <MkButton class="_vMargin" primary @click="regenerateToken" full><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</MkButton> - <div class="_caption _vMargin" style="padding: 0 6px;">{{ $t('regenerateLoginTokenDescription') }}</div> - </div> -</div> +<FormBase> + <X2fa/> + <FormLink to="/settings/2fa"><template #icon><Fa :icon="faMobileAlt"/></template>{{ $t('twoStepAuthentication') }}</FormLink> + <FormButton primary @click="change()">{{ $t('changePassword') }}</FormButton> + <FormPagination :pagination="pagination"> + <template #label>{{ $t('signinHistory') }}</template> + <template #default="{items}"> + <div class="_formPanel timnmucd" v-for="item in items" :key="item.id"> + <header> + <Fa class="icon succ" :icon="faCheck" v-if="item.success"/> + <Fa class="icon fail" :icon="faTimesCircle" v-else/> + <code class="ip _monospace">{{ item.ip }}</code> + <MkTime :time="item.createdAt" class="time"/> + </header> + </div> + </template> + </FormPagination> + <FormGroup> + <FormButton danger @click="regenerateToken"><Fa :icon="faSyncAlt"/> {{ $t('regenerateLoginToken') }}</FormButton> + <template #caption>{{ $t('regenerateLoginTokenDescription') }}</template> + </FormGroup> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import { faLock, faSyncAlt } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '@/components/ui/button.vue'; -import X2fa from './security.2fa.vue'; +import { faCheck, faTimesCircle, faLock, faSyncAlt, faMobileAlt } from '@fortawesome/free-solid-svg-icons'; +import FormBase from '@/components/form/base.vue'; +import FormLink from '@/components/form/link.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; +import FormPagination from '@/components/form/pagination.vue'; import * as os from '@/os'; export default defineComponent({ components: { - MkButton, - X2fa, + FormBase, + FormLink, + FormButton, + FormPagination, + FormGroup, }, emits: ['info'], @@ -34,7 +50,11 @@ export default defineComponent({ title: this.$t('security'), icon: faLock }, - faLock, faSyncAlt + pagination: { + endpoint: 'i/signin-history', + limit: 5, + }, + faLock, faSyncAlt, faCheck, faTimesCircle, faMobileAlt, } }, @@ -98,3 +118,32 @@ export default defineComponent({ } }); </script> + +<style lang="scss" scoped> +.timnmucd { + padding: 16px; + + > header { + display: flex; + align-items: center; + + > .icon { + width: 1em; + margin-right: 0.75em; + + &.succ { + color: var(--success); + } + + &.fail { + color: var(--error); + } + } + + > .time { + margin-left: auto; + opacity: 0.7; + } + } +} +</style> diff --git a/src/client/pages/settings/sidebar.vue b/src/client/pages/settings/sidebar.vue index 2ab5acf936..4138aaf733 100644 --- a/src/client/pages/settings/sidebar.vue +++ b/src/client/pages/settings/sidebar.vue @@ -1,41 +1,41 @@ <template> -<div class="_section"> - <div class="_card"> - <div class="_content"> - <MkTextarea v-model:value="items" tall> - <span>{{ $t('sidebar') }}</span> - <template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template> - </MkTextarea> - </div> - <div class="_content"> - <div>{{ $t('display') }}</div> - <MkRadio v-model="sidebarDisplay" value="full">{{ $t('_sidebar.full') }}</MkRadio> - <MkRadio v-model="sidebarDisplay" value="icon">{{ $t('_sidebar.icon') }}</MkRadio> - <!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> - </div> - <div class="_footer"> - <MkButton inline @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> - <MkButton inline @click="reset()"><Fa :icon="faRedo"/> {{ $t('default') }}</MkButton> - </div> - </div> -</div> +<FormBase> + <FormTextarea v-model:value="items" tall> + <span>{{ $t('sidebar') }}</span> + <template #desc><button class="_textButton" @click="addItem">{{ $t('addItem') }}</button></template> + </FormTextarea> + + <FormRadios v-model="sidebarDisplay"> + <template #desc>{{ $t('display') }}</template> + <option value="full">{{ $t('_sidebar.full') }}</option> + <option value="icon">{{ $t('_sidebar.icon') }}</option> + <!-- <MkRadio v-model="sidebarDisplay" value="hide" disabled>{{ $t('_sidebar.hide') }}</MkRadio>--> <!-- TODO: サイドバーを完全に隠せるようにすると、別途ハンバーガーボタンのようなものをUIに表示する必要があり面倒 --> + </FormRadios> + + <FormButton @click="save()" primary><Fa :icon="faSave"/> {{ $t('save') }}</FormButton> + <FormButton @click="reset()" danger><Fa :icon="faRedo"/> {{ $t('default') }}</FormButton> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faListUl, faSave, faRedo } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '@/components/ui/button.vue'; -import MkTextarea from '@/components/ui/textarea.vue'; -import MkRadio from '@/components/ui/radio.vue'; +import FormSwitch from '@/components/form/switch.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormButton from '@/components/form/button.vue'; import { defaultDeviceUserSettings } from '@/store'; import * as os from '@/os'; import { sidebarDef } from '@/sidebar'; export default defineComponent({ components: { - MkButton, - MkTextarea, - MkRadio, + FormBase, + FormButton, + FormTextarea, + FormRadios, }, emits: ['info'], @@ -102,7 +102,3 @@ export default defineComponent({ }, }); </script> - -<style lang="scss" scoped> - -</style> diff --git a/src/client/pages/settings/sounds.vue b/src/client/pages/settings/sounds.vue index fc6b751fed..f19be54e82 100644 --- a/src/client/pages/settings/sounds.vue +++ b/src/client/pages/settings/sounds.vue @@ -1,62 +1,35 @@ <template> -<div class="_section"> - <div class="_card"> - <div class="_title"><Fa :icon="faMusic"/> {{ $t('sounds') }}</div> - <div class="_content"> - <MkRange v-model:value="sfxVolume" :min="0" :max="1" :step="0.1"> - <Fa slot="icon" :icon="volumeIcon"/> - <span slot="title">{{ $t('volume') }}</span> - </MkRange> - </div> - <div class="_content"> - <MkSelect v-model:value="sfxNote"> - <template #label>{{ $t('_sfx.note') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxNote)" v-if="sfxNote"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - <MkSelect v-model:value="sfxNoteMy"> - <template #label>{{ $t('_sfx.noteMy') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxNoteMy)" v-if="sfxNoteMy"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - <MkSelect v-model:value="sfxNotification"> - <template #label>{{ $t('_sfx.notification') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxNotification)" v-if="sfxNotification"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - <MkSelect v-model:value="sfxChat"> - <template #label>{{ $t('_sfx.chat') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxChat)" v-if="sfxChat"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - <MkSelect v-model:value="sfxChatBg"> - <template #label>{{ $t('_sfx.chatBg') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxChatBg)" v-if="sfxChatBg"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - <MkSelect v-model:value="sfxAntenna"> - <template #label>{{ $t('_sfx.antenna') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxAntenna)" v-if="sfxAntenna"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - <MkSelect v-model:value="sfxChannel"> - <template #label>{{ $t('_sfx.channel') }}</template> - <option v-for="sound in sounds" :value="sound" :key="sound">{{ sound || $t('none') }}</option> - <template #text><button class="_textButton" @click="listen(sfxChannel)" v-if="sfxChannel"><Fa :icon="faPlay"/> {{ $t('listen') }}</button></template> - </MkSelect> - </div> - </div> -</div> +<FormBase> + <FormRange v-model:value="masterVolume" :min="0" :max="1" :step="0.05"> + <template #label><Fa :icon="volumeIcon" :key="volumeIcon"/> {{ $t('masterVolume') }}</template> + </FormRange> + + <FormGroup> + <template #label>{{ $t('sounds') }}</template> + <FormButton v-for="type in Object.keys(sounds)" :key="type" :center="false" @click="edit(type)"> + {{ $t('_sfx.' + type) }} + <template #suffix>{{ sounds[type].type || $t('none') }}</template> + <template #suffixIcon><Fa :icon="faChevronDown"/></template> + </FormButton> + </FormGroup> + + <FormButton @click="reset()" danger><Fa :icon="faRedo"/> {{ $t('default') }}</FormButton> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; -import { faMusic, faPlay, faVolumeUp, faVolumeMute } from '@fortawesome/free-solid-svg-icons'; -import MkSelect from '@/components/ui/select.vue'; -import MkRange from '@/components/ui/range.vue'; +import { faMusic, faPlay, faVolumeUp, faVolumeMute, faChevronDown, faRedo } from '@fortawesome/free-solid-svg-icons'; +import FormRange from '@/components/form/range.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormBase from '@/components/form/base.vue'; +import FormButton from '@/components/form/button.vue'; +import FormGroup from '@/components/form/group.vue'; import * as os from '@/os'; +import { device, defaultDeviceSettings } from '@/cold-storage'; +import { playFile } from '@/scripts/sound'; -const sounds = [ +const soundsTypes = [ null, 'syuilo/up', 'syuilo/down', @@ -73,6 +46,8 @@ const sounds = [ 'syuilo/square-pico', 'syuilo/reverved', 'syuilo/ryukyu', + 'syuilo/kick', + 'syuilo/snare', 'aisha/1', 'aisha/2', 'aisha/3', @@ -82,71 +57,98 @@ const sounds = [ export default defineComponent({ components: { - MkSelect, - MkRange, + FormSelect, + FormButton, + FormBase, + FormRange, + FormGroup, }, + emits: ['info'], + data() { return { - sounds, - faMusic, faPlay, faVolumeUp, faVolumeMute, + INFO: { + title: this.$t('sounds'), + icon: faMusic + }, + sounds: {}, + faMusic, faPlay, faVolumeUp, faVolumeMute, faChevronDown, faRedo, } }, computed: { - sfxVolume: { - get() { return this.$store.state.device.sfxVolume; }, - set(value) { this.$store.commit('device/set', { key: 'sfxVolume', value: parseFloat(value, 10) }); } + masterVolume: { // TODO: (外部)関数にcomputedを使うのはアレなので直す + get() { return device.get('sound_masterVolume'); }, + set(value) { device.set('sound_masterVolume', value); } }, - - sfxNote: { - get() { return this.$store.state.device.sfxNote; }, - set(value) { this.$store.commit('device/set', { key: 'sfxNote', value }); } - }, - - sfxNoteMy: { - get() { return this.$store.state.device.sfxNoteMy; }, - set(value) { this.$store.commit('device/set', { key: 'sfxNoteMy', value }); } - }, - - sfxNotification: { - get() { return this.$store.state.device.sfxNotification; }, - set(value) { this.$store.commit('device/set', { key: 'sfxNotification', value }); } - }, - - sfxChat: { - get() { return this.$store.state.device.sfxChat; }, - set(value) { this.$store.commit('device/set', { key: 'sfxChat', value }); } - }, - - sfxChatBg: { - get() { return this.$store.state.device.sfxChatBg; }, - set(value) { this.$store.commit('device/set', { key: 'sfxChatBg', value }); } - }, - - sfxAntenna: { - get() { return this.$store.state.device.sfxAntenna; }, - set(value) { this.$store.commit('device/set', { key: 'sfxAntenna', value }); } - }, - - sfxChannel: { - get() { return this.$store.state.device.sfxChannel; }, - set(value) { this.$store.commit('device/set', { key: 'sfxChannel', value }); } - }, - - volumeIcon: { - get() { - return this.sfxVolume === 0 ? faVolumeMute : faVolumeUp; - } + volumeIcon() { + return this.masterVolume === 0 ? faVolumeMute : faVolumeUp; } }, + created() { + this.sounds.note = device.get('sound_note'); + this.sounds.noteMy = device.get('sound_noteMy'); + this.sounds.notification = device.get('sound_notification'); + this.sounds.chat = device.get('sound_chat'); + this.sounds.chatBg = device.get('sound_chatBg'); + this.sounds.antenna = device.get('sound_antenna'); + this.sounds.channel = device.get('sound_channel'); + this.sounds.reversiPutBlack = device.get('sound_reversiPutBlack'); + this.sounds.reversiPutWhite = device.get('sound_reversiPutWhite'); + }, + + mounted() { + this.$emit('info', this.INFO); + }, + methods: { - listen(sound) { - const audio = new Audio(`/assets/sounds/${sound}.mp3`); - audio.volume = this.$store.state.device.sfxVolume; - audio.play(); + async edit(type) { + const { canceled, result } = await os.form(this.$t('_sfx.' + type), { + type: { + type: 'enum', + enum: soundsTypes.map(x => ({ + value: x, + label: x == null ? this.$t('none') : x, + })), + label: this.$t('sound'), + default: this.sounds[type].type, + }, + volume: { + type: 'range', + mim: 0, + max: 1, + step: 0.05, + label: this.$t('volume'), + default: this.sounds[type].volume + }, + listen: { + type: 'button', + content: this.$t('listen'), + action: (_, values) => { + playFile(values.type, values.volume); + } + } + }); + if (canceled) return; + + const v = { + type: result.type, + volume: result.volume, + }; + + device.set('sound_' + type, v); + this.sounds[type] = v; }, + + reset() { + for (const sound of Object.keys(this.sounds)) { + const v = defaultDeviceSettings['sound_' + sound]; + device.set('sound_' + sound, v); + this.sounds[sound] = v; + } + } } }); </script> diff --git a/src/client/pages/settings/theme.install.vue b/src/client/pages/settings/theme.install.vue new file mode 100644 index 0000000000..c3f2565cca --- /dev/null +++ b/src/client/pages/settings/theme.install.vue @@ -0,0 +1,106 @@ +<template> +<FormBase> + <FormGroup> + <FormTextarea v-model:value="installThemeCode"> + <span>{{ $t('_theme.code') }}</span> + </FormTextarea> + <FormButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</FormButton> + </FormGroup> + + <FormButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</FormButton> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/form/button.vue'; +import { applyTheme, validateTheme } from '@/scripts/theme'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$t('_theme.install'), + icon: faDownload + }, + installThemeCode: null, + faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye + } + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + parseThemeCode(code) { + let theme; + + try { + theme = JSON5.parse(code); + } catch (e) { + os.dialog({ + type: 'error', + text: this.$t('_theme.invalid') + }); + return false; + } + if (!validateTheme(theme)) { + os.dialog({ + type: 'error', + text: this.$t('_theme.invalid') + }); + return false; + } + if (this.$store.state.device.themes.some(t => t.id === theme.id)) { + os.dialog({ + type: 'info', + text: this.$t('_theme.alreadyInstalled') + }); + return false; + } + + return theme; + }, + + preview(code) { + const theme = this.parseThemeCode(code); + if (theme) applyTheme(theme, false); + }, + + install(code) { + const theme = this.parseThemeCode(code); + if (!theme) return; + const themes = this.$store.state.device.themes.concat(theme); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + os.dialog({ + type: 'success', + text: this.$t('_theme.installed', { name: theme.name }) + }); + }, + } +}); +</script> diff --git a/src/client/pages/settings/theme.manage.vue b/src/client/pages/settings/theme.manage.vue new file mode 100644 index 0000000000..a7bd97a4d5 --- /dev/null +++ b/src/client/pages/settings/theme.manage.vue @@ -0,0 +1,103 @@ +<template> +<FormBase> + <FormSelect v-model:value="selectedThemeId"> + <template #label>{{ $t('installedThemes') }}</template> + <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + <optgroup :label="$t('builtinThemes')"> + <option v-for="x in builtinThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <template v-if="selectedTheme"> + <FormInput readonly :value="selectedTheme.author"> + <span>{{ $t('author') }}</span> + </FormInput> + <FormTextarea readonly tall :value="selectedThemeCode"> + <span>{{ $t('_theme.code') }}</span> + <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template> + </FormTextarea> + <FormButton @click="uninstall()" danger v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</FormButton> + </template> +</FormBase> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue'; +import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons'; +import * as JSON5 from 'json5'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormInput from '@/components/form/input.vue'; +import FormButton from '@/components/form/button.vue'; +import { Theme, builtinThemes } from '@/scripts/theme'; +import copyToClipboard from '@/scripts/copy-to-clipboard'; +import * as os from '@/os'; + +export default defineComponent({ + components: { + FormTextarea, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormInput, + FormButton, + }, + + emits: ['info'], + + data() { + return { + INFO: { + title: this.$t('_theme.manage'), + icon: faFolderOpen + }, + builtinThemes, + selectedThemeId: null, + faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye + } + }, + + computed: { + themes(): Theme[] { + return builtinThemes.concat(this.$store.state.device.themes); + }, + + installedThemes(): Theme[] { + return this.$store.state.device.themes; + }, + + selectedTheme() { + if (this.selectedThemeId == null) return null; + return this.themes.find(x => x.id === this.selectedThemeId); + }, + + selectedThemeCode() { + if (this.selectedTheme == null) return null; + return JSON5.stringify(this.selectedTheme, null, '\t'); + }, + }, + + mounted() { + this.$emit('info', this.INFO); + }, + + methods: { + copyThemeCode() { + copyToClipboard(this.selectedThemeCode); + os.success(); + }, + + uninstall() { + const theme = this.selectedTheme; + const themes = this.$store.state.device.themes.filter(t => t.id != theme.id); + this.$store.commit('device/set', { + key: 'themes', value: themes + }); + os.success(); + }, + } +}); +</script> diff --git a/src/client/pages/settings/theme.vue b/src/client/pages/settings/theme.vue index c023d56dea..50dcf4952c 100644 --- a/src/client/pages/settings/theme.vue +++ b/src/client/pages/settings/theme.vue @@ -1,7 +1,26 @@ <template> -<div class=""> - <div class="rfqxtzch _card _vMargin"> - <div class="_content"> +<FormBase> + <FormSelect v-model:value="lightTheme" v-if="!darkMode"> + <template #label>{{ $t('themeForLightMode') }}</template> + <optgroup :label="$t('lightThemes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('darkThemes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + <FormSelect v-model:value="darkTheme" v-else> + <template #label>{{ $t('themeForDarkMode') }}</template> + <optgroup :label="$t('darkThemes')"> + <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + <optgroup :label="$t('lightThemes')"> + <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> + </optgroup> + </FormSelect> + + <FormGroup> + <div class="rfqxtzch _formItem _formPanel"> <div class="darkMode" :class="{ disabled: syncDeviceDarkMode }"> <div class="toggleWrapper"> <input type="checkbox" class="dn" id="dn" v-model="darkMode" :disabled="syncDeviceDarkMode"/> @@ -23,85 +42,47 @@ </div> </div> </div> - <div class="_content"> - <MkSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</MkSwitch> - </div> - </div> - <div class="_card _vMargin"> - <div class="_content"> - <MkSelect v-model:value="lightTheme"> - <template #label>{{ $t('themeForLightMode') }}</template> - <optgroup :label="$t('lightThemes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('darkThemes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model:value="darkTheme"> - <template #label>{{ $t('themeForDarkMode') }}</template> - <optgroup :label="$t('darkThemes')"> - <option v-for="x in darkThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$t('lightThemes')"> - <option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </optgroup> - </MkSelect> - <a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>・<MkA to="/theme-editor" class="_link">{{ $t('_theme.make') }}</MkA> - </div> - <div class="_content"> - <MkButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</MkButton> - <MkButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</MkButton> - </div> - </div> - <div class="_card _vMargin"> - <div class="_title"><Fa :icon="faDownload"/> {{ $t('_theme.install') }}</div> - <div class="_content"> - <MkTextarea v-model:value="installThemeCode"> - <span>{{ $t('_theme.code') }}</span> - </MkTextarea> - <MkButton @click="() => install(installThemeCode)" :disabled="installThemeCode == null" primary inline><Fa :icon="faCheck"/> {{ $t('install') }}</MkButton> - <MkButton @click="() => preview(installThemeCode)" :disabled="installThemeCode == null" inline><Fa :icon="faEye"/> {{ $t('preview') }}</MkButton> - </div> - </div> - <div class="_card _vMargin"> - <div class="_title"><Fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</div> - <div class="_content"> - <MkSelect v-model:value="selectedThemeId"> - <option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option> - </MkSelect> - <template v-if="selectedTheme"> - <MkTextarea readonly tall :value="selectedThemeCode"> - <span>{{ $t('_theme.code') }}</span> - <template #desc><button @click="copyThemeCode()" class="_textButton">{{ $t('copy') }}</button></template> - </MkTextarea> - <MkButton @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><Fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</MkButton> - </template> - </div> - </div> -</div> + <FormSwitch v-model:value="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</FormSwitch> + </FormGroup> + + <FormButton primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</FormButton> + <FormButton primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</FormButton> + + <FormGroup> + <FormLink to="https://assets.msky.cafe/theme/list" external>{{ $t('_theme.explore') }}</FormLink> + <FormLink to="/theme-editor">{{ $t('_theme.make') }}</FormLink> + </FormGroup> + + <FormLink to="/settings/theme/install"><template #icon><Fa :icon="faDownload"/></template>{{ $t('_theme.install') }}</FormLink> + + <FormLink to="/settings/theme/manage"><template #icon><Fa :icon="faFolderOpen"/></template>{{ $t('_theme.manage') }}</FormLink> +</FormBase> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } from '@fortawesome/free-solid-svg-icons'; -import * as JSON5 from 'json5'; -import MkButton from '@/components/ui/button.vue'; -import MkSelect from '@/components/ui/select.vue'; -import MkSwitch from '@/components/ui/switch.vue'; -import MkTextarea from '@/components/ui/textarea.vue'; -import { Theme, builtinThemes, applyTheme, validateTheme } from '@/scripts/theme'; +import FormSwitch from '@/components/form/switch.vue'; +import FormSelect from '@/components/form/select.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormBase from '@/components/form/base.vue'; +import FormGroup from '@/components/form/group.vue'; +import FormLink from '@/components/form/link.vue'; +import FormButton from '@/components/form/button.vue'; +import { Theme, builtinThemes, applyTheme } from '@/scripts/theme'; import { selectFile } from '@/scripts/select-file'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; -import copyToClipboard from '@/scripts/copy-to-clipboard'; import * as os from '@/os'; export default defineComponent({ components: { - MkButton, - MkSelect, - MkSwitch, - MkTextarea, + FormSwitch, + FormSelect, + FormRadios, + FormBase, + FormGroup, + FormLink, + FormButton, }, emits: ['info'], @@ -113,8 +94,6 @@ export default defineComponent({ icon: faPalette }, builtinThemes, - installThemeCode: null, - selectedThemeId: null, wallpaper: localStorage.getItem('wallpaper'), faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt, faEye } @@ -156,16 +135,6 @@ export default defineComponent({ get() { return this.$store.state.device.syncDeviceDarkMode; }, set(value) { this.$store.commit('device/set', { key: 'syncDeviceDarkMode', value }); } }, - - selectedTheme() { - if (this.selectedThemeId == null) return null; - return this.themes.find(x => x.id === this.selectedThemeId); - }, - - selectedThemeCode() { - if (this.selectedTheme == null) return null; - return JSON5.stringify(this.selectedTheme, null, '\t'); - }, }, watch: { @@ -207,292 +176,230 @@ export default defineComponent({ this.wallpaper = file.url; }); }, - - copyThemeCode() { - copyToClipboard(this.selectedThemeCode); - os.success(); - }, - - parseThemeCode(code) { - let theme; - - try { - theme = JSON5.parse(code); - } catch (e) { - os.dialog({ - type: 'error', - text: this.$t('_theme.invalid') - }); - return false; - } - if (!validateTheme(theme)) { - os.dialog({ - type: 'error', - text: this.$t('_theme.invalid') - }); - return false; - } - if (this.$store.state.device.themes.some(t => t.id === theme.id)) { - os.dialog({ - type: 'info', - text: this.$t('_theme.alreadyInstalled') - }); - return false; - } - - return theme; - }, - - preview(code) { - const theme = this.parseThemeCode(code); - if (theme) applyTheme(theme, false); - }, - - install(code) { - const theme = this.parseThemeCode(code); - if (!theme) return; - const themes = this.$store.state.device.themes.concat(theme); - this.$store.commit('device/set', { - key: 'themes', value: themes - }); - os.dialog({ - type: 'success', - text: this.$t('_theme.installed', { name: theme.name }) - }); - }, - - uninstall() { - const theme = this.selectedTheme; - const themes = this.$store.state.device.themes.filter(t => t.id != theme.id); - this.$store.commit('device/set', { - key: 'themes', value: themes - }); - os.success(); - }, } }); </script> <style lang="scss" scoped> .rfqxtzch { - > ._content { - > .darkMode { - position: relative; - padding: 32px 0; + padding: 16px; - &.disabled { - opacity: 0.7; + > .darkMode { + position: relative; + padding: 32px 0; - &, * { - cursor: not-allowed !important; - } + &.disabled { + opacity: 0.7; + + &, * { + cursor: not-allowed !important; } + } - .toggleWrapper { + .toggleWrapper { + position: absolute; + top: 50%; + left: 50%; + overflow: hidden; + padding: 0 100px; + transform: translate3d(-50%, -50%, 0); + + input { position: absolute; - top: 50%; - left: 50%; - overflow: hidden; - padding: 0 100px; - transform: translate3d(-50%, -50%, 0); + left: -99em; + } + } - input { - position: absolute; - left: -99em; - } + .toggle { + cursor: pointer; + display: inline-block; + position: relative; + width: 90px; + height: 50px; + background-color: #83D8FF; + border-radius: 90px - 6; + transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + + > .before, > .after { + position: absolute; + top: 15px; + font-size: 18px; + transition: color 1s ease; } - .toggle { - cursor: pointer; - display: inline-block; - position: relative; - width: 90px; - height: 50px; - background-color: #83D8FF; - border-radius: 90px - 6; - transition: background-color 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + > .before { + left: -70px; + color: var(--accent); + } - > .before, > .after { - position: absolute; - top: 15px; - font-size: 18px; - transition: color 1s ease; - } + > .after { + right: -68px; + color: var(--fg); + } + } + + .toggle__handler { + display: inline-block; + position: relative; + z-index: 1; + top: 3px; + left: 3px; + width: 50px - 6; + height: 50px - 6; + background-color: #FFCF96; + border-radius: 50px; + box-shadow: 0 2px 6px rgba(0,0,0,.3); + transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; + transform: rotate(-45deg); + + .crater { + position: absolute; + background-color: #E8CDA5; + opacity: 0; + transition: opacity 200ms ease-in-out !important; + border-radius: 100%; + } + + .crater--1 { + top: 18px; + left: 10px; + width: 4px; + height: 4px; + } + + .crater--2 { + top: 28px; + left: 22px; + width: 6px; + height: 6px; + } + + .crater--3 { + top: 10px; + left: 25px; + width: 8px; + height: 8px; + } + } + + .star { + position: absolute; + background-color: #ffffff; + transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + border-radius: 50%; + } + + .star--1 { + top: 10px; + left: 35px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--2 { + top: 18px; + left: 28px; + z-index: 1; + width: 30px; + height: 3px; + } + + .star--3 { + top: 27px; + left: 40px; + z-index: 0; + width: 30px; + height: 3px; + } + + .star--4, + .star--5, + .star--6 { + opacity: 0; + transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } + + .star--4 { + top: 16px; + left: 11px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + .star--5 { + top: 32px; + left: 17px; + z-index: 0; + width: 3px; + height: 3px; + transform: translate3d(3px,0,0); + } + + .star--6 { + top: 36px; + left: 28px; + z-index: 0; + width: 2px; + height: 2px; + transform: translate3d(3px,0,0); + } + + input:checked { + + .toggle { + background-color: #749DD6; > .before { - left: -70px; - color: var(--accent); + color: var(--fg); } > .after { - right: -68px; - color: var(--fg); - } - } - - .toggle__handler { - display: inline-block; - position: relative; - z-index: 1; - top: 3px; - left: 3px; - width: 50px - 6; - height: 50px - 6; - background-color: #FFCF96; - border-radius: 50px; - box-shadow: 0 2px 6px rgba(0,0,0,.3); - transition: all 400ms cubic-bezier(0.68, -0.55, 0.265, 1.55) !important; - transform: rotate(-45deg); - - .crater { - position: absolute; - background-color: #E8CDA5; - opacity: 0; - transition: opacity 200ms ease-in-out !important; - border-radius: 100%; + color: var(--accent); } - .crater--1 { - top: 18px; - left: 10px; + .toggle__handler { + background-color: #FFE5B5; + transform: translate3d(40px, 0, 0) rotate(0); + + .crater { opacity: 1; } + } + + .star--1 { + width: 2px; + height: 2px; + } + + .star--2 { width: 4px; height: 4px; + transform: translate3d(-5px, 0, 0); } - .crater--2 { - top: 28px; - left: 22px; - width: 6px; - height: 6px; + .star--3 { + width: 2px; + height: 2px; + transform: translate3d(-7px, 0, 0); } - .crater--3 { - top: 10px; - left: 25px; - width: 8px; - height: 8px; + .star--4, + .star--5, + .star--6 { + opacity: 1; + transform: translate3d(0,0,0); } - } - .star { - position: absolute; - background-color: #ffffff; - transition: all 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - border-radius: 50%; - } + .star--4 { + transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } - .star--1 { - top: 10px; - left: 35px; - z-index: 0; - width: 30px; - height: 3px; - } + .star--5 { + transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; + } - .star--2 { - top: 18px; - left: 28px; - z-index: 1; - width: 30px; - height: 3px; - } - - .star--3 { - top: 27px; - left: 40px; - z-index: 0; - width: 30px; - height: 3px; - } - - .star--4, - .star--5, - .star--6 { - opacity: 0; - transition: all 300ms 0 cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - - .star--4 { - top: 16px; - left: 11px; - z-index: 0; - width: 2px; - height: 2px; - transform: translate3d(3px,0,0); - } - - .star--5 { - top: 32px; - left: 17px; - z-index: 0; - width: 3px; - height: 3px; - transform: translate3d(3px,0,0); - } - - .star--6 { - top: 36px; - left: 28px; - z-index: 0; - width: 2px; - height: 2px; - transform: translate3d(3px,0,0); - } - - input:checked { - + .toggle { - background-color: #749DD6; - - > .before { - color: var(--fg); - } - - > .after { - color: var(--accent); - } - - .toggle__handler { - background-color: #FFE5B5; - transform: translate3d(40px, 0, 0) rotate(0); - - .crater { opacity: 1; } - } - - .star--1 { - width: 2px; - height: 2px; - } - - .star--2 { - width: 4px; - height: 4px; - transform: translate3d(-5px, 0, 0); - } - - .star--3 { - width: 2px; - height: 2px; - transform: translate3d(-7px, 0, 0); - } - - .star--4, - .star--5, - .star--6 { - opacity: 1; - transform: translate3d(0,0,0); - } - - .star--4 { - transition: all 300ms 200ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - - .star--5 { - transition: all 300ms 300ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } - - .star--6 { - transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; - } + .star--6 { + transition: all 300ms 400ms cubic-bezier(0.445, 0.05, 0.55, 0.95) !important; } } } diff --git a/src/client/pages/settings/word-mute.vue b/src/client/pages/settings/word-mute.vue index 444b2e598c..3148c635bc 100644 --- a/src/client/pages/settings/word-mute.vue +++ b/src/client/pages/settings/word-mute.vue @@ -1,47 +1,53 @@ <template> -<div class="_section"> - <div class="_card"> - <MkTab v-model:value="tab"> - <option value="soft">{{ $t('_wordMute.soft') }}</option> - <option value="hard">{{ $t('_wordMute.hard') }}</option> - </MkTab> - <div class="_content"> +<div> + <MkTab v-model:value="tab"> + <option value="soft">{{ $t('_wordMute.soft') }}</option> + <option value="hard">{{ $t('_wordMute.hard') }}</option> + </MkTab> + <FormBase> + <div class="_formItem"> <div v-show="tab === 'soft'"> <MkInfo>{{ $t('_wordMute.softDescription') }}</MkInfo> - <MkTextarea v-model:value="softMutedWords"> + <FormTextarea v-model:value="softMutedWords"> <span>{{ $t('_wordMute.muteWords') }}</span> <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> - </MkTextarea> + </FormTextarea> </div> <div v-show="tab === 'hard'"> <MkInfo>{{ $t('_wordMute.hardDescription') }}</MkInfo> - <MkTextarea v-model:value="hardMutedWords" style="margin-bottom: 16px;"> + <FormTextarea v-model:value="hardMutedWords"> <span>{{ $t('_wordMute.muteWords') }}</span> <template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template> - </MkTextarea> - <div v-if="hardWordMutedNotesCount != null" class="_caption">{{ $t('_wordMute.mutedNotes') }}: {{ hardWordMutedNotesCount | number }}</div> + </FormTextarea> + <FormKeyValueView v-if="hardWordMutedNotesCount != null"> + <template #key>{{ $t('_wordMute.mutedNotes') }}</template> + <template #value>{{ number(hardWordMutedNotesCount) }}</template> + </FormKeyValueView> </div> </div> - <div class="_footer"> - <MkButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</MkButton> - </div> - </div> + <FormButton @click="save()" primary inline :disabled="!changed"><Fa :icon="faSave"/> {{ $t('save') }}</FormButton> + </FormBase> </div> </template> <script lang="ts"> import { defineComponent } from 'vue'; import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons'; -import MkButton from '@/components/ui/button.vue'; -import MkTextarea from '@/components/ui/textarea.vue'; +import FormTextarea from '@/components/form/textarea.vue'; +import FormBase from '@/components/form/base.vue'; +import FormKeyValueView from '@/components/form/key-value-view.vue'; +import FormButton from '@/components/form/button.vue'; import MkTab from '@/components/tab.vue'; import MkInfo from '@/components/ui/info.vue'; import * as os from '@/os'; +import number from '@/filters/number'; export default defineComponent({ components: { - MkButton, - MkTextarea, + FormBase, + FormButton, + FormTextarea, + FormKeyValueView, MkTab, MkInfo, }, @@ -97,6 +103,8 @@ export default defineComponent({ }); this.changed = false; }, + + number } }); </script> diff --git a/src/client/pages/user/follow-list.vue b/src/client/pages/user/follow-list.vue index 6761210ff6..90a67e9a8e 100644 --- a/src/client/pages/user/follow-list.vue +++ b/src/client/pages/user/follow-list.vue @@ -1,5 +1,5 @@ <template> -<div class="_section"> +<div> <MkPagination :pagination="pagination" #default="{items}" class="mk-following-or-followers _content" ref="list"> <div class="users"> <MkUserInfo class="user" v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :user="user" :key="user.id"/> diff --git a/src/client/pages/user/index.activity.vue b/src/client/pages/user/index.activity.vue index 30c02ec54a..1f059146e3 100644 --- a/src/client/pages/user/index.activity.vue +++ b/src/client/pages/user/index.activity.vue @@ -1,15 +1,24 @@ <template> -<div> - <div ref="chart"></div> -</div> +<MkContainer> + <template #header><Fa :icon="faChartBar" style="margin-right: 0.5em;"/>{{ $t('activity') }}</template> + + <div style="padding: 8px;"> + <div ref="chart"></div> + </div> +</MkContainer> </template> <script lang="ts"> import { defineComponent } from 'vue'; import ApexCharts from 'apexcharts'; +import { faChartBar } from '@fortawesome/free-solid-svg-icons'; import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; export default defineComponent({ + components: { + MkContainer, + }, props: { user: { type: Object, @@ -25,7 +34,8 @@ export default defineComponent({ return { fetching: true, data: [], - peak: null + peak: null, + faChartBar, }; }, mounted() { diff --git a/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue index aabcbebe8a..7d498cfb30 100644 --- a/src/client/pages/user/index.photos.vue +++ b/src/client/pages/user/index.photos.vue @@ -1,29 +1,43 @@ <template> -<div class="ujigsodd"> - <MkLoading v-if="fetching"/> - <div class="stream" v-if="!fetching && images.length > 0"> - <MkA v-for="image in images" - class="img" - :style="`background-image: url(${thumbnail(image.file)})`" - :to="notePage(image.note)" - ></MkA> +<MkContainer> + <template #header><Fa :icon="faImage" style="margin-right: 0.5em;"/>{{ $t('images') }}</template> + <div class="ujigsodd"> + <MkLoading v-if="fetching"/> + <div class="stream" v-if="!fetching && images.length > 0"> + <MkA v-for="image in images" + class="img" + :style="`background-image: url(${thumbnail(image.file)})`" + :to="notePage(image.note)" + ></MkA> + </div> + <p class="empty" v-if="!fetching && images.length == 0">{{ $t('nothing') }}</p> </div> - <p class="empty" v-if="!fetching && images.length == 0">{{ $t('nothing') }}</p> -</div> +</MkContainer> </template> <script lang="ts"> import { defineComponent } from 'vue'; +import { faImage } from '@fortawesome/free-solid-svg-icons'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import notePage from '../../filters/note'; import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; export default defineComponent({ - props: ['user'], + components: { + MkContainer, + }, + props: { + user: { + type: Object, + required: true + }, + }, data() { return { fetching: true, - images: [] + images: [], + faImage }; }, mounted() { @@ -37,7 +51,7 @@ export default defineComponent({ os.api('users/notes', { userId: this.user.id, fileType: image, - excludeNsfw: !this.$store.state.device.alwaysShowNsfw, + excludeNsfw: this.$store.state.device.nsfw !== 'ignore', limit: 9, }).then(notes => { for (const note of notes) { @@ -66,6 +80,8 @@ export default defineComponent({ <style lang="scss" scoped> .ujigsodd { + padding: 8px; + > .stream { display: flex; justify-content: center; diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue index 015d83f755..ceafa7ba97 100644 --- a/src/client/pages/user/index.vue +++ b/src/client/pages/user/index.vue @@ -1,115 +1,113 @@ <template> -<div class="mk-user-page" v-if="user" v-size="{ max: [500] }"> - <!-- TODO --> - <!-- <div class="punished" v-if="user.isSuspended"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div> --> - <!-- <div class="punished" v-if="user.isSilenced"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div> --> +<div> + <div class="mk-user-page" v-if="user" v-size="{ max: [500] }" :class="{ _section: narrow === false }"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><Fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div> --> - <div class="profile _section _fitBottom"> - <MkRemoteCaution v-if="user.host != null" :href="user.url" class="_content _vMargin"/> + <div class="main"> + <div class="profile _vMargin" :class="{ _section: narrow === true }"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="_content _vMargin"/> - <div class="_content _vMargin" :key="user.id"> - <div class="banner-container" :style="style"> - <div class="banner" ref="banner" :style="style"></div> - <div class="fade"></div> - <div class="title"> - <MkUserName class="name" :user="user" :nowrap="true"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true" /></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')" style="color: var(--badge);"><Fa :icon="farBookmark"/></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> + <div class="_content _panel _vMargin" :key="user.id"> + <div class="banner-container" :style="style"> + <div class="banner" ref="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></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')" style="color: var(--badge);"><Fa :icon="farBookmark"/></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> + </div> + </div> + <span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span> + <div class="actions" v-if="$store.getters.isSignedIn"> + <button @click="menu" class="menu _button"><Fa :icon="faEllipsisH"/></button> + <MkFollowButton v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true" /></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')" style="color: var(--badge);"><Fa :icon="farBookmark"/></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> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $t('noAccountDescription') }}</p> + </div> + <div class="fields system"> + <dl class="field" v-if="user.location"> + <dt class="name"><Fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl class="field" v-if="user.birthday"> + <dt class="name"><Fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><Fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div class="fields" v-if="user.fields.length > 0"> + <dl class="field" v-for="(field, i) in user.fields" :key="i"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $t('notes') }}</span> + </MkA> + <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $t('following') }}</span> + </MkA> + <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $t('followers') }}</span> + </MkA> </div> </div> - <span class="followed" v-if="$store.getters.isSignedIn && $store.state.i.id != user.id && user.isFollowed">{{ $t('followsYou') }}</span> - <div class="actions" v-if="$store.getters.isSignedIn"> - <button @click="menu" class="menu _button"><Fa :icon="faEllipsisH"/></button> - <MkFollowButton v-if="$store.state.i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + + <template v-if="page === 'index'"> + <div v-if="user.pinnedNotes.length > 0" :class="{ _section: narrow === true, _vMargin: narrow === false }"> + <XNote v-for="note in user.pinnedNotes" class="note _content _vMargin" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/> </div> - </div> - <MkAvatar class="avatar" :user="user" :disable-preview="true"/> - <div class="title"> - <MkUserName :user="user" :nowrap="false" class="name"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true" /></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')" style="color: var(--badge);"><Fa :icon="farBookmark"/></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> + <div v-if="narrow === true" class="_section"> + <XPhotos class="_content _vMargin" :user="user" :key="user.id"/> + <XActivity class="_content _vMargin" :user="user" :key="user.id"/> </div> - </div> - <div class="description"> - <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$store.state.i" :custom-emojis="user.emojis"/> - <p v-else class="empty">{{ $t('noAccountDescription') }}</p> - </div> - <div class="fields system"> - <dl class="field" v-if="user.location"> - <dt class="name"><Fa :icon="faMapMarker" fixed-width/> {{ $t('location') }}</dt> - <dd class="value">{{ user.location }}</dd> - </dl> - <dl class="field" v-if="user.birthday"> - <dt class="name"><Fa :icon="faBirthdayCake" fixed-width/> {{ $t('birthday') }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> - </dl> - <dl class="field"> - <dt class="name"><Fa :icon="faCalendarAlt" fixed-width/> {{ $t('registeredDate') }}</dt> - <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> - </dl> - </div> - <div class="fields" v-if="user.fields.length > 0"> - <dl class="field" v-for="(field, i) in user.fields" :key="i"> - <dt class="name"> - <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> - </dt> - <dd class="value"> - <Mfm :text="field.value" :author="user" :i="$store.state.i" :custom-emojis="user.emojis" :colored="false"/> - </dd> - </dl> - </div> - <div class="status"> - <MkA :to="userPage(user)" :class="{ active: page === 'index' }"> - <b>{{ number(user.notesCount) }}</b> - <span>{{ $t('notes') }}</span> - </MkA> - <MkA :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> - <b>{{ number(user.followingCount) }}</b> - <span>{{ $t('following') }}</span> - </MkA> - <MkA :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> - <b>{{ number(user.followersCount) }}</b> - <span>{{ $t('followers') }}</span> - </MkA> - </div> + <div :class="{ _section: narrow === true, _vMargin: narrow === false }"> + <XUserTimeline :user="user" class="_content"/> + </div> + </template> + <XFollowList v-else-if="page === 'following'" :class="{ _section: narrow === true, _vMargin: narrow === false }" type="following" :user="user"/> + <XFollowList v-else-if="page === 'followers'" :class="{ _section: narrow === true, _vMargin: narrow === false }" type="followers" :user="user"/> + </div> + <div class="side" v-if="narrow === false"> + <XPhotos class="_vMargin" :user="user" :key="user.id"/> + <XActivity class="_vMargin" :user="user" :key="user.id"/> </div> </div> - - <template v-if="page === 'index'"> - <div class="_section"> - <div class="_content _vMargin" v-if="user.pinnedNotes.length > 0"> - <XNote v-for="note in user.pinnedNotes" class="note _vMargin" :note="note" @update:note="pinnedNoteUpdated(note, $event)" :key="note.id" :detail="true" :pinned="true"/> - </div> - <MkFolder :body-togglable="true" class="_content _vMargin" persist-key="user-images"> - <template #header><Fa :icon="faImage" style="margin-right: 0.5em;"/>{{ $t('images') }}</template> - <div> - <XPhotos :user="user" :key="user.id"/> - </div> - </MkFolder> - <MkFolder :body-togglable="true" class="_content _vMargin" persist-key="user-activity"> - <template #header><Fa :icon="faChartBar" style="margin-right: 0.5em;"/>{{ $t('activity') }}</template> - <div> - <XActivity :user="user" :key="user.id"/> - </div> - </MkFolder> - </div> - <div class="_section"> - <XUserTimeline :user="user" class="_content"/> - </div> - </template> - <XFollowList v-else-if="page === 'following'" type="following" :user="user"/> - <XFollowList v-else-if="page === 'followers'" type="followers" :user="user"/> -</div> -<div v-else-if="error"> - <MkError @retry="fetch()"/> + <div v-else-if="error"> + <MkError @retry="fetch()"/> + </div> </div> </template> @@ -170,6 +168,7 @@ export default defineComponent({ user: null, error: null, parallaxAnimationId: null, + narrow: null, faExclamationTriangle, faEllipsisH, faRobot, faLock, faBookmark, farBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt }; }, @@ -197,6 +196,7 @@ export default defineComponent({ mounted() { window.requestAnimationFrame(this.parallaxLoop); + this.narrow = this.$el.clientWidth < 1000; }, beforeUnmount() { @@ -254,220 +254,234 @@ export default defineComponent({ <style lang="scss" scoped> .mk-user-page { - > .punished { - font-size: 0.8em; - padding: 16px; - } + display: flex; + max-width: 1050px; + margin: 0 auto; + + > .main { + flex: 1; - > .profile { - > ._content { - position: relative; - overflow: hidden; + > .punished { + font-size: 0.8em; + padding: 16px; + } - > .banner-container { + > .profile { + > ._content { position: relative; - height: 250px; overflow: hidden; - background-size: cover; - background-position: center; - border-radius: 12px; - > .banner { - height: 100%; - background-color: #4c5e6d; + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; background-size: cover; background-position: center; - box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; - will-change: background-position; - } - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 78px; - background: linear-gradient(transparent, rgba(#000, 0.7)); - } + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } - > .followed { - position: absolute; - top: 12px; - left: 12px; - padding: 4px 8px; - color: #fff; - background: rgba(0, 0, 0, 0.7); - font-size: 0.7em; - border-radius: 6px; - } + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } - > .actions { - position: absolute; - top: 12px; - right: 12px; - -webkit-backdrop-filter: blur(8px); - backdrop-filter: blur(8px); - background: rgba(0, 0, 0, 0.2); - padding: 8px; - border-radius: 24px; - - > .menu { - vertical-align: bottom; - height: 31px; - width: 31px; + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; color: #fff; - text-shadow: 0 0 8px #000; - font-size: 16px; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; } - > .koudoku { - margin-left: 4px; - vertical-align: bottom; - } - } + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; - > .title { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 0 0 8px 154px; - box-sizing: border-box; - color: #fff; + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } - > .name { - display: block; - margin: 0; - line-height: 32px; - font-weight: bold; - font-size: 1.8em; - text-shadow: 0 0 8px #000; + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } } - > .bottom { - > * { - display: inline-block; - margin-right: 16px; - line-height: 20px; - opacity: 0.8; + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; - &.username { - font-weight: bold; + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } } } } } - } - > .title { - display: none; - text-align: center; - padding: 50px 8px 16px 8px; - font-weight: bold; - border-bottom: solid 1px var(--divider); - - > .bottom { - > * { - display: inline-block; - margin-right: 8px; - opacity: 0.8; - } - } - } - - > .avatar { - display: block; - position: absolute; - top: 170px; - left: 16px; - z-index: 2; - width: 120px; - height: 120px; - box-shadow: 1px 1px 3px rgba(#000, 0.2); - } - - > .description { - padding: 24px 24px 24px 154px; - font-size: 0.95em; - - > .empty { - margin: 0; - opacity: 0.5; - } - } - - > .fields { - padding: 24px; - font-size: 0.9em; - border-top: solid 1px var(--divider); - - > .field { - display: flex; - padding: 0; - margin: 0; - align-items: center; - - &:not(:last-child) { - margin-bottom: 8px; - } - - > .name { - width: 30%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-weight: bold; - text-align: center; - } - - > .value { - width: 70%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - } - - &.system > .field > .name { - } - } - - > .status { - display: flex; - padding: 24px; - border-top: solid 1px var(--divider); - - > a { - flex: 1; + > .title { + display: none; text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 1px var(--divider); - &.active { - color: var(--accent); + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 1px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } - &:hover { - text-decoration: none; + &.system > .field > .name { } + } - > b { - display: block; - line-height: 16px; - } + > .status { + display: flex; + padding: 24px; + border-top: solid 1px var(--divider); - > span { - font-size: 70%; + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } } } } } + + > .content { + margin-bottom: var(--margin); + } } - > .content { - margin-bottom: var(--margin); + > .side { + flex-basis: 300px; + margin-left: var(--margin); } &.max-width_500px { - > .profile > ._content { + display: block; + + > .main > .profile > ._content { > .banner-container { height: 140px; diff --git a/src/client/pages/welcome.entrance.vue b/src/client/pages/welcome.entrance.vue index b1cd6d50c6..d5ea47bb85 100644 --- a/src/client/pages/welcome.entrance.vue +++ b/src/client/pages/welcome.entrance.vue @@ -1,11 +1,5 @@ <template> <div class="rsqzvsbo _section" v-if="meta"> - <div class="about"> - <h1>{{ instanceName }}</h1> - <div class="desc" v-html="meta.description || $t('introMisskey')"></div> - <MkButton @click="signup()" style="display: inline-block; margin-right: 16px;" primary>{{ $t('signup') }}</MkButton> - <MkButton @click="signin()" style="display: inline-block;">{{ $t('login') }}</MkButton> - </div> <div class="blocks"> <XBlock class="block" v-for="path in meta.pinnedPages" :initial-path="path" :key="path"/> </div> @@ -68,28 +62,6 @@ export default defineComponent({ .rsqzvsbo { text-align: center; - > .about { - display: inline-block; - padding: 24px; - margin-bottom: var(--margin); - -webkit-backdrop-filter: blur(8px); - backdrop-filter: blur(8px); - background: rgba(0, 0, 0, 0.5); - border-radius: var(--radius); - text-align: center; - box-sizing: border-box; - min-width: 300px; - max-width: 800px; - - &, * { - color: #fff !important; - } - - > h1 { - margin: 0 0 16px 0; - } - } - > .blocks { display: grid; grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); diff --git a/src/client/router.ts b/src/client/router.ts index 5ad3345d55..a21c6494b9 100644 --- a/src/client/router.ts +++ b/src/client/router.ts @@ -22,7 +22,7 @@ export const router = createRouter({ { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) }, { path: '/@:user/pages/:pageName/view-source', component: page('page-editor/page-editor'), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, { path: '/@:acct/room', props: true, component: page('room/room') }, - { path: '/settings/:page?', name: 'settings', component: page('settings/index'), props: route => ({ page: route.params.page || null }) }, + { path: '/settings/:page(.*)?', name: 'settings', component: page('settings/index'), props: route => ({ page: route.params.page || null }) }, { path: '/announcements', component: page('announcements') }, { path: '/about', component: page('about') }, { path: '/about-misskey', component: page('about-misskey') }, @@ -57,7 +57,6 @@ export const router = createRouter({ { path: '/my/groups/:group', component: page('my-groups/group') }, { path: '/my/antennas', component: page('my-antennas/index') }, { path: '/my/clips', component: page('my-clips/index') }, - { path: '/my/apps', component: page('apps') }, { path: '/scratchpad', component: page('scratchpad') }, { path: '/instance', component: page('instance/index') }, { path: '/instance/emojis', component: page('instance/emojis') }, diff --git a/src/client/scripts/sound.ts b/src/client/scripts/sound.ts new file mode 100644 index 0000000000..13fd9a80f5 --- /dev/null +++ b/src/client/scripts/sound.ts @@ -0,0 +1,24 @@ +import { device } from '@/cold-storage'; + +const cache = new Map<string, HTMLAudioElement>(); + +export function play(type: string) { + const sound = device.get('sound_' + type as any); + if (sound.type == null) return; + playFile(sound.type, sound.volume); +} + +export function playFile(file: string, volume: number) { + const masterVolume = device.get('sound_masterVolume'); + if (masterVolume === 0) return; + + let audio: HTMLAudioElement; + if (cache.has(file)) { + audio = cache.get(file); + } else { + audio = new Audio(`/assets/sounds/${file}.mp3`); + cache.set(file, audio); + } + audio.volume = masterVolume - ((1 - volume) * masterVolume); + audio.play(); +} diff --git a/src/client/scripts/theme.ts b/src/client/scripts/theme.ts index c1fc88bf0e..c1580c6367 100644 --- a/src/client/scripts/theme.ts +++ b/src/client/scripts/theme.ts @@ -15,19 +15,12 @@ export const darkTheme: Theme = require('../themes/_dark.json5'); export const themeProps = Object.keys(lightTheme.props).filter(key => !key.startsWith('X')); export const builtinThemes = [ - require('../themes/l-white.json5'), - require('../themes/l-red.json5'), - require('../themes/l-green.json5'), - require('../themes/l-blue.json5'), + require('../themes/l-light.json5'), require('../themes/l-apricot.json5'), - require('../themes/d-black.json5'), - require('../themes/d-red.json5'), - require('../themes/d-green.json5'), - require('../themes/d-blue.json5'), + require('../themes/d-dark.json5'), require('../themes/d-persimmon.json5'), - - require('../themes/d-battery-saver.json5'), + require('../themes/d-black.json5'), ] as Theme[]; let timeout = null; diff --git a/src/client/store.ts b/src/client/store.ts index cb7f993378..2c63e79503 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -55,7 +55,7 @@ export const defaultDeviceUserSettings = { export const defaultDeviceSettings = { lang: null, loadRawImages: false, - alwaysShowNsfw: false, + nsfw: 'respect', // respect, force, ignore useOsNativeEmojis: false, serverDisconnectedBehavior: 'quiet', accounts: [], @@ -87,14 +87,6 @@ export const defaultDeviceSettings = { deckColumnAlign: 'left', deckAlwaysShowMainColumn: true, deckMainColumnPlace: 'left', - sfxVolume: 0.3, - sfxNote: 'syuilo/down', - sfxNoteMy: 'syuilo/up', - sfxNotification: 'syuilo/pope2', - sfxChat: 'syuilo/pope1', - sfxChatBg: 'syuilo/waon', - sfxAntenna: 'syuilo/triple', - sfxChannel: 'syuilo/square-pico', userData: {}, }; diff --git a/src/client/style.scss b/src/client/style.scss index d7a78dc9c9..85a54706e6 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -448,10 +448,14 @@ hr { opacity: 0.7; } +._monospace { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; +} + ._code { + @extend ._monospace; background: #2d2d2d; color: #ccc; - font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; font-size: 14px; line-height: 1.5; padding: 5px; diff --git a/src/client/themes/_dark.json5 b/src/client/themes/_dark.json5 index ee6d9b49e9..f290586eb4 100644 --- a/src/client/themes/_dark.json5 +++ b/src/client/themes/_dark.json5 @@ -19,6 +19,7 @@ divider: 'rgba(255, 255, 255, 0.1)', indicator: '@accent', panel: '#000', + panelHighlight: ':lighten<3<@panel', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/src/client/themes/_light.json5 b/src/client/themes/_light.json5 index 8821999395..0a1125cab7 100644 --- a/src/client/themes/_light.json5 +++ b/src/client/themes/_light.json5 @@ -19,6 +19,7 @@ divider: 'rgba(0, 0, 0, 0.1)', indicator: '@accent', panel: '#fff', + panelHighlight: ':darken<3<@panel', panelHeaderBg: ':lighten<3<@panel', panelHeaderFg: '@fg', panelHeaderDivider: 'rgba(0, 0, 0, 0)', diff --git a/src/client/themes/d-battery-saver.json5 b/src/client/themes/d-battery-saver.json5 deleted file mode 100644 index e6499ace96..0000000000 --- a/src/client/themes/d-battery-saver.json5 +++ /dev/null @@ -1,18 +0,0 @@ -{ - id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1', - - name: 'Battery Saver', - author: 'syuilo', - - base: 'dark', - - props: { - divider: '#2d2d2d', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', - panelShadow: '" 0 0 0 1px var(--divider)', - shadow: 'rgba(255, 255, 255, 0.05)', - modalBg: 'rgba(255, 255, 255, 0.1)', - messageBg: '#1d1d1d', - }, -} diff --git a/src/client/themes/d-black.json5 b/src/client/themes/d-black.json5 index 1e30d56473..b52e0fc394 100644 --- a/src/client/themes/d-black.json5 +++ b/src/client/themes/d-black.json5 @@ -1,29 +1,19 @@ { - id: '8050783a-7f63-445a-b270-36d0f6ba1677', + id: '8c539dc1-0fab-4d47-9194-39c508e9bfe1', name: 'Mi Black', author: 'syuilo', - desc: 'Default light theme', base: 'dark', props: { - bg: '#272727', - fg: 'rgb(199, 209, 216)', - fgHighlighted: '#fff', - divider: 'rgba(255, 255, 255, 0.14)', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', + divider: '#2d2d2d', + panel: '#0a0a0a', panelHeaderBg: '@panel', panelHeaderDivider: '@divider', - infoFg: '@accent', - infoBg: 'rgb(0, 0, 0)', - header: ':alpha<0.7<@bg', - navBg: '#363636', - renote: '@accent', - mention: '#da6d35', - mentionMe: '#d44c4c', - hashtag: '#4cb8d4', - link: '@accent', + panelShadow: '" 0 8px 24px rgb(0 0 0 / 25%)', + shadow: 'rgba(255, 255, 255, 0.05)', + modalBg: 'rgba(255, 255, 255, 0.1)', + messageBg: '#1d1d1d', }, } diff --git a/src/client/themes/d-blue.json5 b/src/client/themes/d-blue.json5 deleted file mode 100644 index 96e6240e90..0000000000 --- a/src/client/themes/d-blue.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - id: 'ab4eb6d5-dcc0-4457-8a3c-98aad8ea3979', - - name: 'Mi D Blue', - author: 'syuilo', - - base: 'dark', - - props: { - accent: 'rgb(81 185 189)', - bg: 'rgb(54, 54, 54)', - fg: 'rgb(199, 209, 216)', - fgHighlighted: '#fff', - divider: 'rgba(255, 255, 255, 0.14)', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', - infoFg: '@accent', - infoBg: 'rgb(0, 0, 0)', - header: ':alpha<0.7<@bg', - navBg: 'rgb(71, 71, 71)', - renote: '@accent', - mention: '#da6d35', - mentionMe: '#d44c4c', - hashtag: '#4cb8d4', - link: '@accent', - }, -} diff --git a/src/client/themes/d-red.json5 b/src/client/themes/d-dark.json5 similarity index 65% rename from src/client/themes/d-red.json5 rename to src/client/themes/d-dark.json5 index 0f137322c0..7dd29b4a0f 100644 --- a/src/client/themes/d-red.json5 +++ b/src/client/themes/d-dark.json5 @@ -1,25 +1,25 @@ { - id: '60960086-26da-4f3c-bb0c-f6a4f89e0f60', + id: '8050783a-7f63-445a-b270-36d0f6ba1677', - name: 'Mi D Red', + name: 'Mi Dark', author: 'syuilo', + desc: 'Default light theme', base: 'dark', props: { - accent: 'rgb(196 115 69)', - bg: 'rgb(54, 54, 54)', + bg: '#232323', fg: 'rgb(199, 209, 216)', fgHighlighted: '#fff', divider: 'rgba(255, 255, 255, 0.14)', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', + panel: '#2d2d2d', + panelShadow: '" 0 8px 24px rgb(0 0 0 / 25%)', panelHeaderBg: '@panel', panelHeaderDivider: '@divider', infoFg: '@accent', infoBg: 'rgb(0, 0, 0)', header: ':alpha<0.7<@bg', - navBg: 'rgb(71, 71, 71)', + navBg: '#363636', renote: '@accent', mention: '#da6d35', mentionMe: '#d44c4c', diff --git a/src/client/themes/d-green.json5 b/src/client/themes/d-green.json5 deleted file mode 100644 index f1f90d1c78..0000000000 --- a/src/client/themes/d-green.json5 +++ /dev/null @@ -1,29 +0,0 @@ -{ - id: '326dc4bf-29d9-45b4-889e-bdc33e84919b', - - name: 'Mi D Green', - author: 'syuilo', - - base: 'dark', - - props: { - accent: 'rgb(152, 196, 69)', - bg: 'rgb(54, 54, 54)', - fg: 'rgb(199, 209, 216)', - fgHighlighted: '#fff', - divider: 'rgba(255, 255, 255, 0.14)', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', - panelHeaderBg: '@panel', - panelHeaderDivider: '@divider', - infoFg: '@accent', - infoBg: 'rgb(0, 0, 0)', - header: ':alpha<0.7<@bg', - navBg: 'rgb(71, 71, 71)', - renote: '@accent', - mention: '#da6d35', - mentionMe: '#d44c4c', - hashtag: '#4cb8d4', - link: '@accent', - }, -} diff --git a/src/client/themes/d-persimmon.json5 b/src/client/themes/d-persimmon.json5 index 2c32e0797b..067911ace6 100644 --- a/src/client/themes/d-persimmon.json5 +++ b/src/client/themes/d-persimmon.json5 @@ -1,23 +1,23 @@ { id: 'c503d768-7c70-4db2-a4e6-08264304bc8d', - name: 'Ai Persimmon', + name: 'Mi Persimmon', author: 'syuilo', base: 'dark', props: { accent: 'rgb(206, 102, 65)', - bg: 'rgb(41, 43, 41)', + bg: 'rgb(31, 33, 31)', fg: '#cdd8c7', fgHighlighted: '#fff', divider: 'rgba(255, 255, 255, 0.14)', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', + panel: 'rgb(41, 43, 41)', + panelShadow: '" 0 8px 24px rgb(0 0 0 / 25%)', panelHeaderBg: '@panel', panelHeaderDivider: '@divider', - infoFg: '@accent', - infoBg: 'rgb(0, 0, 0)', + infoFg: '@fg', + infoBg: '#333c3b', header: ':alpha<0.7<@bg', navBg: '#1f211f', renote: '@accent', diff --git a/src/client/themes/l-apricot.json5 b/src/client/themes/l-apricot.json5 index 7fbc2b47c7..4bdeea21a5 100644 --- a/src/client/themes/l-apricot.json5 +++ b/src/client/themes/l-apricot.json5 @@ -1,7 +1,7 @@ { id: '0ff48d43-aab3-46e7-ab12-8492110d2e2b', - name: 'Ai Apricot', + name: 'Mi Apricot', author: 'syuilo', base: 'light', diff --git a/src/client/themes/l-blue.json5 b/src/client/themes/l-blue.json5 deleted file mode 100644 index 06c06da08b..0000000000 --- a/src/client/themes/l-blue.json5 +++ /dev/null @@ -1,21 +0,0 @@ -{ - id: 'ad18a23b-6af6-4af0-9ed4-600568250574', - - name: 'Mi L Blue', - author: 'syuilo', - - base: 'light', - - props: { - accent: '#4dbccc', - bg: '#fff', - fg: '#5d5d5d', - divider: 'rgb(223, 223, 223)', - header: ':alpha<0.7<@bg', - navBg: '@bg', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', - panelHeaderDivider: '@divider', - messageBg: '#dedede', - }, -} diff --git a/src/client/themes/l-green.json5 b/src/client/themes/l-green.json5 deleted file mode 100644 index 5a9eb8e0a2..0000000000 --- a/src/client/themes/l-green.json5 +++ /dev/null @@ -1,21 +0,0 @@ -{ - id: 'a55af79a-12bf-4f8d-a0cc-718957ad59b4', - - name: 'Mi L Green', - author: 'syuilo', - - base: 'light', - - props: { - accent: '#8bcc4d', - bg: '#fff', - fg: '#5d5d5d', - divider: 'rgb(223, 223, 223)', - header: ':alpha<0.7<@bg', - navBg: '@bg', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', - panelHeaderDivider: '@divider', - messageBg: '#dedede', - }, -} diff --git a/src/client/themes/l-white.json5 b/src/client/themes/l-light.json5 similarity index 95% rename from src/client/themes/l-white.json5 rename to src/client/themes/l-light.json5 index 9daa60c119..f7ec85d01e 100644 --- a/src/client/themes/l-white.json5 +++ b/src/client/themes/l-light.json5 @@ -1,7 +1,7 @@ { id: '4eea646f-7afa-4645-83e9-83af0333cd37', - name: 'Mi White', + name: 'Mi Light', author: 'syuilo', desc: 'Default light theme', diff --git a/src/client/themes/l-red.json5 b/src/client/themes/l-red.json5 deleted file mode 100644 index 22139c3aaa..0000000000 --- a/src/client/themes/l-red.json5 +++ /dev/null @@ -1,21 +0,0 @@ -{ - id: '957db7cb-30fb-4c80-bf0b-04198e7ae7e3', - - name: 'Mi L Red', - author: 'syuilo', - - base: 'light', - - props: { - accent: '#fb734d', - bg: '#fff', - fg: '#5d5d5d', - divider: 'rgb(223, 223, 223)', - header: ':alpha<0.7<@bg', - navBg: '@bg', - panel: '@bg', - panelShadow: '" 0 0 0 1px var(--divider)', - panelHeaderDivider: '@divider', - messageBg: '#dedede', - }, -} diff --git a/src/client/ui/_common_/common.vue b/src/client/ui/_common_/common.vue index d06cbb9869..469220806d 100644 --- a/src/client/ui/_common_/common.vue +++ b/src/client/ui/_common_/common.vue @@ -15,8 +15,9 @@ <script lang="ts"> import { defineAsyncComponent, defineComponent } from 'vue'; -import { stream, sound, popup, popups, uploads, pendingApiRequestsCount } from '@/os'; +import { stream, popup, popups, uploads, pendingApiRequestsCount } from '@/os'; import { store } from '@/store'; +import * as sound from '@/scripts/sound'; export default defineComponent({ components: { @@ -38,7 +39,7 @@ export default defineComponent({ }, {}, 'closed'); } - sound('notification'); + sound.play('notification'); }; if (store.getters.isSignedIn) { diff --git a/src/client/ui/visitor.vue b/src/client/ui/visitor.vue index 56cc270be7..0d83088882 100644 --- a/src/client/ui/visitor.vue +++ b/src/client/ui/visitor.vue @@ -1,209 +1,19 @@ <template> -<div class="mk-app"> - <header> - <MkA class="link" to="/">{{ $t('home') }}</MkA> - <MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA> - <MkA class="link" to="/channels">{{ $t('channel') }}</MkA> - <MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA> - </header> - - <div class="banner" :class="{ asBg: $route.path === '/' }" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> - <h1 v-if="$route.path !== '/'">{{ instanceName }}</h1> - </div> - - <div class="contents" ref="contents" :class="{ wallpaper }"> - <header class="header" ref="header" v-show="$route.path !== '/'"> - <XHeader :info="pageInfo"/> - </header> - <main ref="main"> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <component :is="Component" :ref="changePage"/> - </transition> - </router-view> - </main> - <div class="powered-by"> - <b><MkA to="/">{{ host }}</MkA></b> - <small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small> - </div> - </div> - - <XCommon/> -</div> +<DesignA/> +<XCommon/> </template> <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -import { } from '@fortawesome/free-solid-svg-icons'; -import { host, instanceName } from '@/config'; -import { search } from '@/scripts/search'; -import * as os from '@/os'; -import XHeader from './_common_/header.vue'; +import DesignA from './visitor/a.vue'; +import DesignB from './visitor/b.vue'; import XCommon from './_common_/common.vue'; -const DESKTOP_THRESHOLD = 1100; - export default defineComponent({ components: { XCommon, - XHeader, + DesignA, + DesignB, }, - - data() { - return { - host, - instanceName, - pageKey: 0, - pageInfo: null, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - }; - }, - - computed: { - keymap(): any { - return { - 'd': () => { - if (this.$store.state.device.syncDeviceDarkMode) return; - this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); - }, - 's': search, - 'h|/': this.help - }; - }, - }, - - watch: { - $route(to, from) { - this.pageKey++; - }, - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - }, - - mounted() { - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page.INFO) { - this.pageInfo = page.INFO; - } - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - this.$router.push('/docs/keyboard-shortcut'); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - } }); </script> - -<style lang="scss" scoped> -.mk-app { - min-height: 100vh; - - > header { - position: relative; - z-index: 1; - background: var(--panel); - padding: 0 16px; - text-align: center; - overflow: auto; - white-space: nowrap; - - > .link { - display: inline-block; - line-height: 60px; - padding: 0 0.7em; - - &.MkA-active { - box-shadow: 0 -2px 0 0 var(--accent) inset; - } - } - } - - > .banner { - position: relative; - width: 100%; - height: 200px; - background-size: cover; - background-position: center; - - &.asBg { - position: absolute; - left: 0; - height: 320px; - } - - &:after { - content: ""; - display: block; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 64px; - background: linear-gradient(transparent, var(--bg)); - } - - > h1 { - margin: 0; - text-align: center; - color: #fff; - text-shadow: 0 0 8px #000; - line-height: 200px; - } - } - - > .contents { - position: relative; - z-index: 1; - - > .header { - position: sticky; - top: 0; - left: 0; - z-index: 1000; - height: 60px; - width: 100%; - line-height: 60px; - text-align: center; - -webkit-backdrop-filter: blur(32px); - backdrop-filter: blur(32px); - background-color: var(--header); - border-bottom: 1px solid var(--divider); - } - - > .powered-by { - padding: 28px; - font-size: 14px; - text-align: center; - border-top: 1px solid var(--divider); - - > small { - display: block; - margin-top: 8px; - opacity: 0.5; - } - } - } -} -</style> - -<style lang="scss"> -</style> diff --git a/src/client/ui/visitor/a.vue b/src/client/ui/visitor/a.vue new file mode 100644 index 0000000000..da09a9363b --- /dev/null +++ b/src/client/ui/visitor/a.vue @@ -0,0 +1,357 @@ +<template> +<div class="mk-app"> + <div class="banner" v-if="$route.path === '/'" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> + <div> + <header> + <MkA class="link" to="/">{{ $t('home') }}</MkA> + <MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA> + <MkA class="link" to="/channels">{{ $t('channel') }}</MkA> + <MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA> + </header> + <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + <div class="about" v-if="meta"> + <div class="desc" v-html="meta.description || $t('introMisskey')"></div> + </div> + <div class="action"> + <button class="_button primary" @click="signup()">{{ $t('signup') }}</button> + <button class="_button" @click="signin()">{{ $t('login') }}</button> + </div> + </div> + </div> + <div class="banner-mini" v-else :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> + <div> + <header> + <MkA class="link" to="/">{{ $t('home') }}</MkA> + <MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA> + <MkA class="link" to="/channels">{{ $t('channel') }}</MkA> + <MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA> + <div class="action"> + <button class="_button primary" @click="signup()">{{ $t('signup') }}</button> + <button class="_button" @click="signin()">{{ $t('login') }}</button> + </div> + </header> + <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + </div> + </div> + + <div class="main"> + <div class="contents" ref="contents" :class="{ wallpaper }"> + <header class="header" ref="header" v-show="$route.path !== '/'"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <component :is="Component" :ref="changePage"/> + </transition> + </router-view> + </main> + <div class="powered-by"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { } from '@fortawesome/free-solid-svg-icons'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/ui/pagination.vue'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XHeader from '../_common_/header.vue'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XHeader, + MkPagination, + MkButton, + }, + + data() { + return { + host, + instanceName, + pageKey: 0, + pageInfo: null, + meta: null, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (this.$store.state.device.syncDeviceDarkMode) return; + this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); + }, + 's': search, + 'h|/': this.help + }; + }, + }, + + watch: { + $route(to, from) { + this.pageKey++; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + mounted() { + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + setParallax(el) { + //new simpleParallax(el); + }, + + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + this.$router.push('/docs/keyboard-shortcut'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + min-height: 100vh; + + > .banner { + position: relative; + width: 100%; + text-align: center; + background-position: center; + background-size: cover; + + > div { + height: 100%; + background: rgba(0, 0, 0, 0.3); + + * { + color: #fff; + } + + > h1 { + margin: 0; + padding: 96px 32px 0 32px; + text-shadow: 0 0 8px black; + + > .logo { + vertical-align: bottom; + max-height: 150px; + } + } + + > .about { + padding: 32px; + max-width: 580px; + margin: 0 auto; + box-sizing: border-box; + text-shadow: 0 0 8px black; + } + + > .action { + padding-bottom: 64px; + + > button { + display: inline-block; + padding: 10px 20px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + background: var(--panel); + color: var(--fg); + + &.primary { + background: var(--accent); + color: #fff; + } + + &:first-child { + margin-right: 16px; + } + } + } + } + } + + > .banner-mini { + position: relative; + width: 100%; + text-align: center; + background-position: center; + background-size: cover; + + > div { + position: relative; + z-index: 1; + height: 100%; + background: rgba(0, 0, 0, 0.3); + + * { + color: #fff !important; + } + + > header { + + } + + > h1 { + margin: 0; + padding: 32px; + text-shadow: 0 0 8px black; + + > .logo { + vertical-align: bottom; + max-height: 100px; + } + } + } + } + + > .main { + > header { + position: relative; + z-index: 1; + background: var(--panel); + padding: 0 32px; + text-align: left; + overflow: auto; + white-space: nowrap; + + > .link { + display: inline-block; + line-height: 60px; + padding: 0 0.7em; + + &.MkA-active { + box-shadow: 0 -2px 0 0 var(--accent) inset; + } + } + } + + > .banner { + position: relative; + width: 100%; + height: 200px; + background-size: cover; + background-position: center; + + &.asBg { + position: absolute; + left: 0; + height: 320px; + } + + &:after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(transparent, var(--bg)); + } + + > h1 { + margin: 0; + text-align: center; + color: #fff; + text-shadow: 0 0 8px #000; + line-height: 200px; + } + } + + > .contents { + position: relative; + z-index: 1; + + > .header { + position: sticky; + top: 0; + left: 0; + z-index: 1000; + height: 60px; + width: 100%; + line-height: 60px; + text-align: center; + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: 1px solid var(--divider); + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } +} +</style> + +<style lang="scss"> +</style> diff --git a/src/client/ui/visitor/b.vue b/src/client/ui/visitor/b.vue new file mode 100644 index 0000000000..13f93a522e --- /dev/null +++ b/src/client/ui/visitor/b.vue @@ -0,0 +1,372 @@ +<template> +<div class="mk-app"> + <div class="side" v-if="!narrow"> + <div :style="{ backgroundImage: `url(${ $store.state.instance.meta.backgroundImageUrl })` }"> + <div class="fade"></div> + <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + <div class="about _panel" v-if="meta"> + <div class="desc" v-html="meta.description || $t('introMisskey')"></div> + </div> + <div class="action"> + <button class="_button primary" @click="signup()">{{ $t('signup') }}</button> + <button class="_button" @click="signin()">{{ $t('login') }}</button> + </div> + <div class="announcements panel"> + <header>{{ $t('announcements') }}</header> + <MkPagination :pagination="announcements" #default="{items}" class="list"> + <section class="item" v-for="(announcement, i) in items" :key="announcement.id"> + <div class="title">{{ announcement.title }}</div> + <div class="content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + </div> + </section> + </MkPagination> + </div> + </div> + </div> + + <div class="main"> + <header> + <MkA class="link" to="/">{{ $t('home') }}</MkA> + <MkA class="link" to="/announcements">{{ $t('announcements') }}</MkA> + <MkA class="link" to="/channels">{{ $t('channel') }}</MkA> + <MkA class="link" to="/about">{{ $t('aboutX', { x: instanceName }) }}</MkA> + </header> + + <div v-if="narrow" class="banner" :style="{ backgroundImage: `url(${ $store.state.instance.meta.bannerUrl })` }"> + <h1 v-if="meta"><img class="logo" v-if="meta.logoImageUrl" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> + </div> + + <div class="contents" ref="contents" :class="{ wallpaper }"> + <header class="header" ref="header" v-show="$route.path !== '/'"> + <XHeader :info="pageInfo"/> + </header> + <main ref="main"> + <router-view v-slot="{ Component }"> + <transition :name="$store.state.device.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> + <component :is="Component" :ref="changePage"/> + </transition> + </router-view> + </main> + <div class="powered-by"> + <b><MkA to="/">{{ host }}</MkA></b> + <small>Powered by <a href="https://github.com/syuilo/misskey" target="_blank">Misskey</a></small> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import { defineComponent, defineAsyncComponent } from 'vue'; +import { } from '@fortawesome/free-solid-svg-icons'; +import { host, instanceName } from '@/config'; +import { search } from '@/scripts/search'; +import * as os from '@/os'; +import MkPagination from '@/components/ui/pagination.vue'; +import XSigninDialog from '@/components/signin-dialog.vue'; +import XSignupDialog from '@/components/signup-dialog.vue'; +import MkButton from '@/components/ui/button.vue'; +import XHeader from '../_common_/header.vue'; + +const DESKTOP_THRESHOLD = 1100; + +export default defineComponent({ + components: { + XHeader, + MkPagination, + MkButton, + }, + + data() { + return { + host, + instanceName, + pageKey: 0, + pageInfo: null, + meta: null, + narrow: window.innerWidth < 1280, + announcements: { + endpoint: 'announcements', + limit: 10, + }, + isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, + }; + }, + + computed: { + keymap(): any { + return { + 'd': () => { + if (this.$store.state.device.syncDeviceDarkMode) return; + this.$store.commit('device/set', { key: 'darkMode', value: !this.$store.state.device.darkMode }); + }, + 's': search, + 'h|/': this.help + }; + }, + }, + + watch: { + $route(to, from) { + this.pageKey++; + }, + }, + + created() { + document.documentElement.style.overflowY = 'scroll'; + + os.api('meta', { detail: true }).then(meta => { + this.meta = meta; + }); + }, + + mounted() { + if (!this.isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; + }, { passive: true }); + } + }, + + methods: { + changePage(page) { + if (page == null) return; + if (page.INFO) { + this.pageInfo = page.INFO; + } + }, + + top() { + window.scroll({ top: 0, behavior: 'smooth' }); + }, + + help() { + this.$router.push('/docs/keyboard-shortcut'); + }, + + onTransition() { + if (window._scroll) window._scroll(); + }, + + signin() { + os.popup(XSigninDialog, { + autoSet: true + }, {}, 'closed'); + }, + + signup() { + os.popup(XSignupDialog, { + autoSet: true + }, {}, 'closed'); + } + } +}); +</script> + +<style lang="scss" scoped> +.mk-app { + display: flex; + min-height: 100vh; + + > .side { + width: 500px; + height: 100vh; + text-align: center; + + > div { + position: fixed; + top: 0; + left: 0; + width: 500px; + height: 100vh; + background-position: center; + background-size: cover; + + > .panel { + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + background: rgba(0, 0, 0, 0.5); + border-radius: var(--radius); + + &, * { + color: #fff !important; + } + } + + > .fade { + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 300px; + background: linear-gradient(rgba(#000, 0.5), transparent); + } + + > h1 { + display: block; + margin: 0; + padding: 64px 32px 48px 32px; + color: #fff; + + > .logo { + vertical-align: bottom; + max-height: 150px; + } + } + + > .about { + display: block; + margin: 0 64px 16px 64px; + padding: 24px; + text-align: center; + box-sizing: border-box; + } + + > .action { + padding: 0 64px; + + > button { + display: block; + width: 100%; + padding: 10px; + box-sizing: border-box; + text-align: center; + border-radius: 999px; + background: var(--panel); + + &.primary { + background: var(--accent); + color: #fff; + } + + &:first-child { + margin-bottom: 16px; + } + } + } + + > .announcements { + margin: 64px 64px 16px 64px; + text-align: left; + + > header { + padding: 12px 16px; + border-bottom: solid 1px rgba(255, 255, 255, 0.5); + } + + > .list { + max-height: 300px; + overflow: auto; + + > .item { + padding: 12px 16px; + + & + .item { + border-top: solid 1px rgba(255, 255, 255, 0.5); + } + + > .title { + font-weight: bold; + } + } + } + } + } + } + + > .main { + flex: 1; + + > header { + position: relative; + z-index: 1; + background: var(--panel); + padding: 0 32px; + text-align: left; + overflow: auto; + white-space: nowrap; + + > .link { + display: inline-block; + line-height: 60px; + padding: 0 0.7em; + + &.MkA-active { + box-shadow: 0 -2px 0 0 var(--accent) inset; + } + } + } + + > .banner { + position: relative; + width: 100%; + height: 200px; + background-size: cover; + background-position: center; + + &:after { + content: ""; + display: block; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 64px; + background: linear-gradient(transparent, var(--bg)); + } + + > h1 { + margin: 0; + padding: 32px; + text-align: center; + color: #fff; + text-shadow: 0 0 8px #000; + + > .logo { + vertical-align: bottom; + max-height: 150px; + } + } + } + + > .contents { + position: relative; + z-index: 1; + + > .header { + position: sticky; + top: 0; + left: 0; + z-index: 1000; + height: 60px; + width: 100%; + line-height: 60px; + text-align: center; + -webkit-backdrop-filter: blur(32px); + backdrop-filter: blur(32px); + background-color: var(--header); + border-bottom: 1px solid var(--divider); + } + + > .powered-by { + padding: 28px; + font-size: 14px; + text-align: center; + border-top: 1px solid var(--divider); + + > small { + display: block; + margin-top: 8px; + opacity: 0.5; + } + } + } + } +} +</style> + +<style lang="scss"> +</style> diff --git a/src/client/widgets/digital-clock.vue b/src/client/widgets/digital-clock.vue index 702f335c7f..9d32e8b9fe 100644 --- a/src/client/widgets/digital-clock.vue +++ b/src/client/widgets/digital-clock.vue @@ -1,5 +1,5 @@ <template> -<div class="mkw-digitalClock" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }"> +<div class="mkw-digitalClock _monospace" :class="{ _panel: !props.transparent }" :style="{ fontSize: `${props.fontSize}em` }"> <span> <span v-text="hh"></span> <span :style="{ visibility: showColon ? 'visible' : 'hidden' }">:</span> @@ -74,7 +74,6 @@ export default defineComponent({ <style lang="scss" scoped> .mkw-digitalClock { padding: 16px 0; - font-family: Lucida Console, Courier, monospace; text-align: center; } </style> diff --git a/src/games/reversi/maps.ts b/src/games/reversi/maps.ts index b95eb4f02d..dc0d1bf9d0 100644 --- a/src/games/reversi/maps.ts +++ b/src/games/reversi/maps.ts @@ -878,3 +878,19 @@ export const test7: Map = { '--wwww--', ] }; + +// 検証用: この盤面で藍(lv5)が黒で始めると何故か(?)A1に打ってしまう +export const test8: Map = { + name: 'Test8', + category: 'Test', + data: [ + '--------', + '-----w--', + 'w--www--', + 'wwwwww--', + 'bbbbwww-', + 'wwwwww--', + '--www---', + '--ww----', + ] +}; diff --git a/src/models/entities/meta.ts b/src/models/entities/meta.ts index b7fe8b18ad..cfc9782614 100644 --- a/src/models/entities/meta.ts +++ b/src/models/entities/meta.ts @@ -77,7 +77,7 @@ export class Meta { public blockedHosts: string[]; @Column('varchar', { - length: 512, array: true, default: '{"/announcements", "/featured", "/channels", "/explore", "/games/reversi", "/about-misskey"}' + length: 512, array: true, default: '{"/featured", "/channels", "/explore", "/pages", "/about-misskey"}' }) public pinnedPages: string[]; @@ -94,6 +94,18 @@ export class Meta { }) public bannerUrl: string | null; + @Column('varchar', { + length: 512, + nullable: true + }) + public backgroundImageUrl: string | null; + + @Column('varchar', { + length: 512, + nullable: true + }) + public logoImageUrl: string | null; + @Column('varchar', { length: 512, nullable: true, diff --git a/src/models/entities/note-reaction.ts b/src/models/entities/note-reaction.ts index ed38450bb2..69bb663fd3 100644 --- a/src/models/entities/note-reaction.ts +++ b/src/models/entities/note-reaction.ts @@ -35,6 +35,8 @@ export class NoteReaction { @JoinColumn() public note: Note | null; + // TODO: 対象noteのuserIdを非正規化したい(「受け取ったリアクション一覧」のようなものを(JOIN無しで)実装したいため) + @Column('varchar', { length: 260 }) diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index bd37da5ecc..97a4150be0 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -111,6 +111,12 @@ export class UserProfile { }) public autoAcceptFollowed: boolean; + @Column('boolean', { + default: false, + comment: 'Whether reject index by crawler.' + }) + public noCrawle: boolean; + @Column('boolean', { default: false, }) diff --git a/src/models/repositories/drive-file.ts b/src/models/repositories/drive-file.ts index 7d1f2b9fec..ab22d2dc09 100644 --- a/src/models/repositories/drive-file.ts +++ b/src/models/repositories/drive-file.ts @@ -48,7 +48,7 @@ export class DriveFileRepository extends Repository<DriveFile> { return thumbnail ? (file.thumbnailUrl || (isImage ? (file.webpublicUrl || file.url) : null)) : (file.webpublicUrl || file.url); } - public async clacDriveUsageOf(user: User['id'] | User): Promise<number> { + public async calcDriveUsageOf(user: User['id'] | User): Promise<number> { const id = typeof user === 'object' ? user.id : user; const { sum } = await this @@ -60,7 +60,7 @@ export class DriveFileRepository extends Repository<DriveFile> { return parseInt(sum, 10) || 0; } - public async clacDriveUsageOfHost(host: string): Promise<number> { + public async calcDriveUsageOfHost(host: string): Promise<number> { const { sum } = await this .createQueryBuilder('file') .where('file.userHost = :host', { host: toPuny(host) }) @@ -70,7 +70,7 @@ export class DriveFileRepository extends Repository<DriveFile> { return parseInt(sum, 10) || 0; } - public async clacDriveUsageOfLocal(): Promise<number> { + public async calcDriveUsageOfLocal(): Promise<number> { const { sum } = await this .createQueryBuilder('file') .where('file.userHost IS NULL') @@ -80,7 +80,7 @@ export class DriveFileRepository extends Repository<DriveFile> { return parseInt(sum, 10) || 0; } - public async clacDriveUsageOfRemote(): Promise<number> { + public async calcDriveUsageOfRemote(): Promise<number> { const { sum } = await this .createQueryBuilder('file') .where('file.userHost IS NOT NULL') diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index a334196832..87f50b448b 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -239,6 +239,7 @@ export class UserRepository extends Repository<User> { alwaysMarkNsfw: profile!.alwaysMarkNsfw, carefulBot: profile!.carefulBot, autoAcceptFollowed: profile!.autoAcceptFollowed, + noCrawle: profile!.noCrawle, hasUnreadSpecifiedNotes: NoteUnreads.count({ where: { userId: user.id, isSpecified: true }, take: 1 diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index ae6d2a4163..acb29b9e51 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -94,6 +94,14 @@ export const meta = { } }, + backgroundImageUrl: { + validator: $.optional.nullable.str, + }, + + logoImageUrl: { + validator: $.optional.nullable.str, + }, + name: { validator: $.optional.nullable.str, desc: { @@ -473,6 +481,14 @@ export default define(meta, async (ps, me) => { set.iconUrl = ps.iconUrl; } + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + if (ps.name !== undefined) { set.name = ps.name; } diff --git a/src/server/api/endpoints/drive.ts b/src/server/api/endpoints/drive.ts index 9b723a0542..527b7719a4 100644 --- a/src/server/api/endpoints/drive.ts +++ b/src/server/api/endpoints/drive.ts @@ -34,7 +34,7 @@ export default define(meta, async (ps, user) => { const instance = await fetchMeta(true); // Calculate drive usage - const usage = await DriveFiles.clacDriveUsageOf(user); + const usage = await DriveFiles.calcDriveUsageOf(user); return { capacity: 1024 * 1024 * instance.localDriveCapacityMb, diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index d3a8e0a8ce..0872671208 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -106,6 +106,13 @@ export const meta = { } }, + noCrawle: { + validator: $.optional.bool, + desc: { + 'ja-JP': '検索エンジンによるインデックスを拒否するか否か' + } + }, + isBot: { validator: $.optional.bool, desc: { @@ -204,6 +211,7 @@ export default define(meta, async (ps, user, token) => { if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.autoAcceptFollowed === 'boolean') profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === 'boolean') profileUpdates.noCrawle = ps.noCrawle; if (typeof ps.isCat === 'boolean') updates.isCat = ps.isCat; if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; if (typeof ps.alwaysMarkNsfw === 'boolean') profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; diff --git a/src/server/api/endpoints/meta.ts b/src/server/api/endpoints/meta.ts index 97376a9d73..f24493899a 100644 --- a/src/server/api/endpoints/meta.ts +++ b/src/server/api/endpoints/meta.ts @@ -129,6 +129,8 @@ export default define(meta, async (ps, me) => { bannerUrl: instance.bannerUrl, errorImageUrl: instance.errorImageUrl, iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, maxNoteTextLength: Math.min(instance.maxNoteTextLength, DB_MAX_NOTE_TEXT_LENGTH), emojis: await Emojis.packMany(emojis), enableEmail: instance.enableEmail, diff --git a/src/server/api/endpoints/users/stats.ts b/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000000..50730e7cd0 --- /dev/null +++ b/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,144 @@ +import $ from 'cafy'; +import define from '../../define'; +import { ApiError } from '../../error'; +import { ID } from '../../../../misc/cafy-id'; +import { DriveFiles, Followings, NoteFavorites, NoteReactions, Notes, PageLikes, PollVotes, ReversiGames, Users } from '../../../../models'; + +export const meta = { + tags: ['users'], + + requireCredential: false as const, + + params: { + userId: { + validator: $.type(ID), + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9e638e45-3b25-4ef7-8f95-07e8498f1819' + }, + } +}; + +export default define(meta, async (ps, me) => { + const user = await Users.findOne(ps.userId); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const [ + notesCount, + repliesCount, + renotesCount, + repliedCount, + renotedCount, + pollVotesCount, + pollVotedCount, + localFollowingCount, + remoteFollowingCount, + localFollowersCount, + remoteFollowersCount, + sentReactionsCount, + receivedReactionsCount, + noteFavoritesCount, + pageLikesCount, + pageLikedCount, + driveFilesCount, + driveUsage, + reversiCount, + ] = await Promise.all([ + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.replyId IS NOT NULL') + .getCount(), + Notes.createQueryBuilder('note') + .where('note.userId = :userId', { userId: user.id }) + .andWhere('note.renoteId IS NOT NULL') + .getCount(), + Notes.createQueryBuilder('note') + .where('note.replyUserId = :userId', { userId: user.id }) + .getCount(), + Notes.createQueryBuilder('note') + .where('note.renoteUserId = :userId', { userId: user.id }) + .getCount(), + PollVotes.createQueryBuilder('vote') + .where('vote.userId = :userId', { userId: user.id }) + .getCount(), + PollVotes.createQueryBuilder('vote') + .innerJoin('vote.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followerId = :userId', { userId: user.id }) + .andWhere('following.followeeHost IS NOT NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NULL') + .getCount(), + Followings.createQueryBuilder('following') + .where('following.followeeId = :userId', { userId: user.id }) + .andWhere('following.followerHost IS NOT NULL') + .getCount(), + NoteReactions.createQueryBuilder('reaction') + .where('reaction.userId = :userId', { userId: user.id }) + .getCount(), + NoteReactions.createQueryBuilder('reaction') + .innerJoin('reaction.note', 'note') + .where('note.userId = :userId', { userId: user.id }) + .getCount(), + NoteFavorites.createQueryBuilder('favorite') + .where('favorite.userId = :userId', { userId: user.id }) + .getCount(), + PageLikes.createQueryBuilder('like') + .where('like.userId = :userId', { userId: user.id }) + .getCount(), + PageLikes.createQueryBuilder('like') + .innerJoin('like.page', 'page') + .where('page.userId = :userId', { userId: user.id }) + .getCount(), + DriveFiles.createQueryBuilder('file') + .where('file.userId = :userId', { userId: user.id }) + .getCount(), + DriveFiles.calcDriveUsageOf(user), + ReversiGames.createQueryBuilder('game') + .where('game.user1Id = :userId', { userId: user.id }) + .orWhere('game.user2Id = :userId', { userId: user.id }) + .getCount(), + ]); + + return { + notesCount, + repliesCount, + renotesCount, + repliedCount, + renotedCount, + pollVotesCount, + pollVotedCount, + localFollowingCount, + remoteFollowingCount, + localFollowersCount, + remoteFollowersCount, + followingCount: localFollowingCount + remoteFollowingCount, + followersCount: localFollowersCount + remoteFollowersCount, + sentReactionsCount, + receivedReactionsCount, + noteFavoritesCount, + pageLikesCount, + pageLikedCount, + driveFilesCount, + driveUsage, + reversiCount, + }; +}); diff --git a/src/server/index.ts b/src/server/index.ts index 15e1fedc98..5a7bb99c63 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -21,10 +21,11 @@ import apiServer from './api'; import { sum } from '../prelude/array'; import Logger from '../services/logger'; import { program } from '../argv'; -import { UserProfiles } from '../models'; +import { UserProfiles, Users } from '../models'; import { networkChart } from '../services/chart'; import { genAvatar } from '../misc/gen-avatar'; import { createTemp } from '../misc/create-temp'; +import { publishMainStream } from '../services/stream'; export const serverLogger = new Logger('server', 'gray', false); @@ -83,10 +84,15 @@ router.get('/verify-email/:code', async ctx => { ctx.body = 'Verify succeeded!'; ctx.status = 200; - UserProfiles.update({ userId: profile.userId }, { + await UserProfiles.update({ userId: profile.userId }, { emailVerified: true, emailVerifyCode: null }); + + publishMainStream(profile.userId, 'meUpdated', await Users.pack(profile.userId, profile.userId, { + detail: true, + includeSecrets: true + })); } else { ctx.status = 404; } diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 0bc9f242ad..468ece5359 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -242,9 +242,11 @@ router.get('/notes/:note', async ctx => { if (note) { const _note = await Notes.pack(note); + const profile = await UserProfiles.findOne(note.userId).then(ensure); const meta = await fetchMeta(); await ctx.render('note', { note: _note, + profile, // TODO: Let locale changeable by instance setting summary: getNoteSummary(_note, locales['ja-JP']), instanceName: meta.name || 'Misskey', @@ -280,9 +282,11 @@ router.get('/@:user/pages/:page', async ctx => { if (page) { const _page = await Pages.pack(page); + const profile = await UserProfiles.findOne(page.userId).then(ensure); const meta = await fetchMeta(); await ctx.render('page', { page: _page, + profile, instanceName: meta.name || 'Misskey' }); @@ -307,9 +311,11 @@ router.get('/clips/:clip', async ctx => { if (clip) { const _clip = await Clips.pack(clip); + const profile = await UserProfiles.findOne(clip.userId).then(ensure); const meta = await fetchMeta(); await ctx.render('clip', { clip: _clip, + profile, instanceName: meta.name || 'Misskey' }); diff --git a/src/server/web/views/clip.pug b/src/server/web/views/clip.pug index 8cd1c673ed..8de53f19d6 100644 --- a/src/server/web/views/clip.pug +++ b/src/server/web/views/clip.pug @@ -19,6 +19,9 @@ block og meta(property='og:image' content= user.avatarUrl) block meta + if profile.noCrawle + meta(name='robots' content='noindex') + meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) meta(name='misskey:clip-id' content=clip.id) diff --git a/src/server/web/views/note.pug b/src/server/web/views/note.pug index 0580e959f7..7030936975 100644 --- a/src/server/web/views/note.pug +++ b/src/server/web/views/note.pug @@ -19,6 +19,9 @@ block og meta(property='og:image' content= user.avatarUrl) block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) meta(name='misskey:note-id' content=note.id) @@ -26,9 +29,6 @@ block meta meta(name='twitter:card' content='summary') // todo - if user.host - meta(name='robots' content='noindex') - if user.twitter meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/src/server/web/views/page.pug b/src/server/web/views/page.pug index 55f64ff054..cb9e1039e1 100644 --- a/src/server/web/views/page.pug +++ b/src/server/web/views/page.pug @@ -19,6 +19,9 @@ block og meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl) block meta + if profile.noCrawle + meta(name='robots' content='noindex') + meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) meta(name='misskey:page-id' content=page.id) diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug index d41b0bbac0..1a8a6b4413 100644 --- a/src/server/web/views/user.pug +++ b/src/server/web/views/user.pug @@ -19,14 +19,14 @@ block og meta(property='og:image' content= img) block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + meta(name='misskey:user-username' content=user.username) meta(name='misskey:user-id' content=user.id) meta(name='twitter:card' content='summary') - if user.host - meta(name='robots' content='noindex') - if profile.twitter meta(name='twitter:creator' content=`@${profile.twitter.screenName}`) diff --git a/src/services/chart/charts/classes/drive.ts b/src/services/chart/charts/classes/drive.ts index c3bcacb7df..57bb120beb 100644 --- a/src/services/chart/charts/classes/drive.ts +++ b/src/services/chart/charts/classes/drive.ts @@ -32,8 +32,8 @@ export default class DriveChart extends Chart<DriveLog> { const [localCount, remoteCount, localSize, remoteSize] = await Promise.all([ DriveFiles.count({ userHost: null }), DriveFiles.count({ userHost: Not(IsNull()) }), - DriveFiles.clacDriveUsageOfLocal(), - DriveFiles.clacDriveUsageOfRemote() + DriveFiles.calcDriveUsageOfLocal(), + DriveFiles.calcDriveUsageOfRemote() ]); return { diff --git a/src/services/chart/charts/classes/instance.ts b/src/services/chart/charts/classes/instance.ts index f3d341f383..7575abfb6f 100644 --- a/src/services/chart/charts/classes/instance.ts +++ b/src/services/chart/charts/classes/instance.ts @@ -51,7 +51,7 @@ export default class InstanceChart extends Chart<InstanceLog> { Followings.count({ followerHost: group }), Followings.count({ followeeHost: group }), DriveFiles.count({ userHost: group }), - DriveFiles.clacDriveUsageOfHost(group), + DriveFiles.calcDriveUsageOfHost(group), ]); return { diff --git a/src/services/chart/charts/classes/per-user-drive.ts b/src/services/chart/charts/classes/per-user-drive.ts index 822f4eda0f..aed9f6fce7 100644 --- a/src/services/chart/charts/classes/per-user-drive.ts +++ b/src/services/chart/charts/classes/per-user-drive.ts @@ -24,7 +24,7 @@ export default class PerUserDriveChart extends Chart<PerUserDriveLog> { protected async fetchActual(group: string): Promise<DeepPartial<PerUserDriveLog>> { const [count, size] = await Promise.all([ DriveFiles.count({ userId: group }), - DriveFiles.clacDriveUsageOf(group) + DriveFiles.calcDriveUsageOf(group) ]); return { diff --git a/src/services/drive/add-file.ts b/src/services/drive/add-file.ts index 8d32d06d2e..b5085ec8e3 100644 --- a/src/services/drive/add-file.ts +++ b/src/services/drive/add-file.ts @@ -334,7 +334,7 @@ export default async function( //#region Check drive usage if (user && !isLink) { - const usage = await DriveFiles.clacDriveUsageOf(user); + const usage = await DriveFiles.calcDriveUsageOf(user); const instance = await fetchMeta(); const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);