From da75b53f52d886776694203caef19f8ff46b59a8 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sat, 16 Jul 2022 23:11:05 +0900
Subject: [PATCH] feat(client): registry editor

---
 CHANGELOG.md                                 |   8 ++
 packages/client/src/pages/registry.keys.vue  |  96 +++++++++++++++
 packages/client/src/pages/registry.value.vue | 123 +++++++++++++++++++
 packages/client/src/pages/registry.vue       |  74 +++++++++++
 packages/client/src/pages/settings/other.vue |   2 +
 packages/client/src/router.ts                |   9 ++
 6 files changed, 312 insertions(+)
 create mode 100644 packages/client/src/pages/registry.keys.vue
 create mode 100644 packages/client/src/pages/registry.value.vue
 create mode 100644 packages/client/src/pages/registry.vue

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 90325f136..a5e1fa65b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,14 @@
 You should also include the user name that made the change.
 -->
 
+## 12.x.x (unreleased)
+
+### Improvements
+- Client: registry editor @syuilo
+
+### Bugfixes
+- 
+
 ## 12.115.0 (2022/07/16)
 
 ### Improvements
diff --git a/packages/client/src/pages/registry.keys.vue b/packages/client/src/pages/registry.keys.vue
new file mode 100644
index 000000000..9d2f24f18
--- /dev/null
+++ b/packages/client/src/pages/registry.keys.vue
@@ -0,0 +1,96 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600">
+		<FormSplit>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ $ts._registry.domain }}</template>
+				<template #value>{{ $ts.system }}</template>
+			</MkKeyValue>
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ $ts._registry.scope }}</template>
+				<template #value>{{ scope.join('/') }}</template>
+			</MkKeyValue>
+		</FormSplit>
+		
+		<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
+
+		<FormSection v-if="keys">
+			<template #label>{{ i18n.ts.keys }}</template>
+			<div class="_formLinks">
+				<FormLink v-for="key in keys" :to="`/registry/value/system/${scope.join('/')}/${key[0]}`" class="_monospace">{{ key[0] }}<template #suffix>{{ key[1].toUpperCase() }}</template></FormLink>
+			</div>
+		</FormSection>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import JSON5 from 'json5';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkKeyValue from '@/components/key-value.vue';
+import FormSplit from '@/components/form/split.vue';
+
+const props = defineProps<{
+	path: string;
+}>();
+
+const scope = $computed(() => props.path.split('/'));
+
+let keys = $ref(null);
+
+function fetchKeys() {
+	os.api('i/registry/keys-with-type', {
+		scope: scope,
+	}).then(res => {
+		keys = Object.entries(res).sort((a, b) => a[0].localeCompare(b[0]));
+	});
+}
+
+async function createKey() {
+	const { canceled, result } = await os.form(i18n.ts._registry.createKey, {
+		key: {
+			type: 'string',
+			label: i18n.ts._registry.key,
+		},
+		value: {
+			type: 'string',
+			multiline: true,
+			label: i18n.ts.value,
+		},
+		scope: {
+			type: 'string',
+			label: i18n.ts._registry.scope,
+			default: scope.join('/'),
+		},
+	});
+	if (canceled) return;
+	os.apiWithDialog('i/registry/set', {
+		scope: result.scope.split('/'),
+		key: result.key,
+		value: JSON5.parse(result.value),
+	}).then(() => {
+		fetchKeys();
+	});
+}
+
+watch(() => props.path, fetchKeys, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.registry,
+	icon: 'fas fa-cogs',
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/registry.value.vue b/packages/client/src/pages/registry.value.vue
new file mode 100644
index 000000000..5291b2e4c
--- /dev/null
+++ b/packages/client/src/pages/registry.value.vue
@@ -0,0 +1,123 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600">
+		<FormInfo warn>{{ $ts.editTheseSettingsMayBreakAccount }}</FormInfo>
+
+		<template v-if="value">
+			<FormSplit>
+				<MkKeyValue class="_formBlock">
+					<template #key>{{ $ts._registry.domain }}</template>
+					<template #value>{{ $ts.system }}</template>
+				</MkKeyValue>
+				<MkKeyValue class="_formBlock">
+					<template #key>{{ $ts._registry.scope }}</template>
+					<template #value>{{ scope.join('/') }}</template>
+				</MkKeyValue>
+				<MkKeyValue class="_formBlock">
+					<template #key>{{ $ts._registry.key }}</template>
+					<template #value>{{ key }}</template>
+				</MkKeyValue>
+			</FormSplit>
+			
+			<FormTextarea v-model="valueForEditor" tall class="_formBlock _monospace">
+				<template #label>{{ $ts.value }} (JSON)</template>
+			</FormTextarea>
+
+			<MkButton class="_formBlock" primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+
+			<MkKeyValue class="_formBlock">
+				<template #key>{{ $ts.updatedAt }}</template>
+				<template #value><MkTime :time="value.updatedAt" mode="detail"/></template>
+			</MkKeyValue>
+
+			<MkButton danger @click="del"><i class="fas fa-trash"></i> {{ $ts.delete }}</MkButton>
+		</template>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import JSON5 from 'json5';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/ui/button.vue';
+import MkKeyValue from '@/components/key-value.vue';
+import FormTextarea from '@/components/form/textarea.vue';
+import FormSplit from '@/components/form/split.vue';
+import FormInfo from '@/components/ui/info.vue';
+
+const props = defineProps<{
+	path: string;
+}>();
+
+const scope = $computed(() => props.path.split('/').slice(0, -1));
+const key = $computed(() => props.path.split('/').at(-1));
+
+let value = $ref(null);
+let valueForEditor = $ref(null);
+
+function fetchValue() {
+	os.api('i/registry/get-detail', {
+		scope,
+		key,
+	}).then(res => {
+		value = res;
+		valueForEditor = JSON5.stringify(res.value, null, '\t');
+	});
+}
+
+async function save() {
+	try {
+		JSON5.parse(valueForEditor);
+	} catch (e) {
+		os.alert({
+			type: 'error',
+			text: i18n.ts.invalidValue,
+		});
+		return;
+	}
+	os.confirm({
+		type: 'warning',
+		text: i18n.ts.saveConfirm,
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.apiWithDialog('i/registry/set', {
+			scope,
+			key,
+			value: JSON5.parse(valueForEditor),
+		});
+	});
+}
+
+function del() {
+	os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteConfirm,
+	}).then(({ canceled }) => {
+		if (canceled) return;
+		os.apiWithDialog('i/registry/remove', {
+			scope,
+			key,
+		});
+	});
+}
+
+watch(() => props.path, fetchValue, { immediate: true });
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.registry,
+	icon: 'fas fa-cogs',
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/registry.vue b/packages/client/src/pages/registry.vue
new file mode 100644
index 000000000..a428755a8
--- /dev/null
+++ b/packages/client/src/pages/registry.vue
@@ -0,0 +1,74 @@
+<template>
+<MkStickyContainer>
+	<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :content-max="600">
+		<MkButton primary @click="createKey">{{ i18n.ts._registry.createKey }}</MkButton>
+
+		<FormSection v-if="scopes">
+			<template #label>{{ i18n.ts.system }}</template>
+			<div class="_formLinks">
+				<FormLink v-for="scope in scopes" :to="`/registry/keys/system/${scope.join('/')}`" class="_monospace">{{ scope.join('/') }}</FormLink>
+			</div>
+		</FormSection>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch } from 'vue';
+import JSON5 from 'json5';
+import * as os from '@/os';
+import { i18n } from '@/i18n';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/ui/button.vue';
+
+let scopes = $ref(null);
+
+function fetchScopes() {
+	os.api('i/registry/scopes').then(res => {
+		scopes = res.slice().sort((a, b) => a.join('/').localeCompare(b.join('/')));
+	});
+}
+
+async function createKey() {
+	const { canceled, result } = await os.form(i18n.ts._registry.createKey, {
+		key: {
+			type: 'string',
+			label: i18n.ts._registry.key,
+		},
+		value: {
+			type: 'string',
+			multiline: true,
+			label: i18n.ts.value,
+		},
+		scope: {
+			type: 'string',
+			label: i18n.ts._registry.scope,
+		},
+	});
+	if (canceled) return;
+	os.apiWithDialog('i/registry/set', {
+		scope: result.scope.split('/'),
+		key: result.key,
+		value: JSON5.parse(result.value),
+	}).then(() => {
+		fetchScopes();
+	});
+}
+
+fetchScopes();
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.registry,
+	icon: 'fas fa-cogs',
+});
+</script>
+
+<style lang="scss" scoped>
+</style>
diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue
index 52ef4d401..51dab04cf 100644
--- a/packages/client/src/pages/settings/other.vue
+++ b/packages/client/src/pages/settings/other.vue
@@ -10,6 +10,8 @@
 
 	<FormLink to="/settings/account-info" class="_formBlock">{{ i18n.ts.accountInfo }}</FormLink>
 
+	<FormLink to="/registry" class="_formBlock"><template #icon><i class="fas fa-cogs"></i></template>{{ i18n.ts.registry }}</FormLink>
+
 	<FormLink to="/settings/delete-account" class="_formBlock"><template #icon><i class="fas fa-exclamation-triangle"></i></template>{{ i18n.ts.closeAccount }}</FormLink>
 </div>
 </template>
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index 2ff41e972..b61b77eee 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -153,6 +153,15 @@ export const routes = [{
 }, {
 	path: '/channels',
 	component: page(() => import('./pages/channels.vue')),
+}, {
+	path: '/registry/keys/system/:path(*)?',
+	component: page(() => import('./pages/registry.keys.vue')),
+}, {
+	path: '/registry/value/system/:path(*)?',
+	component: page(() => import('./pages/registry.value.vue')),
+}, {
+	path: '/registry',
+	component: page(() => import('./pages/registry.vue')),
 }, {
 	path: '/admin/file/:fileId',
 	component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),