From d435d04eaf992f994ff4e690a658207757c8bdf3 Mon Sep 17 00:00:00 2001
From: syuilo <4439005+syuilo@users.noreply.github.com>
Date: Sun, 22 Sep 2024 18:26:21 +0900
Subject: [PATCH] enhance(frontend): tweak control panel

---
 packages/frontend/src/components/MkFolder.vue |  18 ++
 .../frontend/src/pages/admin/settings.vue     | 210 +++++++++++-------
 2 files changed, 151 insertions(+), 77 deletions(-)

diff --git a/packages/frontend/src/components/MkFolder.vue b/packages/frontend/src/components/MkFolder.vue
index f805be7b57..79676e8354 100644
--- a/packages/frontend/src/components/MkFolder.vue
+++ b/packages/frontend/src/components/MkFolder.vue
@@ -41,6 +41,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<MkSpacer :marginMin="14" :marginMax="22">
 							<slot></slot>
 						</MkSpacer>
+						<div :class="$style.footer" v-if="withFooter">
+							<slot name="footer"></slot>
+						</div>
 					</div>
 				</KeepAlive>
 			</Transition>
@@ -56,9 +59,11 @@ import { defaultStore } from '@/store.js';
 const props = withDefaults(defineProps<{
 	defaultOpen?: boolean;
 	maxHeight?: number | null;
+	withFooter?: boolean;
 }>(), {
 	defaultOpen: false,
 	maxHeight: null,
+	withFooter: false
 });
 
 const getBgColor = (el: HTMLElement) => {
@@ -224,4 +229,17 @@ onMounted(() => {
 		background: var(--bg);
 	}
 }
+
+.footer {
+	position: sticky !important;
+	z-index: 1;
+	bottom: var(--stickyBottom, 0px);
+	left: 0;
+	padding: 9px 12px;
+	border-top: solid 0.5px var(--divider);
+	background: var(--acrylicBg);
+	-webkit-backdrop-filter: var(--blur, blur(15px));
+	backdrop-filter: var(--blur, blur(15px));
+	border-radius: 0 0 6px 6px;
+}
 </style>
diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue
index 6eaafed6df..1e9682775a 100644
--- a/packages/frontend/src/pages/admin/settings.vue
+++ b/packages/frontend/src/pages/admin/settings.vue
@@ -10,71 +10,93 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<MkSpacer :contentMax="700" :marginMin="16" :marginMax="32">
 			<FormSuspense :p="init">
 				<div class="_gaps_m">
-					<MkInput v-model="name">
-						<template #label>{{ i18n.ts.instanceName }}</template>
-					</MkInput>
+					<MkFolder :defaultOpen="true" :withFooter="true">
+						<template #icon><i class="ti ti-info-circle"></i></template>
+						<template #label>{{ i18n.ts.info }}</template>
+						<template #footer>
+							<MkButton primary rounded @click="saveInfo">{{ i18n.ts.save }}</MkButton>
+						</template>
 
-					<MkInput v-model="shortName">
-						<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})</template>
-						<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
-					</MkInput>
+						<div class="_gaps">
+							<MkInput v-model="name">
+								<template #label>{{ i18n.ts.instanceName }}</template>
+							</MkInput>
 
-					<MkTextarea v-model="description">
-						<template #label>{{ i18n.ts.instanceDescription }}</template>
-					</MkTextarea>
+							<MkInput v-model="shortName">
+								<template #label>{{ i18n.ts._serverSettings.shortName }} ({{ i18n.ts.optional }})</template>
+								<template #caption>{{ i18n.ts._serverSettings.shortNameDescription }}</template>
+							</MkInput>
 
-					<FormSplit :minWidth="300">
-						<MkInput v-model="maintainerName">
-							<template #label>{{ i18n.ts.maintainerName }}</template>
-						</MkInput>
+							<MkTextarea v-model="description">
+								<template #label>{{ i18n.ts.instanceDescription }}</template>
+							</MkTextarea>
 
-						<MkInput v-model="maintainerEmail" type="email">
-							<template #prefix><i class="ti ti-mail"></i></template>
-							<template #label>{{ i18n.ts.maintainerEmail }}</template>
-						</MkInput>
-					</FormSplit>
+							<FormSplit :minWidth="300">
+								<MkInput v-model="maintainerName">
+									<template #label>{{ i18n.ts.maintainerName }}</template>
+								</MkInput>
 
-					<MkInput v-model="tosUrl" type="url">
-						<template #prefix><i class="ti ti-link"></i></template>
-						<template #label>{{ i18n.ts.tosUrl }}</template>
-					</MkInput>
+								<MkInput v-model="maintainerEmail" type="email">
+									<template #prefix><i class="ti ti-mail"></i></template>
+									<template #label>{{ i18n.ts.maintainerEmail }}</template>
+								</MkInput>
+							</FormSplit>
 
