summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2025-03-10 18:35:51 +0900
committerGitHub <noreply@github.com>2025-03-10 09:35:51 +0000
commitf797765b1dd8af364c7effca3ed6a7a3e3cb040a (patch)
tree7be1e1cd0e40d8801e5768346196631a9d78d6ca /packages/frontend
parentenhance(frontend): add navbar transition animation (diff)
downloadmisskey-f797765b1dd8af364c7effca3ed6a7a3e3cb040a.tar.gz
misskey-f797765b1dd8af364c7effca3ed6a7a3e3cb040a.tar.bz2
misskey-f797765b1dd8af364c7effca3ed6a7a3e3cb040a.zip
enhance(frontend): テーマ設定で簡易プレビューを表示するように (#15643)
* enhance(frontend): テーマ設定で簡易プレビューを表示するように * Update Changelog * fix lint * 🎨 --------- Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/frontend')
-rw-r--r--packages/frontend/src/components/MkThemePreview.vue96
-rw-r--r--packages/frontend/src/pages/settings/theme.vue289
-rw-r--r--packages/frontend/src/theme.ts2
-rw-r--r--packages/frontend/src/utility/autogen/settings-search-index.ts4
4 files changed, 295 insertions, 96 deletions
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'],
},