diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 6eb90c9d02..40772f9b1f 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -466,6 +466,17 @@ details: "詳細"
 chooseEmoji: "絵文字を選択"
 unableToProcess: "操作を完了できません"
 recentUsed: "最近使用"
+install: "インストール"
+uninstall: "アンインストール"
+
+_theme:
+  explore: "テーマを探す"
+  install: "テーマのインストール"
+  manage: "テーマの管理"
+  code: "テーマコード"
+  installed: "{name}をインストールしました"
+  alreadyInstalled: "そのテーマは既にインストールされています"
+  invalid: "テーマの形式が間違っています"
 
 _sfx:
   note: "ノート"
diff --git a/src/client/components/error.vue b/src/client/components/error.vue
index 7446a7cb5d..dd9de43c16 100644
--- a/src/client/components/error.vue
+++ b/src/client/components/error.vue
@@ -1,6 +1,6 @@
 <template>
 <div class="mjndxjcg _panel">
-	<img src="https://xn--931a.moe/assets/error.png" class="_ghost"/>
+	<img src="https://xn--931a.moe/assets/error.jpg" class="_ghost"/>
 	<p><fa :icon="faExclamationTriangle"/> {{ $t('error') }}</p>
 	<mk-button @click="() => $emit('retry')" class="button">{{ $t('retry') }}</mk-button>
 </div>
diff --git a/src/client/components/notes.vue b/src/client/components/notes.vue
index bc2ae8472c..65dda17575 100644
--- a/src/client/components/notes.vue
+++ b/src/client/components/notes.vue
@@ -1,7 +1,7 @@
 <template>
 <div class="mk-notes" v-size="[{ max: 500 }]">
 	<div class="empty" v-if="empty">
-		<img src="https://xn--931a.moe/assets/info.png" class="_ghost"/>
+		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 		<div>{{ $t('noNotes') }}</div>
 	</div>
 
