From 6a82e94c5489d4879cbbf86091cd15c7d144f284 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 29 Sep 2018 00:01:11 +0900
Subject: [PATCH] wip

---
 locales/ja-JP.yml                             |  24 +++
 package.json                                  |   1 +
 src/client/app/app.vue                        |  12 +-
 .../app/common/views/components/avatar.vue    |   4 +-
 .../app/common/views/components/index.ts      |   2 +
 .../app/common/views/components/theme.vue     | 179 ++++++++++++++++++
 .../app/common/views/components/ui/button.vue |  12 +-
 .../app/desktop/views/components/settings.vue |   5 +
 src/client/app/init.ts                        |  35 +++-
 .../app/mobile/views/pages/settings.vue       |   7 +
 src/client/app/store.ts                       |   3 +
 src/client/app/{common/scripts => }/theme.ts  |  27 ++-
 src/client/theme/dark.json                    |   2 +-
 src/client/theme/halloween.json               |   3 +-
 src/client/theme/light.json                   |   2 +-
 15 files changed, 287 insertions(+), 31 deletions(-)
 create mode 100644 src/client/app/common/views/components/theme.vue
 rename src/client/app/{common/scripts => }/theme.ts (74%)

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 0f83ba8419..46dea949d2 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -285,6 +285,28 @@ common/views/components/media-banner.vue:
   sensitive: "閲覧注意"
   click-to-show: "クリックして表示"
 
+common/views/components/theme.vue:
+  light-theme: "非ダークモード時に使用するテーマ"
+  dark-theme: "ダークモード時に使用するテーマ"
+  install-a-theme: "テーマのインストール"
+  theme-code: "テーマコード"
+  install: "インストール"
+  create-a-theme: "テーマの作成"
+  save-created-theme: "テーマを保存"
+  primary-color: "プライマリ カラー"
+  secondary-color: "セカンダリ カラー"
+  text-color: "文字色"
+  base-theme: "ベーステーマ"
+  base-theme-light: "Light"
+  base-theme-dark: "Dark"
+  theme-name: "テーマ名"
+  preview-created-theme: "プレビュー"
+  invalid-theme: "テーマが正しくありません。"
+  already-installed: "既にそのテーマはインストールされています。"
+  saved: "保存しました"
+  installed-themes: "インストールされたテーマ"
+  select-theme: "テーマを選択してください"
+
 common/views/components/cw-button.vue:
   hide: "隠す"
   show: "もっと見る"
@@ -762,6 +784,7 @@ desktop/views/components/settings.vue:
   2fa: "二段階認証"
   other: "その他"
   license: "ライセンス"
+  theme: "テーマ"
 
   behaviour: "動作"
   fetch-on-scroll: "スクロールで自動読み込み"
@@ -1417,6 +1440,7 @@ mobile/views/pages/settings.vue:
   notification-position: "通知の表示"
   notification-position-bottom: "下"
   notification-position-top: "上"
+  theme: "テーマ"
   behavior: "動作"
   fetch-on-scroll: "スクロールで自動読み込み"
   note-visibility: "投稿の公開範囲"
diff --git a/package.json b/package.json
index 347e9d0c24..e19283cf64 100644
--- a/package.json
+++ b/package.json
@@ -208,6 +208,7 @@
 		"v-animate-css": "0.0.2",
 		"vue": "2.5.17",
 		"vue-chartjs": "3.4.0",
+		"vue-color": "2.6.0",
 		"vue-cropperjs": "2.2.2",
 		"vue-js-modal": "1.3.26",
 		"vue-json-tree-view": "2.1.4",
diff --git a/src/client/app/app.vue b/src/client/app/app.vue
index 9b6af27ece..778e9f29cf 100644
--- a/src/client/app/app.vue
+++ b/src/client/app/app.vue
@@ -14,8 +14,7 @@ export default Vue.extend({
 		keymap(): any {
 			return {
 				'h|slash': this.help,
-				'd': this.dark,
-				'x': this.test
+				'd': this.dark
 			};
 		}
 	},
@@ -26,11 +25,10 @@ export default Vue.extend({
 		},
 
 		dark() {
-			applyTheme(darkTheme);
-		},
-
-		test() {
-			applyTheme(halloweenTheme);
+			this.$store.commit('device/set', {
+				key: 'darkmode',
+				value: !this.$store.state.device.darkmode
+			});
 		}
 	}
 });