-					<MkInput v-model="privacyPolicyUrl" type="url">
-						<template #prefix><i class="ti ti-link"></i></template>
-						<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
-					</MkInput>
+							<MkInput v-model="tosUrl" type="url">
+								<template #prefix><i class="ti ti-link"></i></template>
+								<template #label>{{ i18n.ts.tosUrl }}</template>
+							</MkInput>
 
-					<MkInput v-model="inquiryUrl" type="url">
-						<template #prefix><i class="ti ti-link"></i></template>
-						<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template>
-						<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
-					</MkInput>
+							<MkInput v-model="privacyPolicyUrl" type="url">
+								<template #prefix><i class="ti ti-link"></i></template>
+								<template #label>{{ i18n.ts.privacyPolicyUrl }}</template>
+							</MkInput>
 
-					<MkInput v-model="repositoryUrl" type="url">
-						<template #label>{{ i18n.ts.repositoryUrl }}</template>
-						<template #prefix><i class="ti ti-link"></i></template>
-						<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
-					</MkInput>
+							<MkInput v-model="inquiryUrl" type="url">
+								<template #prefix><i class="ti ti-link"></i></template>
+								<template #label>{{ i18n.ts._serverSettings.inquiryUrl }}</template>
+								<template #caption>{{ i18n.ts._serverSettings.inquiryUrlDescription }}</template>
+							</MkInput>
 
-					<MkInfo v-if="!instance.providesTarball && !repositoryUrl" warn>
-						{{ i18n.ts.repositoryUrlOrTarballRequired }}
-					</MkInfo>
+							<MkInput v-model="repositoryUrl" type="url">
+								<template #label>{{ i18n.ts.repositoryUrl }}</template>
+								<template #prefix><i class="ti ti-link"></i></template>
+								<template #caption>{{ i18n.ts.repositoryUrlDescription }}</template>
+							</MkInput>
 
-					<MkInput v-model="impressumUrl" type="url">
-						<template #label>{{ i18n.ts.impressumUrl }}</template>
-						<template #prefix><i class="ti ti-link"></i></template>
-						<template #caption>{{ i18n.ts.impressumDescription }}</template>
-					</MkInput>
+							<MkInfo v-if="!instance.providesTarball && !repositoryUrl" warn>
+								{{ i18n.ts.repositoryUrlOrTarballRequired }}
+							</MkInfo>
 
-					<MkTextarea v-model="pinnedUsers">
+							<MkInput v-model="impressumUrl" type="url">
+								<template #label>{{ i18n.ts.impressumUrl }}</template>
+								<template #prefix><i class="ti ti-link"></i></template>
+								<template #caption>{{ i18n.ts.impressumDescription }}</template>
+							</MkInput>
+						</div>
+					</MkFolder>
+
+					<MkFolder :withFooter="true">
+						<template #icon><i class="ti ti-user-star"></i></template>
 						<template #label>{{ i18n.ts.pinnedUsers }}</template>
-						<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
-					</MkTextarea>
+						<template #footer>
+							<MkButton primary rounded @click="save_pinnedUsers">{{ i18n.ts.save }}</MkButton>
+						</template>
 
-					<FormSection>
+						<MkTextarea v-model="pinnedUsers">
+							<template #label>{{ i18n.ts.pinnedUsers }}</template>
+							<template #caption>{{ i18n.ts.pinnedUsersDescription }}</template>
+						</MkTextarea>
+					</MkFolder>
+
+					<MkFolder :withFooter="true">
+						<template #icon><i class="ti ti-cloud"></i></template>
 						<template #label>{{ i18n.ts.files }}</template>
+						<template #footer>
+							<MkButton primary rounded @click="saveFiles">{{ i18n.ts.save }}</MkButton>
+						</template>
 
-						<div class="_gaps_m">
+						<div class="_gaps">
 							<MkSwitch v-model="cacheRemoteFiles">
 								<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
 								<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}{{ i18n.ts.youCanCleanRemoteFilesCache }}</template>
@@ -87,12 +109,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 								</MkSwitch>
 							</template>
 						</div>
-					</FormSection>
+					</MkFolder>
 
-					<FormSection>
+					<MkFolder :withFooter="true">
+						<template #icon><i class="ti ti-world-cog"></i></template>
 						<template #label>ServiceWorker</template>
+						<template #footer>
+							<MkButton primary rounded @click="saveServiceWorker">{{ i18n.ts.save }}</MkButton>
+						</template>
 
-						<div class="_gaps_m">
+						<div class="_gaps">
 							<MkSwitch v-model="enableServiceWorker">
 								<template #label>{{ i18n.ts.enableServiceworker }}</template>
 								<template #caption>{{ i18n.ts.serviceworkerInfo }}</template>
@@ -110,12 +136,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 								</MkInput>
 							</template>
 						</div>
-					</FormSection>
+					</MkFolder>
 
