From f797765b1dd8af364c7effca3ed6a7a3e3cb040a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=93=E3=81=8B=E3=82=8A?= <67428053+kakkokari-gtyih@users.noreply.github.com> Date: Mon, 10 Mar 2025 18:35:51 +0900 Subject: [PATCH] =?UTF-8?q?enhance(frontend):=20=E3=83=86=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E8=A8=AD=E5=AE=9A=E3=81=A7=E7=B0=A1=E6=98=93=E3=83=97?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E3=82=92=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E3=81=AB=20(#15643)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * enhance(frontend): テーマ設定で簡易プレビューを表示するように * Update Changelog * fix lint * 🎨 --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com> --- CHANGELOG.md | 1 + locales/index.d.ts | 4 + locales/ja-JP.yml | 1 + .../src/components/MkThemePreview.vue | 96 ++++++ .../frontend/src/pages/settings/theme.vue | 289 ++++++++++++------ packages/frontend/src/theme.ts | 2 +- .../utility/autogen/settings-search-index.ts | 4 +- 7 files changed, 301 insertions(+), 96 deletions(-) create mode 100644 packages/frontend/src/components/MkThemePreview.vue diff --git a/CHANGELOG.md b/CHANGELOG.md index e0b47ff5e8..7e3215dc6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Feat: 設定の管理が強化されました - 自動でバックアップされるように - Enhance: プラグインの管理が強化されました +- Enhance: テーマ設定画面のデザインを改善 - Fix: テーマ切り替え時に一部の色が変わらない問題を修正 ### Server diff --git a/locales/index.d.ts b/locales/index.d.ts index 0cdd428c82..1f06e25f1e 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -7746,6 +7746,10 @@ export interface Locale extends ILocale { * 標準のテーマ */ "builtinThemes": string; + /** + * サーバーのテーマ + */ + "instanceTheme": string; /** * そのテーマは既にインストールされています */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1e41c43864..9b3a051f0b 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2031,6 +2031,7 @@ _theme: installed: "{name}をインストールしました" installedThemes: "インストールされたテーマ" builtinThemes: "標準のテーマ" + instanceTheme: "サーバーのテーマ" alreadyInstalled: "そのテーマは既にインストールされています" invalid: "テーマの形式が間違っています" make: "テーマを作る" diff --git a/packages/frontend/src/components/MkThemePreview.vue b/packages/frontend/src/components/MkThemePreview.vue new file mode 100644 index 0000000000..5b180b3680 --- /dev/null +++ b/packages/frontend/src/components/MkThemePreview.vue @@ -0,0 +1,96 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<svg + version="1.1" + viewBox="0 0 203.2 152.4" + xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" +> + <g fill-rule="evenodd"> + <rect width="203.2" height="152.4" :fill="themeVariables.bg" stroke-width=".26458" /> + <rect width="65.498" height="152.4" :fill="themeVariables.panel" stroke-width=".26458" /> + <rect x="65.498" width="137.7" height="40.892" :fill="themeVariables.acrylicBg" stroke-width=".265" /> + <path transform="scale(.26458)" d="m439.77 247.19c-43.673 0-78.832 35.157-78.832 78.83v249.98h407.06v-328.81z" :fill="themeVariables.panel" /> + </g> + <circle cx="32.749" cy="83.054" r="21.132" :fill="themeVariables.accentedBg" stroke-dasharray="0.319256, 0.319256" stroke-width=".15963" style="paint-order:stroke fill markers" /> + <circle cx="136.67" cy="106.76" r="23.876" :fill="themeVariables.fg" fill-opacity="0.5" stroke-dasharray="0.352425, 0.352425" stroke-width=".17621" style="paint-order:stroke fill markers" /> + <g :fill="themeVariables.fg" fill-rule="evenodd" stroke-width=".26458"> + <rect x="171.27" y="87.815" width="48.576" height="6.8747" ry="3.4373"/> + <rect x="171.27" y="105.09" width="48.576" height="6.875" ry="3.4375"/> + <rect x="171.27" y="121.28" width="48.576" height="6.875" ry="3.4375"/> + <rect x="171.27" y="137.47" width="48.576" height="6.875" ry="3.4375"/> + </g> + <path d="m65.498 40.892h137.7" :stroke="themeVariables.divider" stroke-width="0.75" /> + <g transform="matrix(.60823 0 0 .60823 25.45 75.755)" fill="none" :stroke="themeVariables.accent" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none" /> + <path d="m5 12h-2l9-9 9 9h-2" /> + <path d="m5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-7" /> + <path d="m9 21v-6a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v6" /> + </g> + <g transform="matrix(.61621 0 0 .61621 25.354 117.92)" fill="none" :stroke="themeVariables.fg" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> + <path d="m0 0h24v24h-24z" fill="none" stroke="none" /> + <path d="m10 5a2 2 0 1 1 4 0 7 7 0 0 1 4 6v3a4 4 0 0 0 2 3h-16a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6" /> + <path d="m9 17v1a3 3 0 0 0 6 0v-1" /> + </g> + <image x="20.948" y="18.388" width="23.602" height="23.602" image-rendering="optimizeSpeed" preserveAspectRatio="xMidYMid meet" v-bind="{ 'xlink:href': instance.iconUrl || '/favicon.ico' }" /> +</svg> +</template> + +<script setup lang="ts"> +import { ref, watch } from 'vue'; +import { instance } from '@/instance.js'; +import { compile } from '@/theme.js'; +import type { Theme } from '@/theme.js'; +import { deepClone } from '@/utility/clone.js'; +import lightTheme from '@@/themes/_light.json5'; +import darkTheme from '@@/themes/_dark.json5'; + +const props = defineProps<{ + theme: Theme; +}>(); + +const themeVariables = ref<{ + bg: string; + acrylicBg: string; + panel: string; + fg: string; + divider: string; + accent: string; + accentedBg: string; +}>({ + bg: 'var(--MI_THEME-bg)', + acrylicBg: 'var(--MI_THEME-acrylicBg)', + panel: 'var(--MI_THEME-panel)', + fg: 'var(--MI_THEME-fg)', + divider: 'var(--MI_THEME-divider)', + accent: 'var(--MI_THEME-accent)', + accentedBg: 'var(--MI_THEME-accentedBg)', +}); + +watch(() => props.theme, (theme) => { + if (theme == null) return; + + const _theme = deepClone(theme); + + if (_theme?.base != null) { + const base = [lightTheme, darkTheme].find(x => x.id === _theme.base); + if (base) _theme.props = Object.assign({}, base.props, _theme.props); + } + + const compiled = compile(_theme); + + themeVariables.value = { + bg: compiled.bg ?? 'var(--MI_THEME-bg)', + acrylicBg: compiled.acrylicBg ?? 'var(--MI_THEME-acrylicBg)', + panel: compiled.panel ?? 'var(--MI_THEME-panel)', + fg: compiled.fg ?? 'var(--MI_THEME-fg)', + divider: compiled.divider ?? 'var(--MI_THEME-divider)', + accent: compiled.accent ?? 'var(--MI_THEME-accent)', + accentedBg: compiled.accentedBg ?? 'var(--MI_THEME-accentedBg)', + }; +}, { immediate: true }); +</script> diff --git a/packages/frontend/src/pages/settings/theme.vue b/packages/frontend/src/pages/settings/theme.vue index 71dba777b7..0e4f791f2c 100644 --- a/packages/frontend/src/pages/settings/theme.vue +++ b/packages/frontend/src/pages/settings/theme.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <SearchMarker path="/settings/theme" :label="i18n.ts.theme" :keywords="['theme']" icon="ti ti-palette"> - <div class="_gaps_m rsljpzjq"> + <div class="_gaps_m"> <div v-adaptive-border class="rfqxtzch _panel"> <div class="toggle"> <div class="toggleWrapper"> @@ -36,23 +36,149 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <div class="selects"> - <div class="select"> + <div class="_gaps"> + <template v-if="!darkMode"> <SearchMarker :keywords="['light', 'theme']"> - <MkSelect v-model="lightThemeId" large :items="lightThemeSelectorItems"> + <MkFolder :defaultOpen="true" :max-height="500"> + <template #icon><i class="ti ti-sun"></i></template> <template #label><SearchLabel>{{ i18n.ts.themeForLightMode }}</SearchLabel></template> - <template #prefix><i class="ti ti-sun"></i></template> - </MkSelect> + <template #caption>{{ lightThemeName }}</template> + + <div class="_gaps_m"> + <FormSection v-if="instanceLightTheme != null" first> + <template #label>{{ i18n.ts._theme.instanceTheme }}</template> + <div :class="$style.themeSelect"> + <div :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${instanceLightTheme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="instanceLightTheme.id" + /> + <label :for="`themeRadio_${instanceLightTheme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="instanceLightTheme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ instanceLightTheme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection v-if="installedLightThemes.length > 0" :first="instanceLightTheme == null"> + <template #label>{{ i18n.ts._theme.installedThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in installedLightThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection :first="installedLightThemes.length === 0 && instanceLightTheme == null"> + <template #label>{{ i18n.ts._theme.builtinThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in builtinLightThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="lightThemeId" + type="radio" + name="lightTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + </div> + </MkFolder> </SearchMarker> - </div> - <div class="select"> + </template> + <template v-else> <SearchMarker :keywords="['dark', 'theme']"> - <MkSelect v-model="darkThemeId" large :items="darkThemeSelectorItems"> + <MkFolder :defaultOpen="true" :max-height="500"> + <template #icon><i class="ti ti-moon"></i></template> <template #label><SearchLabel>{{ i18n.ts.themeForDarkMode }}</SearchLabel></template> - <template #prefix><i class="ti ti-moon"></i></template> - </MkSelect> + <template #caption>{{ darkThemeName }}</template> + + <div class="_gaps_m"> + <FormSection v-if="instanceDarkTheme != null" first> + <template #label>{{ i18n.ts._theme.instanceTheme }}</template> + <div :class="$style.themeSelect"> + <div :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${instanceDarkTheme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="instanceDarkTheme.id" + /> + <label :for="`themeRadio_${instanceDarkTheme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="instanceDarkTheme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ instanceDarkTheme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection v-if="installedDarkThemes.length > 0" :first="instanceDarkTheme == null"> + <template #label>{{ i18n.ts._theme.installedThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in installedDarkThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + + <FormSection :first="installedDarkThemes.length === 0 && instanceDarkTheme == null"> + <template #label>{{ i18n.ts._theme.builtinThemes }}</template> + <div :class="$style.themeSelect"> + <div v-for="theme in builtinDarkThemes" :class="$style.themeItemOuter"> + <input + :id="`themeRadio_${theme.id}`" + v-model="darkThemeId" + type="radio" + name="darkTheme" + :class="$style.themeRadio" + :value="theme.id" + /> + <label :for="`themeRadio_${theme.id}`" :class="$style.themeItemRoot" class="_button"> + <MkThemePreview :theme="theme" :class="$style.themeItemPreview"/> + <div :class="$style.themeItemCaption">{{ theme.name }}</div> + </label> + </div> + </div> + </FormSection> + </div> + </MkFolder> </SearchMarker> - </div> + </template> </div> <FormSection> @@ -77,12 +203,13 @@ import { computed, onActivated, ref, watch } from 'vue'; import JSON5 from 'json5'; import defaultLightTheme from '@@/themes/l-light.json5'; import defaultDarkTheme from '@@/themes/d-green-lime.json5'; -import type { MkSelectItem } from '@/components/MkSelect.vue'; +import type { Theme } from '@/theme.js'; import MkSwitch from '@/components/MkSwitch.vue'; -import MkSelect from '@/components/MkSelect.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkButton from '@/components/MkButton.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkThemePreview from '@/components/MkThemePreview.vue'; import { getBuiltinThemesRef } from '@/theme.js'; import { selectFile } from '@/utility/select-file.js'; import { isDeviceDarkmode } from '@/utility/is-device-darkmode.js'; @@ -99,79 +226,16 @@ import { prefer } from '@/preferences.js'; const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); -const instanceDarkTheme = computed(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); +const instanceDarkTheme = computed<Theme | null>(() => instance.defaultDarkTheme ? JSON5.parse(instance.defaultDarkTheme) : null); const installedDarkThemes = computed(() => installedThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); const builtinDarkThemes = computed(() => builtinThemes.value.filter(t => t.base === 'dark' || t.kind === 'dark')); -const instanceLightTheme = computed(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); +const instanceLightTheme = computed<Theme | null>(() => instance.defaultLightTheme ? JSON5.parse(instance.defaultLightTheme) : null); const installedLightThemes = computed(() => installedThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); const builtinLightThemes = computed(() => builtinThemes.value.filter(t => t.base === 'light' || t.kind === 'light')); const themes = computed(() => uniqueBy([instanceDarkTheme.value, instanceLightTheme.value, ...builtinThemes.value, ...installedThemes.value].filter(x => x != null), theme => theme.id)); -const lightThemeSelectorItems = computed(() => { - const items = [] as MkSelectItem[]; - if (instanceLightTheme.value) { - items.push({ - type: 'option', - value: instanceLightTheme.value.id, - label: instanceLightTheme.value.name, - }); - } - if (installedLightThemes.value.length > 0) { - items.push({ - type: 'group', - label: i18n.ts._theme.installedThemes, - items: installedLightThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - } - items.push({ - type: 'group', - label: i18n.ts._theme.builtinThemes, - items: builtinLightThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - return items; -}); - -const darkThemeSelectorItems = computed(() => { - const items = [] as MkSelectItem[]; - if (instanceDarkTheme.value) { - items.push({ - type: 'option', - value: instanceDarkTheme.value.id, - label: instanceDarkTheme.value.name, - }); - } - if (installedDarkThemes.value.length > 0) { - items.push({ - type: 'group', - label: i18n.ts._theme.installedThemes, - items: installedDarkThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - } - items.push({ - type: 'group', - label: i18n.ts._theme.builtinThemes, - items: builtinDarkThemes.value.map(x => ({ - type: 'option', - value: x.id, - label: x.name, - })), - }); - return items; -}); - const darkTheme = prefer.r.darkTheme; +const darkThemeName = computed(() => darkTheme.value?.name ?? defaultDarkTheme.name); const darkThemeId = computed({ get() { return darkTheme.value ? darkTheme.value.id : defaultDarkTheme.id; @@ -184,6 +248,7 @@ const darkThemeId = computed({ }, }); const lightTheme = prefer.r.lightTheme; +const lightThemeName = computed(() => lightTheme.value?.name ?? defaultLightTheme.name); const lightThemeId = computed({ get() { return lightTheme.value ? lightTheme.value.id : defaultLightTheme.id; @@ -236,6 +301,57 @@ definePage(() => ({ })); </script> +<style module> +.themeSelect { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: var(--MI-margin); +} + +.themeItemOuter { + position: relative; +} + +.themeRadio { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.themeItemRoot { + position: relative; + display: block; + overflow: clip; + box-sizing: border-box; + border: 2px solid var(--MI_THEME-divider); + border-radius: var(--MI-radius); +} + +.themeRadio:focus-visible + .themeItemRoot { + outline: 2px solid var(--MI_THEME-focus); + outline-offset: 2px; +} + +.themeRadio:checked + .themeItemRoot { + border-color: var(--MI_THEME-accent); +} + +.themeItemPreview { + display: block; + width: calc(100% + 2px); + height: auto; + margin-left: -1px; + border-bottom: 1px solid var(--MI_THEME-divider); +} + +.themeItemCaption { + box-sizing: border-box; + padding: 8px 12px; + text-align: center; + font-size: 80%; +} +</style> + <style lang="scss" scoped> .rfqxtzch { border-radius: 6px; @@ -471,17 +587,4 @@ definePage(() => ({ border-top: solid 0.5px var(--MI_THEME-divider); } } - -.rsljpzjq { - > .selects { - display: flex; - gap: 1.5em var(--MI-margin); - flex-wrap: wrap; - - > .select { - flex: 1; - min-width: 280px; - } - } -} </style> diff --git a/packages/frontend/src/theme.ts b/packages/frontend/src/theme.ts index ed2f1d3164..970d143b97 100644 --- a/packages/frontend/src/theme.ts +++ b/packages/frontend/src/theme.ts @@ -114,7 +114,7 @@ export function applyTheme(theme: Theme, persist = true) { globalEvents.emit('themeChanging'); } -function compile(theme: Theme): Record<string, string> { +export function compile(theme: Theme): Record<string, string> { function getColor(val: string): tinycolor.Instance { if (val[0] === '@') { // ref (prop) return getColor(theme.props[val.substring(1)]); diff --git a/packages/frontend/src/utility/autogen/settings-search-index.ts b/packages/frontend/src/utility/autogen/settings-search-index.ts index 66476672e3..db4459bf06 100644 --- a/packages/frontend/src/utility/autogen/settings-search-index.ts +++ b/packages/frontend/src/utility/autogen/settings-search-index.ts @@ -33,12 +33,12 @@ export const searchIndexes: SearchIndexItem[] = [ keywords: ['light', 'theme'], }, { - id: 'eLOwK5Ia2', + id: 'CsSVILKpX', label: i18n.ts.themeForDarkMode, keywords: ['dark', 'theme'], }, { - id: 'ujvMfyzUr', + id: '8wcoRp76b', label: i18n.ts.setWallpaper, keywords: ['wallpaper'], },