diff --git a/src/client/app/common/views/components/avatar.vue b/src/client/app/common/views/components/avatar.vue
index ca09af87de..ac018abcfc 100644
--- a/src/client/app/common/views/components/avatar.vue
+++ b/src/client/app/common/views/components/avatar.vue
@@ -59,7 +59,9 @@ export default Vue.extend({
 		}
 	},
 	mounted() {
-		this.$el.style.color = `rgb(${this.user.avatarColor.slice(0, 3).join(',')})`;
+		if (this.user.avatarColor) {
+			this.$el.style.color = `rgb(${this.user.avatarColor.slice(0, 3).join(',')})`;
+		}
 	},
 	methods: {
 		onClick(e) {
diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index 4c1c0afa80..0dea38a7a1 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -1,5 +1,6 @@
 import Vue from 'vue';
 
+import theme from './theme.vue';
 import instance from './instance.vue';
 import cwButton from './cw-button.vue';
 import tagCloud from './tag-cloud.vue';
@@ -43,6 +44,7 @@ import uiSelect from './ui/select.vue';
 import formButton from './ui/form/button.vue';
 import formRadio from './ui/form/radio.vue';
 
+Vue.component('mk-theme', theme);
 Vue.component('mk-instance', instance);
 Vue.component('mk-cw-button', cwButton);
 Vue.component('mk-tag-cloud', tagCloud);
diff --git a/src/client/app/common/views/components/theme.vue b/src/client/app/common/views/components/theme.vue
new file mode 100644
index 0000000000..27888d1e85
--- /dev/null
+++ b/src/client/app/common/views/components/theme.vue
@@ -0,0 +1,179 @@
+<template>
+<div class="nicnklzforebnpfgasiypmpdaaglujqm">
+	<label>
+		<span>%i18n:@light-theme%</span>
+		<ui-select v-model="light" placeholder="%i18n:@light-theme%">
+			<option v-for="x in themes" :value="x.meta.id" :key="x.meta.id">{{ x.meta.name }}</option>
+		</ui-select>
+	</label>
+
+	<label>
+		<span>%i18n:@dark-theme%</span>
+		<ui-select v-model="dark" placeholder="%i18n:@dark-theme%">
+			<option v-for="x in themes" :value="x.meta.id" :key="x.meta.id">{{ x.meta.name }}</option>
+		</ui-select>
+	</label>
+
+	<details class="creator">
+		<summary>%i18n:@create-a-theme%</summary>
+		<div>
+			<span>%i18n:@base-theme%:</span>
+			<ui-radio v-model="myThemeBase" value="light">%i18n:@base-theme-light%</ui-radio>
+			<ui-radio v-model="myThemeBase" value="dark">%i18n:@base-theme-dark%</ui-radio>
+		</div>
+		<div>
+			<ui-input v-model="myThemeName">
+				<span>%i18n:@theme-name%</span>
+			</ui-input>
+		</div>
+		<div>
+			<div style="padding-bottom:8px;">%i18n:@primary-color%:</div>
+			<color-picker v-model="myThemePrimary"/>
+		</div>
+		<div>
+			<div style="padding-bottom:8px;">%i18n:@secondary-color%:</div>
+			<color-picker v-model="myThemeSecondary"/>
+		</div>
+		<div>
+			<div style="padding-bottom:8px;">%i18n:@text-color%:</div>
+			<color-picker v-model="myThemeText"/>
+		</div>
+		<ui-button @click="preview()">%i18n:@preview-created-theme%</ui-button>
+		<ui-button primary @click="gen()">%i18n:@save-created-theme%</ui-button>
+	</details>
+
+	<details>
+		<summary>%i18n:@install-a-theme%</summary>
+		<ui-textarea v-model="installThemeCode">
+			<span>%i18n:@theme-code%</span>
+		</ui-textarea>
+		<ui-button @click="install()">%i18n:@install%</ui-button>
+	</details>
+
+	<details>
+		<summary>%i18n:@installed-themes%</summary>
+		<ui-select v-model="selectedInstalledTheme" placeholder="%i18n:@select-theme%">
+			<option v-for="x in installedThemes" :value="x.meta.id" :key="x.meta.id">{{ x.meta.name }}</option>
+		</ui-select>
+		<ui-textarea readonly :value="selectedInstalledThemeCode">
+			<span>%i18n:@theme-code%</span>
+		</ui-textarea>
+	</details>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { apiUrl, docsUrl } from '../../../config';
+import { lightTheme, darkTheme, builtinThemes, applyTheme } from '../../../theme';
+import { Chrome } from 'vue-color';
+import * as uuid from 'uuid';
+import * as tinycolor from 'tinycolor2';
+
+export default Vue.extend({
+	components: {
+		ColorPicker: Chrome
+	},
+
+	data() {
+		return {
+			installThemeCode: null,
+			selectedInstalledTheme: null,
+			myThemeBase: 'light',
+			myThemeName: '',
+			myThemePrimary: lightTheme.meta.vars.primary,
+			myThemeSecondary: lightTheme.meta.vars.secondary,
+			myThemeText: lightTheme.meta.vars.text
+		};
+	},
+
+	computed: {
+		themes(): any {
+			return this.$store.state.device.themes.concat(builtinThemes);
+		},
+
+		installedThemes(): any {
+			return this.$store.state.device.themes;
+		},
+
+		light: {
+			get() { return this.$store.state.device.lightTheme; },
+			set(value) { this.$store.commit('device/set', { key: 'lightTheme', value }); }
+		},
+
+		dark: {
+			get() { return this.$store.state.device.darkTheme; },
+			set(value) { this.$store.commit('device/set', { key: 'darkTheme', value }); }
+		},
+
+		selectedInstalledThemeCode() {
+			if (this.selectedInstalledTheme == null) return null;
+			return JSON.stringify(this.installedThemes.find(x => x.meta.id == this.selectedInstalledTheme));
+		},
+
+		myTheme(): any {
+			return {
+				meta: {
+					name: this.myThemeName,
+					author: this.$store.state.i.name,
+					base: this.myThemeBase,
+					vars: {
+						primary: tinycolor(typeof this.myThemePrimary == 'string' ? this.myThemePrimary : this.myThemePrimary.rgba).toRgbString(),
+						secondary: tinycolor(typeof this.myThemeSecondary == 'string' ? this.myThemeSecondary : this.myThemeSecondary.rgba).toRgbString(),
+						text: tinycolor(typeof this.myThemeText == 'string' ? this.myThemeText : this.myThemeText.rgba).toRgbString()
+					}
+				}
+			};
+		}
+	},
+
+	watch: {
+		myThemeBase(v) {
+			const theme = v == 'light' ? lightTheme : darkTheme;
+			this.myThemePrimary = theme.meta.vars.primary;
+			this.myThemeSecondary = theme.meta.vars.secondary;
+			this.myThemeText = theme.meta.vars.text;
+		}
+	},
+
+	methods: {
+		install() {
+			const theme = JSON.parse(this.installThemeCode);
+			if (theme.meta == null || theme.meta.id == null) {
+				alert('%i18n:@invalid-theme%');
+				return;
+			}
+			if (this.$store.state.device.themes.some(t => t.meta.id == theme.meta.id)) {
+				alert('%i18n:@already-installed%');
+				return;
+			}
+			const themes = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+		},
+
+		preview() {
+			applyTheme(this.myTheme, false);
+		},
+
+		gen() {
+			const theme = this.myTheme;
+			theme.meta.id = uuid();
+			const themes = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			alert('%i18n:@saved%');
+		}
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.nicnklzforebnpfgasiypmpdaaglujqm
+	> .creator
+		> div
+			padding 16px 0
+			border-bottom solid 1px var(--faceDivider)
+</style>
diff --git a/src/client/app/common/views/components/ui/button.vue b/src/client/app/common/views/components/ui/button.vue
index a165d100a4..47644b32b5 100644
--- a/src/client/app/common/views/components/ui/button.vue
+++ b/src/client/app/common/views/components/ui/button.vue
@@ -27,14 +27,6 @@ export default Vue.extend({
 		return {
 			styl: 'fill'
 		};
-	},
-	inject: {
-		isCardChild: { default: false }
-	},
-	created() {
-		if (this.isCardChild) {
-			this.styl = 'line';
-		}
 	}
 });
 </script>
@@ -43,6 +35,7 @@ export default Vue.extend({
 .dmtdnykelhudezerjlfpbhgovrgnqqgr
 	display block
 	width 100%
+	min-height 40px
 	margin 0
 	padding 0
 	font-weight normal
@@ -52,6 +45,9 @@ export default Vue.extend({
 	outline none
 	box-shadow none
 
+	&:not(.inline) + .dmtdnykelhudezerjlfpbhgovrgnqqgr
+		margin-top 16px
+
 	&.inline
 		display inline-block
 		width auto
diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue
index c7d82590ea..1cb8d4d4c8 100644
--- a/src/client/app/desktop/views/components/settings.vue
+++ b/src/client/app/desktop/views/components/settings.vue
@@ -19,6 +19,11 @@
 			<x-profile/>
 		</section>
 
+		<section class="web" v-show="page == 'web'">
+			<h1>%i18n:@theme%</h1>
+			<mk-theme/>
+		</section>
+
 		<section class="web" v-show="page == 'web'">
 			<h1>%i18n:@behaviour%</h1>
 			<ui-switch v-model="fetchOnScroll">
diff --git a/src/client/app/init.ts b/src/client/app/init.ts
index 8d430ad7ff..802f7b42eb 100644
--- a/src/client/app/init.ts
+++ b/src/client/app/init.ts
@@ -14,11 +14,11 @@ import App from './app.vue';
 import checkForUpdate from './common/scripts/check-for-update';
 import MiOS, { API } from './mios';
 import { version, codename, lang } from './config';
-import applyTheme from './common/scripts/theme';
-const defaultTheme = require('../theme/light.json');
+import { builtinThemes, applyTheme } from './theme';
+const lightTheme = require('../theme/light.json');
 
 if (localStorage.getItem('theme') == null) {
-	applyTheme(defaultTheme);
+	applyTheme(lightTheme);
 }
 
 Vue.use(Vuex);
@@ -92,6 +92,35 @@ export default (callback: (launch: (router: VueRouter, api?: (os: MiOS) => API)
 		const launch = (router: VueRouter, api?: (os: MiOS) => API) => {
 			os.apis = api ? api(os) : null;
 
+			//#region theme
+			os.store.watch(s => {
+				return s.device.darkmode;
+			}, v => {
+				const themes = os.store.state.device.themes.concat(builtinThemes);
+				const dark = themes.find(t => t.meta.id == os.store.state.device.darkTheme);
+				const light = themes.find(t => t.meta.id == os.store.state.device.lightTheme);
+				applyTheme(v ? dark : light);
+			});
+			os.store.watch(s => {
+				return s.device.lightTheme;
+			}, v => {
+				const themes = os.store.state.device.themes.concat(builtinThemes);
+				const theme = themes.find(t => t.meta.id == v);
+				if (!os.store.state.device.darkmode) {
+					applyTheme(theme);
+				}
+			});
+			os.store.watch(s => {
+				return s.device.darkTheme;
+			}, v => {
+				const themes = os.store.state.device.themes.concat(builtinThemes);
+				const theme = themes.find(t => t.meta.id == v);
+				if (os.store.state.device.darkmode) {
+					applyTheme(theme);
+				}
+			});
+			//#endregion
+
 			//#region shadow
 			const shadow = '0 3px 8px rgba(0, 0, 0, 0.2)';
 			if (os.store.state.settings.useShadow) document.documentElement.style.setProperty('--shadow', shadow);
diff --git a/src/client/app/mobile/views/pages/settings.vue b/src/client/app/mobile/views/pages/settings.vue
index b83eaf6d33..94fa38cec9 100644
--- a/src/client/app/mobile/views/pages/settings.vue
+++ b/src/client/app/mobile/views/pages/settings.vue
@@ -23,6 +23,13 @@
 					<ui-switch v-model="games_reversi_useContrastStones">%i18n:common.use-contrast-reversi-stones%</ui-switch>
 				</section>
 
+				<section>
+					<header>%i18n:@theme%</header>
+					<div>
+						<mk-theme/>
+					</div>
+				</section>
+
 				<section>
 					<header>%i18n:@timeline%</header>
 					<div>
diff --git a/src/client/app/store.ts b/src/client/app/store.ts
index fbcc53d7be..545261225a 100644
--- a/src/client/app/store.ts
+++ b/src/client/app/store.ts
@@ -44,6 +44,9 @@ const defaultDeviceSettings = {
 	apiViaStream: true,
 	autoPopout: false,
 	darkmode: false,
+	darkTheme: 'dark',
+	lightTheme: 'light',
+	themes: [],
 	enableSounds: true,
 	soundVolume: 0.5,
 	lang: null,
diff --git a/src/client/app/common/scripts/theme.ts b/src/client/app/theme.ts
similarity index 74%
rename from src/client/app/common/scripts/theme.ts
rename to src/client/app/theme.ts
index 7a1c6abb76..1147ff300d 100644
--- a/src/client/app/common/scripts/theme.ts
+++ b/src/client/app/theme.ts
@@ -1,22 +1,21 @@
 import * as tinycolor from 'tinycolor2';
-const lightTheme = require('../../../theme/light');
-const darkTheme = require('../../../theme/dark');
 
 type Theme = {
 	meta: {
 		id: string;
 		name: string;
-		inherit: string;
+		author: string;
+		base?: string;
 		vars: any;
 	};
 } & {
 	[key: string]: string;
 };
 
-export default function(theme: Theme) {
-	if (theme.meta.inherit) {
-		const inherit = [lightTheme, darkTheme].find(x => x.meta.id == theme.meta.inherit);
-		theme = Object.assign({}, inherit, theme);
+export function applyTheme(theme: Theme, persisted = true) {
+	if (theme.meta.base) {
+		const base = [lightTheme, darkTheme].find(x => x.meta.id == theme.meta.base);
+		theme = Object.assign({}, base, theme);
 	}
 
 	const props = compile(theme);
@@ -26,7 +25,9 @@ export default function(theme: Theme) {
 		document.documentElement.style.setProperty(`--${k}`, v.toString());
 	});
 
-	localStorage.setItem('theme', JSON.stringify(props));
+	if (persisted) {
+		localStorage.setItem('theme', JSON.stringify(props));
+	}
 }
 
 function compile(theme: Theme): { [key: string]: string } {
@@ -87,3 +88,13 @@ function compile(theme: Theme): { [key: string]: string } {
 function genValue(c: tinycolor.Instance): string {
 	return c.toRgbString();
 }
+
+export const lightTheme = require('../theme/light.json');
+export const darkTheme = require('../theme/dark.json');
+export const halloweenTheme = require('../theme/halloween.json');
+
+export const builtinThemes = [
+	lightTheme,
+	darkTheme,
+	halloweenTheme
+];
diff --git a/src/client/theme/dark.json b/src/client/theme/dark.json
index 015225ddab..74447b8f2f 100644
--- a/src/client/theme/dark.json
+++ b/src/client/theme/dark.json
@@ -1,6 +1,6 @@
 {
 	"meta": {
-		"id": "9978f7f9-5616-44fd-a704-cc5985efdd63",
+		"id": "dark",
 		"name": "Dark",
 		"author": "syuilo",
 		"vars": {
diff --git a/src/client/theme/halloween.json b/src/client/theme/halloween.json
index 6e92db95ff..fb34db57a8 100644
--- a/src/client/theme/halloween.json
+++ b/src/client/theme/halloween.json
@@ -3,10 +3,9 @@
 		"id": "42e4f09b-67d5-498c-af7d-29faa54745b0",
 		"name": "Halloween",
 		"author": "syuilo",
-		"inherit": "9978f7f9-5616-44fd-a704-cc5985efdd63",
+		"base": "dark",
 		"vars": {
 			"primary": "#d67036",
-			"primaryForeground": "#fff",
 			"secondary": "#1f1d30",
 			"text": "#b1bee3"
 		}
diff --git a/src/client/theme/light.json b/src/client/theme/light.json
index 3d131f066a..1b6604e642 100644
--- a/src/client/theme/light.json
+++ b/src/client/theme/light.json
@@ -1,6 +1,6 @@
 {
 	"meta": {
-		"id": "406cfea3-a4e7-486c-9057-30ede1353c3f",
+		"id": "light",
 		"name": "Light",
 		"author": "syuilo",
 		"vars": {
-- 
GitLab