-					<FormSection>
+					<MkFolder :withFooter="true">
+						<template #icon><i class="ti ti-ad"></i></template>
 						<template #label>{{ i18n.ts._ad.adsSettings }}</template>
+						<template #footer>
+							<MkButton primary rounded @click="saveAd">{{ i18n.ts.save }}</MkButton>
+						</template>
 
-						<div class="_gaps_m">
+						<div class="_gaps">
 							<div class="_gaps_s">
 								<MkInput v-model="notesPerOneAd" :min="0" type="number">
 									<template #label>{{ i18n.ts._ad.notesPerOneAd }}</template>
@@ -126,12 +156,16 @@ SPDX-License-Identifier: AGPL-3.0-only
 								</MkInfo>
 							</div>
 						</div>
-					</FormSection>
+					</MkFolder>
 
-					<FormSection>
+					<MkFolder :withFooter="true">
+						<template #icon><i class="ti ti-world-search"></i></template>
 						<template #label>{{ i18n.ts._urlPreviewSetting.title }}</template>
+						<template #footer>
+							<MkButton primary rounded @click="saveUrlPreview">{{ i18n.ts.save }}</MkButton>
+						</template>
 
-						<div class="_gaps_m">
+						<div class="_gaps">
 							<MkSwitch v-model="urlPreviewEnabled">
 								<template #label>{{ i18n.ts._urlPreviewSetting.enable }}</template>
 							</MkSwitch>
@@ -173,17 +207,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 								</div>
 							</div>
 						</div>
-					</FormSection>
+					</MkFolder>
 				</div>
 			</FormSuspense>
 		</MkSpacer>
-		<template #footer>
-			<div :class="$style.footer">
-				<MkSpacer :contentMax="700" :marginMin="16" :marginMax="16">
-					<MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
-				</MkSpacer>
-			</div>
-		</template>
 	</MkStickyContainer>
 </div>
 </template>
@@ -195,7 +222,6 @@ import MkSwitch from '@/components/MkSwitch.vue';
 import MkInput from '@/components/MkInput.vue';
 import MkTextarea from '@/components/MkTextarea.vue';
 import MkInfo from '@/components/MkInfo.vue';
-import FormSection from '@/components/form/section.vue';
 import FormSplit from '@/components/form/split.vue';
 import FormSuspense from '@/components/form/suspense.vue';
 import * as os from '@/os.js';
@@ -258,8 +284,8 @@ async function init(): Promise<void> {
 	urlPreviewSummaryProxyUrl.value = meta.urlPreviewSummaryProxyUrl;
 }
 
-async function save() {
-	await os.apiWithDialog('admin/update-meta', {
+function saveInfo() {
+	os.apiWithDialog('admin/update-meta', {
 		name: name.value,
 		shortName: shortName.value === '' ? null : shortName.value,
 		description: description.value,
@@ -270,22 +296,57 @@ async function save() {
 		inquiryUrl: inquiryUrl.value,
 		repositoryUrl: repositoryUrl.value,
 		impressumUrl: impressumUrl.value,
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
+function save_pinnedUsers() {
+	os.apiWithDialog('admin/update-meta', {
 		pinnedUsers: pinnedUsers.value.split('\n'),
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
+function saveFiles() {
+	os.apiWithDialog('admin/update-meta', {
 		cacheRemoteFiles: cacheRemoteFiles.value,
 		cacheRemoteSensitiveFiles: cacheRemoteSensitiveFiles.value,
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
+function saveServiceWorker() {
+	os.apiWithDialog('admin/update-meta', {
 		enableServiceWorker: enableServiceWorker.value,
 		swPublicKey: swPublicKey.value,
 		swPrivateKey: swPrivateKey.value,
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
+function saveAd() {
+	os.apiWithDialog('admin/update-meta', {
 		notesPerOneAd: notesPerOneAd.value,
+	}).then(() => {
+		fetchInstance(true);
+	});
+}
+
+function saveUrlPreview() {
+	os.apiWithDialog('admin/update-meta', {
 		urlPreviewEnabled: urlPreviewEnabled.value,
 		urlPreviewTimeout: urlPreviewTimeout.value,
 		urlPreviewMaximumContentLength: urlPreviewMaximumContentLength.value,
 		urlPreviewRequireContentLength: urlPreviewRequireContentLength.value,
 		urlPreviewUserAgent: urlPreviewUserAgent.value,
 		urlPreviewSummaryProxyUrl: urlPreviewSummaryProxyUrl.value,
+	}).then(() => {
+		fetchInstance(true);
 	});
-
-	fetchInstance(true);
 }
 
 const headerTabs = computed(() => []);
@@ -297,11 +358,6 @@ definePageMetadata(() => ({
 </script>
 
 <style lang="scss" module>
-.footer {
-	-webkit-backdrop-filter: var(--blur, blur(15px));
-	backdrop-filter: var(--blur, blur(15px));
-}
-
 .subCaption {
 	font-size: 0.85em;
 	color: var(--fgTransparentWeak);