diff --git a/src/client/init.ts b/src/client/init.ts
index c7587afb8c..b9c6aedae4 100644
--- a/src/client/init.ts
+++ b/src/client/init.ts
@@ -178,6 +178,7 @@ os.init(async () => {
 		},
 		watch: {
 			'$store.state.device.darkMode'() {
+				// TODO: このファイルでbuiltinThemesを参照するとcode splittingが効かず、初回読み込み時に全てのテーマコードを読み込むことになってしまい無駄なので何とかする
 				const themes = builtinThemes.concat(this.$store.state.device.themes);
 				applyTheme(themes.find(x => x.id === (this.$store.state.device.darkMode ? this.$store.state.device.darkTheme : this.$store.state.device.lightTheme)));
 			}
diff --git a/src/client/pages/follow-requests.vue b/src/client/pages/follow-requests.vue
index 14d60a12ec..a900bf735c 100644
--- a/src/client/pages/follow-requests.vue
+++ b/src/client/pages/follow-requests.vue
@@ -6,7 +6,7 @@
 	<mk-pagination :pagination="pagination" class="mk-follow-requests" ref="list">
 		<template #empty>
 			<div class="tkdrhpxr">
-				<img src="https://xn--931a.moe/assets/info.png" class="_ghost"/>
+				<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 				<div>{{ $t('noFollowRequests') }}</div>
 			</div>
 		</template>
diff --git a/src/client/pages/messaging/index.vue b/src/client/pages/messaging/index.vue
index 702979a098..ed24f8ef54 100644
--- a/src/client/pages/messaging/index.vue
+++ b/src/client/pages/messaging/index.vue
@@ -32,7 +32,7 @@
 		</router-link>
 	</div>
 	<div class="no-history" v-if="!fetching && messages.length == 0">
-		<img src="https://xn--931a.moe/assets/info.png" class="_ghost"/>
+		<img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/>
 		<div>{{ $t('noHistory') }}</div>
 	</div>
 	<mk-loading v-if="fetching"/>
diff --git a/src/client/pages/not-found.vue b/src/client/pages/not-found.vue
index 6ddbd1932b..9608e07786 100644
--- a/src/client/pages/not-found.vue
+++ b/src/client/pages/not-found.vue
@@ -5,7 +5,7 @@
 
 	<section class="_card">
 		<div class="_content">
-			<img src="https://xn--931a.moe/assets/not-found.png" class="_ghost"/>
+			<img src="https://xn--931a.moe/assets/not-found.jpg" class="_ghost"/>
 			<div>{{ $t('notFoundDescription') }}</div>
 		</div>
 	</section>
diff --git a/src/client/pages/preferences/theme.vue b/src/client/pages/preferences/theme.vue
index 488935a0cd..fcea457396 100644
--- a/src/client/pages/preferences/theme.vue
+++ b/src/client/pages/preferences/theme.vue
@@ -42,6 +42,7 @@
 				<option v-for="x in lightThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
 			</optgroup>
 		</mk-select>
+		<a href="https://assets.msky.cafe/theme/list" rel="noopener" target="_blank" class="_link">{{ $t('_theme.explore') }}</a>
 	</div>
 	<div class="_content">
 		<mk-switch v-model="syncDeviceDarkMode">{{ $t('syncDeviceDarkMode') }}</mk-switch>
@@ -50,18 +51,43 @@
 		<mk-button primary v-if="wallpaper == null" @click="setWallpaper">{{ $t('setWallpaper') }}</mk-button>
 		<mk-button primary v-else @click="wallpaper = null">{{ $t('removeWallpaper') }}</mk-button>
 	</div>
+	<div class="_content">
+		<details>
+			<summary><fa :icon="faDownload"/> {{ $t('_theme.install') }}</summary>
+			<mk-textarea v-model="installThemeCode">
+				<span>{{ $t('_theme.code') }}</span>
+			</mk-textarea>
+			<mk-button @click="() => install(this.installThemeCode)" :disabled="installThemeCode == null"><fa :icon="faCheck"/> {{ $t('install') }}</mk-button>
+		</details>
+	</div>
+	<div class="_content">
+		<details>
+			<summary><fa :icon="faFolderOpen"/> {{ $t('_theme.manage') }}</summary>
+			<mk-select v-model="selectedThemeId">
+				<option v-for="x in installedThemes" :value="x.id" :key="x.id">{{ x.name }}</option>
+			</mk-select>
+			<template v-if="selectedTheme">
+				<mk-textarea readonly tall :value="selectedThemeCode">
+					<span>{{ $t('_theme.code') }}</span>
+				</mk-textarea>
+				<mk-button @click="uninstall()" v-if="!builtinThemes.some(t => t.id == selectedTheme.id)"><fa :icon="faTrashAlt"/> {{ $t('uninstall') }}</mk-button>
+			</template>
+		</details>
+	</div>
 </section>
 </template>
 
 <script lang="ts">
 import Vue from 'vue';
-import { faPalette } from '@fortawesome/free-solid-svg-icons';
+import { faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
+import * as JSON5 from 'json5';
 import MkInput from '../../components/ui/input.vue';
 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 i18n from '../../i18n';
-import { Theme, builtinThemes, applyTheme } from '../../theme';
+import { Theme, builtinThemes, applyTheme, validateTheme } from '../../theme';
 import { selectFile } from '../../scripts/select-file';
 import { isDeviceDarkmode } from '../../scripts/is-device-darkmode';
 
@@ -73,12 +99,16 @@ export default Vue.extend({
 		MkButton,
 		MkSelect,
 		MkSwitch,
+		MkTextarea,
 	},
 	
 	data() {
 		return {
+			builtinThemes,
+			installThemeCode: null,
+			selectedThemeId: null,
 			wallpaper: localStorage.getItem('wallpaper'),
-			faPalette
+			faPalette, faDownload, faFolderOpen, faCheck, faTrashAlt
 		}
 	},
 
@@ -118,6 +148,16 @@ export default Vue.extend({
 			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: {
@@ -155,6 +195,53 @@ export default Vue.extend({
 				this.wallpaper = file.url;
 			});
 		},
+
+		install(code) {
+			let theme;
+			try {
+				theme = JSON5.parse(code);
+			} catch (e) {
+				this.$root.dialog({
+					type: 'error',
+					text: this.$t('_theme.invalid')
+				});
+				return;
+			}
+			if (!validateTheme(theme)) {
+				this.$root.dialog({
+					type: 'error',
+					text: this.$t('_theme.invalid')
+				});
+				return;
+			}
+			if (this.$store.state.device.themes.some(t => t.id === theme.id)) {
+				this.$root.dialog({
+					type: 'info',
+					text: this.$t('_theme.alreadyInstalled')
+				});
+				return;
+			}
+			const themes = this.$store.state.device.themes.concat(theme);
+			this.$store.commit('device/set', {
+				key: 'themes', value: themes
+			});
+			this.$root.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
+			});
+			this.$root.dialog({
+				type: 'info',
+				iconOnly: true, autoClose: true
+			});
+		},
 	}
 });
 </script>
@@ -179,7 +266,7 @@ export default Vue.extend({
 				top: 50%;
 				left: 50%;
 				overflow: hidden;
-				padding: 0 200px;
+				padding: 0 100px;
 				transform: translate3d(-50%, -50%, 0);
 
 				input {
diff --git a/src/client/theme.ts b/src/client/theme.ts
index 2a6adbffcc..e90c1f3a3b 100644
--- a/src/client/theme.ts
+++ b/src/client/theme.ts
@@ -102,3 +102,10 @@ function compile(theme: Theme): { [key: string]: string } {
 function genValue(c: tinycolor.Instance): string {
 	return c.toRgbString();
 }
+
+export function validateTheme(theme: Record<string, any>): boolean {
+	if (theme.id == null) return false;
+	if (theme.name == null) return false;
+	if (theme.base == null || !['light', 'dark'].includes(theme.base)) return false;
+	return true;
+}
diff --git a/src/server/web/views/base.pug b/src/server/web/views/base.pug
index e6751ecca2..76114e6f5a 100644
--- a/src/server/web/views/base.pug
+++ b/src/server/web/views/base.pug
@@ -16,9 +16,9 @@ html
 		link(rel='icon' href= icon || '/favicon.ico')
 		link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png')
 		link(rel='manifest' href='/manifest.json')
-		link(rel='prefetch' href='https://xn--931a.moe/assets/info.png')
-		link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.png')
-		link(rel='prefetch' href='https://xn--931a.moe/assets/error.png')
+		link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg')
+		link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg')
+		link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg')
 
 		title
 			block title