From 66f1aaf5f7ba793500123320ac723b5c3fbe80d6 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Wed, 20 Jul 2022 19:59:27 +0900
Subject: [PATCH] =?UTF-8?q?enhance(client):=20=E3=83=8D=E3=82=B9=E3=83=88?=
 =?UTF-8?q?=E3=81=97=E3=81=9F=E3=83=AB=E3=83=BC=E3=83=86=E3=82=A3=E3=83=B3?=
 =?UTF-8?q?=E3=82=B0=E3=81=AB=E5=AF=BE=E5=BF=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../src/components/global/router-view.vue     |  38 +++-
 .../client/src/components/page-window.vue     |   2 +-
 packages/client/src/nirax.ts                  | 190 +++++++++++-------
 packages/client/src/pages/_empty_.vue         |   7 +
 packages/client/src/pages/admin/index.vue     |  99 +++------
 packages/client/src/pages/settings/index.vue  | 144 ++++---------
 ....statusbar.vue => statusbar.statusbar.vue} |   0
 .../{statusbars.vue => statusbar.vue}         |   2 +-
 packages/client/src/router.ts                 | 174 +++++++++++++++-
 9 files changed, 391 insertions(+), 265 deletions(-)
 create mode 100644 packages/client/src/pages/_empty_.vue
 rename packages/client/src/pages/settings/{statusbars.statusbar.vue => statusbar.statusbar.vue} (100%)
 rename packages/client/src/pages/settings/{statusbars.vue => statusbar.vue} (96%)

diff --git a/packages/client/src/components/global/router-view.vue b/packages/client/src/components/global/router-view.vue
index cd1e780196..1d841e050c 100644
--- a/packages/client/src/components/global/router-view.vue
+++ b/packages/client/src/components/global/router-view.vue
@@ -11,8 +11,8 @@
 </template>
 
 <script lang="ts" setup>
-import { inject, nextTick, onMounted, onUnmounted, watch } from 'vue';
-import { Router } from '@/nirax';
+import { inject, nextTick, onBeforeUnmount, onMounted, onUnmounted, provide, watch } from 'vue';
+import { Resolved, Router } from '@/nirax';
 import { defaultStore } from '@/store';
 
 const props = defineProps<{
@@ -25,19 +25,37 @@ if (router == null) {
 	throw new Error('no router provided');
 }
 
-let currentPageComponent = $shallowRef(router.getCurrentComponent());
-let currentPageProps = $ref(router.getCurrentProps());
-let key = $ref(router.getCurrentKey());
+const currentDepth = inject('routerCurrentDepth', 0);
+provide('routerCurrentDepth', currentDepth + 1);
 
-function onChange({ route, props: newProps, key: newKey }) {
-	currentPageComponent = route.component;
-	currentPageProps = newProps;
-	key = newKey;
+function resolveNested(current: Resolved, d = 0): Resolved | null {
+	if (d === currentDepth) {
+		return current;
+	} else {
+		if (current.child) {
+			return resolveNested(current.child, d + 1);
+		} else {
+			return null;
+		}
+	}
+}
+
+const current = resolveNested(router.current)!;
+let currentPageComponent = $shallowRef(current.route.component);
+let currentPageProps = $ref(current.props);
+let key = $ref(current.route.path + JSON.stringify(Object.fromEntries(current.props)));
+
+function onChange({ resolved, key: newKey }) {
+	const current = resolveNested(resolved);
+	if (current == null) return;
+	currentPageComponent = current.route.component;
+	currentPageProps = current.props;
+	key = current.route.path + JSON.stringify(Object.fromEntries(current.props));
 }
 
 router.addListener('change', onChange);
 
-onUnmounted(() => {
+onBeforeUnmount(() => {
 	router.removeListener('change', onChange);
 });
 </script>
diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue
index 98140b95c0..43d75b0cf9 100644
--- a/packages/client/src/components/page-window.vue
+++ b/packages/client/src/components/page-window.vue
@@ -114,7 +114,7 @@ function menu(ev) {
 
 function back() {
 	history.pop();
-	router.change(history[history.length - 1].path, history[history.length - 1].key);
+	router.replace(history[history.length - 1].path, history[history.length - 1].key);
 }
 
 function close() {
diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts
index 4ba1fe70f6..0ee39bf473 100644
--- a/packages/client/src/nirax.ts
+++ b/packages/client/src/nirax.ts
@@ -13,6 +13,7 @@ type RouteDef = {
 	name?: string;
 	hash?: string;
 	globalCacheKey?: string;
+	children?: RouteDef[];
 };
 
 type ParsedPath = (string | {
@@ -22,6 +23,8 @@ type ParsedPath = (string | {
 	optional?: boolean;
 })[];
 
+export type Resolved = { route: RouteDef; props: Map<string, string>; child?: Resolved; };
+
 function parsePath(path: string): ParsedPath {
 	const res = [] as ParsedPath;
 
@@ -51,8 +54,11 @@ export class Router extends EventEmitter<{
 	change: (ctx: {
 		beforePath: string;
 		path: string;
-		route: RouteDef | null;
-		props: Map<string, string> | null;
+		resolved: Resolved;
+		key: string;
+	}) => void;
+	replace: (ctx: {
+		path: string;
 		key: string;
 	}) => void;
 	push: (ctx: {
@@ -65,12 +71,12 @@ export class Router extends EventEmitter<{
 	same: () => void;
 }> {
 	private routes: RouteDef[];
+	public current: Resolved;
+	public currentRef: ShallowRef<Resolved> = shallowRef();
+	public currentRoute: ShallowRef<RouteDef> = shallowRef();
 	private currentPath: string;
-	private currentComponent: Component | null = null;
-	private currentProps: Map<string, string> | null = null;
 	private currentKey = Date.now().toString();
 
-	public currentRoute: ShallowRef<RouteDef | null> = shallowRef(null);
 	public navHook: ((path: string, flag?: any) => boolean) | null = null;
 
 	constructor(routes: Router['routes'], currentPath: Router['currentPath']) {
@@ -78,10 +84,10 @@ export class Router extends EventEmitter<{
 
 		this.routes = routes;
 		this.currentPath = currentPath;
-		this.navigate(currentPath, null, true);
+		this.navigate(currentPath, null, false);
 	}
 
-	public resolve(path: string): { route: RouteDef; props: Map<string, string>; } | null {
+	public resolve(path: string): Resolved | null {
 		let queryString: string | null = null;
 		let hash: string | null = null;
 		if (path[0] === '/') path = path.substring(1);
@@ -96,77 +102,108 @@ export class Router extends EventEmitter<{
 
 		if (_DEV_) console.log('Routing: ', path, queryString);
 
-		const _parts = path.split('/').filter(part => part.length !== 0);
+		function check(routes: RouteDef[], _parts: string[]): Resolved | null {
+			forEachRouteLoop:
+			for (const route of routes) {
+				let parts = [ ..._parts ];
+				const props = new Map<string, string>();
 
-		forEachRouteLoop:
-		for (const route of this.routes) {
-			let parts = [ ..._parts ];
-			const props = new Map<string, string>();
-
-			pathMatchLoop:
-			for (const p of parsePath(route.path)) {
-				if (typeof p === 'string') {
-					if (p === parts[0]) {
-						parts.shift();
-					} else {
-						continue forEachRouteLoop;
-					}
-				} else {
-					if (parts[0] == null && !p.optional) {
-						continue forEachRouteLoop;
-					}
-					if (p.wildcard) {
-						if (parts.length !== 0) {
-							props.set(p.name, safeURIDecode(parts.join('/')));
-							parts = [];
-						}
-						break pathMatchLoop;
-					} else {
-						if (p.startsWith) {
-							if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
-
-							props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
+				pathMatchLoop:
+				for (const p of parsePath(route.path)) {
+					if (typeof p === 'string') {
+						if (p === parts[0]) {
 							parts.shift();
 						} else {
-							if (parts[0]) {
-								props.set(p.name, safeURIDecode(parts[0]));
+							continue forEachRouteLoop;
+						}
+					} else {
+						if (parts[0] == null && !p.optional) {
+							continue forEachRouteLoop;
+						}
+						if (p.wildcard) {
+							if (parts.length !== 0) {
+								props.set(p.name, safeURIDecode(parts.join('/')));
+								parts = [];
+							}
+							break pathMatchLoop;
+						} else {
+							if (p.startsWith) {
+								if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop;
+
+								props.set(p.name, safeURIDecode(parts[0].substring(p.startsWith.length)));
+								parts.shift();
+							} else {
+								if (parts[0]) {
+									props.set(p.name, safeURIDecode(parts[0]));
+								}
+								parts.shift();
 							}
-							parts.shift();
 						}
 					}
 				}
-			}
 
-			if (parts.length !== 0) continue forEachRouteLoop;
+				if (parts.length === 0) {
+					if (route.children) {
+						const child = check(route.children, []);
+						if (child) {
+							return {
+								route,
+								props,
+								child,
+							};
+						} else {
+							continue forEachRouteLoop;
+						}
+					}
 
-			if (route.hash != null && hash != null) {
-				props.set(route.hash, safeURIDecode(hash));
-			}
-
-			if (route.query != null && queryString != null) {
-				const queryObject = [...new URLSearchParams(queryString).entries()]
-					.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
-
-				for (const q in route.query) {
-					const as = route.query[q];
-					if (queryObject[q]) {
-						props.set(as, safeURIDecode(queryObject[q]));
+					if (route.hash != null && hash != null) {
+						props.set(route.hash, safeURIDecode(hash));
+					}
+	
+					if (route.query != null && queryString != null) {
+						const queryObject = [...new URLSearchParams(queryString).entries()]
+							.reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {});
+	
+						for (const q in route.query) {
+							const as = route.query[q];
+							if (queryObject[q]) {
+								props.set(as, safeURIDecode(queryObject[q]));
+							}
+						}
+					}
+	
+					return {
+						route,
+						props,
+					};
+				} else {
+					if (route.children) {
+						const child = check(route.children, parts);
+						if (child) {
+							return {
+								route,
+								props,
+								child,
+							};
+						} else {
+							continue forEachRouteLoop;
+						}
+					} else {
+						continue forEachRouteLoop;
 					}
 				}
 			}
 
-			return {
-				route,
-				props,
-			};
+			return null;
 		}
 
-		return null;
+		const _parts = path.split('/').filter(part => part.length !== 0);
+
+		return check(this.routes, _parts);
 	}
 
-	private navigate(path: string, key: string | null | undefined, initial = false) {
+	private navigate(path: string, key: string | null | undefined, emitChange = true) {
 		const beforePath = this.currentPath;
-		const beforeRoute = this.currentRoute.value;
 		this.currentPath = path;
 
 		const res = this.resolve(this.currentPath);
@@ -181,28 +218,21 @@ export class Router extends EventEmitter<{
 
 		const isSamePath = beforePath === path;
 		if (isSamePath && key == null) key = this.currentKey;
-		this.currentComponent = res.route.component;
-		this.currentProps = res.props;
+		this.current = res;
+		this.currentRef.value = res;
 		this.currentRoute.value = res.route;
-		this.currentKey = this.currentRoute.value.globalCacheKey ?? key ?? Date.now().toString();
+		this.currentKey = res.route.globalCacheKey ?? key ?? path;
 
-		if (!initial) {
+		if (emitChange) {
 			this.emit('change', {
 				beforePath,
 				path,
-				route: this.currentRoute.value,
-				props: this.currentProps,
+				resolved: res,
 				key: this.currentKey,
 			});
 		}
-	}
 
-	public getCurrentComponent() {
-		return this.currentComponent;
-	}
-
-	public getCurrentProps() {
-		return this.currentProps;
+		return res;
 	}
 
 	public getCurrentPath() {
@@ -223,17 +253,23 @@ export class Router extends EventEmitter<{
 			const cancel = this.navHook(path, flag);
 			if (cancel) return;
 		}
-		this.navigate(path, null);
+		const res = this.navigate(path, null);
 		this.emit('push', {
 			beforePath,
 			path,
-			route: this.currentRoute.value,
-			props: this.currentProps,
+			route: res.route,
+			props: res.props,
 			key: this.currentKey,
 		});
 	}
 
-	public change(path: string, key?: string | null) {
+	public replace(path: string, key?: string | null, emitEvent = true) {
 		this.navigate(path, key);
+		if (emitEvent) {
+			this.emit('replace', {
+				path,
+				key: this.currentKey,
+			});
+		}
 	}
 }
diff --git a/packages/client/src/pages/_empty_.vue b/packages/client/src/pages/_empty_.vue
new file mode 100644
index 0000000000..000b6decc9
--- /dev/null
+++ b/packages/client/src/pages/_empty_.vue
@@ -0,0 +1,7 @@
+<template>
+<div></div>
+</template>
+
+<script lang="ts" setup>
+import { } from 'vue';
+</script>
diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue
index d82880c34a..2ff55d351b 100644
--- a/packages/client/src/pages/admin/index.vue
+++ b/packages/client/src/pages/admin/index.vue
@@ -1,6 +1,6 @@
 <template>
 <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }">
-	<div v-if="!narrow || initialPage == null" class="nav">	
+	<div v-if="!narrow || currentPage?.route.name == null" class="nav">	
 		<MkSpacer :content-max="700" :margin-min="16">
 			<div class="lxpfedzu">
 				<div class="banner">
@@ -12,12 +12,12 @@
 				<MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ $ts.configure }}</MkA></MkInfo>
 				<MkInfo v-if="noEmailServer" warn class="info">{{ $ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ $ts.configure }}</MkA></MkInfo>
 
-				<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+				<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
 			</div>
 		</MkSpacer>
 	</div>
-	<div v-if="!(narrow && initialPage == null)" class="main">
-		<component :is="component" :key="initialPage" v-bind="pageProps"/>
+	<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
+		<RouterView/>
 	</div>
 </div>
 </template>
@@ -44,15 +44,10 @@ const indexInfo = {
 	hideHeader: true,
 };
 
-const props = defineProps<{
-	initialPage?: string,
-}>();
-
 provide('shouldOmitHeaderTitle', false);
 
 let INFO = $ref(indexInfo);
 let childInfo = $ref(null);
-let page = $ref(props.initialPage);
 let narrow = $ref(false);
 let view = $ref(null);
 let el = $ref(null);
@@ -61,6 +56,7 @@ let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instan
 let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha;
 let noEmailServer = !instance.enableEmail;
 let thereIsUnresolvedAbuseReport = $ref(false);
+let currentPage = $computed(() => router.currentRef.value.child);
 
 os.api('admin/abuse-user-reports', {
 	state: 'unresolved',
@@ -94,47 +90,47 @@ const menuDef = $computed(() => [{
 		icon: 'fas fa-tachometer-alt',
 		text: i18n.ts.dashboard,
 		to: '/admin/overview',
-		active: props.initialPage === 'overview',
+		active: currentPage?.route.name === 'overview',
 	}, {
 		icon: 'fas fa-users',
 		text: i18n.ts.users,
 		to: '/admin/users',
-		active: props.initialPage === 'users',
+		active: currentPage?.route.name === 'users',
 	}, {
 		icon: 'fas fa-laugh',
 		text: i18n.ts.customEmojis,
 		to: '/admin/emojis',
-		active: props.initialPage === 'emojis',
+		active: currentPage?.route.name === 'emojis',
 	}, {
 		icon: 'fas fa-globe',
 		text: i18n.ts.federation,
 		to: '/about#federation',
-		active: props.initialPage === 'federation',
+		active: currentPage?.route.name === 'federation',
 	}, {
 		icon: 'fas fa-clipboard-list',
 		text: i18n.ts.jobQueue,
 		to: '/admin/queue',
-		active: props.initialPage === 'queue',
+		active: currentPage?.route.name === 'queue',
 	}, {
 		icon: 'fas fa-cloud',
 		text: i18n.ts.files,
 		to: '/admin/files',
-		active: props.initialPage === 'files',
+		active: currentPage?.route.name === 'files',
 	}, {
 		icon: 'fas fa-broadcast-tower',
 		text: i18n.ts.announcements,
 		to: '/admin/announcements',
-		active: props.initialPage === 'announcements',
+		active: currentPage?.route.name === 'announcements',
 	}, {
 		icon: 'fas fa-audio-description',
 		text: i18n.ts.ads,
 		to: '/admin/ads',
-		active: props.initialPage === 'ads',
+		active: currentPage?.route.name === 'ads',
 	}, {
 		icon: 'fas fa-exclamation-circle',
 		text: i18n.ts.abuseReports,
 		to: '/admin/abuses',
-		active: props.initialPage === 'abuses',
+		active: currentPage?.route.name === 'abuses',
 	}],
 }, {
 	title: i18n.ts.settings,
@@ -142,47 +138,47 @@ const menuDef = $computed(() => [{
 		icon: 'fas fa-cog',
 		text: i18n.ts.general,
 		to: '/admin/settings',
-		active: props.initialPage === 'settings',
+		active: currentPage?.route.name === 'settings',
 	}, {
 		icon: 'fas fa-envelope',
 		text: i18n.ts.emailServer,
 		to: '/admin/email-settings',
-		active: props.initialPage === 'email-settings',
+		active: currentPage?.route.name === 'email-settings',
 	}, {
 		icon: 'fas fa-cloud',
 		text: i18n.ts.objectStorage,
 		to: '/admin/object-storage',
-		active: props.initialPage === 'object-storage',
+		active: currentPage?.route.name === 'object-storage',
 	}, {
 		icon: 'fas fa-lock',
 		text: i18n.ts.security,
 		to: '/admin/security',
-		active: props.initialPage === 'security',
+		active: currentPage?.route.name === 'security',
 	}, {
 		icon: 'fas fa-globe',
 		text: i18n.ts.relays,
 		to: '/admin/relays',
-		active: props.initialPage === 'relays',
+		active: currentPage?.route.name === 'relays',
 	}, {
 		icon: 'fas fa-share-alt',
 		text: i18n.ts.integration,
 		to: '/admin/integrations',
-		active: props.initialPage === 'integrations',
+		active: currentPage?.route.name === 'integrations',
 	}, {
 		icon: 'fas fa-ban',
 		text: i18n.ts.instanceBlocking,
 		to: '/admin/instance-block',
-		active: props.initialPage === 'instance-block',
+		active: currentPage?.route.name === 'instance-block',
 	}, {
 		icon: 'fas fa-ghost',
 		text: i18n.ts.proxyAccount,
 		to: '/admin/proxy-account',
-		active: props.initialPage === 'proxy-account',
+		active: currentPage?.route.name === 'proxy-account',
 	}, {
 		icon: 'fas fa-cogs',
 		text: i18n.ts.other,
 		to: '/admin/other-settings',
-		active: props.initialPage === 'other-settings',
+		active: currentPage?.route.name === 'other-settings',
 	}],
 }, {
 	title: i18n.ts.info,
@@ -190,55 +186,12 @@ const menuDef = $computed(() => [{
 		icon: 'fas fa-database',
 		text: i18n.ts.database,
 		to: '/admin/database',
-		active: props.initialPage === 'database',
+		active: currentPage?.route.name === 'database',
 	}],
 }]);
 
-const component = $computed(() => {
-	if (props.initialPage == null) return null;
-	switch (props.initialPage) {
-		case 'overview': return defineAsyncComponent(() => import('./overview.vue'));
-		case 'users': return defineAsyncComponent(() => import('./users.vue'));
-		case 'emojis': return defineAsyncComponent(() => import('./emojis.vue'));
-		//case 'federation': return defineAsyncComponent(() => import('../federation.vue'));
-		case 'queue': return defineAsyncComponent(() => import('./queue.vue'));
-		case 'files': return defineAsyncComponent(() => import('./files.vue'));
-		case 'announcements': return defineAsyncComponent(() => import('./announcements.vue'));
-		case 'ads': return defineAsyncComponent(() => import('./ads.vue'));
-		case 'database': return defineAsyncComponent(() => import('./database.vue'));
-		case 'abuses': return defineAsyncComponent(() => import('./abuses.vue'));
-		case 'settings': return defineAsyncComponent(() => import('./settings.vue'));
-		case 'email-settings': return defineAsyncComponent(() => import('./email-settings.vue'));
-		case 'object-storage': return defineAsyncComponent(() => import('./object-storage.vue'));
-		case 'security': return defineAsyncComponent(() => import('./security.vue'));
-		case 'relays': return defineAsyncComponent(() => import('./relays.vue'));
-		case 'integrations': return defineAsyncComponent(() => import('./integrations.vue'));
-		case 'instance-block': return defineAsyncComponent(() => import('./instance-block.vue'));
-		case 'proxy-account': return defineAsyncComponent(() => import('./proxy-account.vue'));
-		case 'other-settings': return defineAsyncComponent(() => import('./other-settings.vue'));
-	}
-});
-
-watch(component, () => {
-	pageProps = {};
-
-	nextTick(() => {
-		scroll(el, { top: 0 });
-	});
-}, { immediate: true });
-
-watch(() => props.initialPage, () => {
-	if (props.initialPage == null && !narrow) {
-		router.push('/admin/overview');
-	} else {
-		if (props.initialPage == null) {
-			INFO = indexInfo;
-		}
-	}
-});
-
 watch(narrow, () => {
-	if (props.initialPage == null && !narrow) {
+	if (currentPage?.route.name == null && !narrow) {
 		router.push('/admin/overview');
 	}
 });
@@ -247,7 +200,7 @@ onMounted(() => {
 	ro.observe(el);
 
 	narrow = el.offsetWidth < NARROW_THRESHOLD;
-	if (props.initialPage == null && !narrow) {
+	if (currentPage?.route.name == null && !narrow) {
 		router.push('/admin/overview');
 	}
 });
diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue
index 8b1cc6c124..8964333b31 100644
--- a/packages/client/src/pages/settings/index.vue
+++ b/packages/client/src/pages/settings/index.vue
@@ -4,15 +4,15 @@
 	<MkSpacer :content-max="900" :margin-min="20" :margin-max="32">
 		<div ref="el" class="vvcocwet" :class="{ wide: !narrow }">
 			<div class="body">
-				<div v-if="!narrow || initialPage == null" class="nav">
+				<div v-if="!narrow || currentPage?.route.name == null" class="nav">
 					<div class="baaadecd">
 						<MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo>
-						<MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu>
+						<MkSuperMenu :def="menuDef" :grid="currentPage?.route.name == null"></MkSuperMenu>
 					</div>
 				</div>
-				<div v-if="!(narrow && initialPage == null)" class="main">
+				<div v-if="!(narrow && currentPage?.route.name == null)" class="main">
 					<div class="bkzroven">
-						<component :is="component" :key="initialPage" v-bind="pageProps"/>
+						<RouterView/>
 					</div>
 				</div>
 			</div>
@@ -22,7 +22,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue';
+import { computed, defineAsyncComponent, inject, nextTick, onActivated, onMounted, onUnmounted, provide, ref, watch } from 'vue';
 import { i18n } from '@/i18n';
 import MkInfo from '@/components/ui/info.vue';
 import MkSuperMenu from '@/components/ui/super-menu.vue';
@@ -34,11 +34,6 @@ import { useRouter } from '@/router';
 import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata';
 import * as os from '@/os';
 
-const props = withDefaults(defineProps<{
-  initialPage?: string;
-}>(), {
-});
-
 const indexInfo = {
 	title: i18n.ts.settings,
 	icon: 'fas fa-cog',
@@ -50,12 +45,14 @@ const childInfo = ref(null);
 
 const router = useRouter();
 
-const narrow = ref(false);
+let narrow = $ref(false);
 const NARROW_THRESHOLD = 600;
 
+let currentPage = $computed(() => router.currentRef.value.child);
+
 const ro = new ResizeObserver((entries, observer) => {
 	if (entries.length === 0) return;
-	narrow.value = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
+	narrow = entries[0].borderBoxSize[0].inlineSize < NARROW_THRESHOLD;
 });
 
 const menuDef = computed(() => [{
@@ -64,42 +61,42 @@ const menuDef = computed(() => [{
 		icon: 'fas fa-user',
 		text: i18n.ts.profile,
 		to: '/settings/profile',
-		active: props.initialPage === 'profile',
+		active: currentPage?.route.name === 'profile',
 	}, {
 		icon: 'fas fa-lock-open',
 		text: i18n.ts.privacy,
 		to: '/settings/privacy',
-		active: props.initialPage === 'privacy',
+		active: currentPage?.route.name === 'privacy',
 	}, {
 		icon: 'fas fa-laugh',
 		text: i18n.ts.reaction,
 		to: '/settings/reaction',
-		active: props.initialPage === 'reaction',
+		active: currentPage?.route.name === 'reaction',
 	}, {
 		icon: 'fas fa-cloud',
 		text: i18n.ts.drive,
 		to: '/settings/drive',
-		active: props.initialPage === 'drive',
+		active: currentPage?.route.name === 'drive',
 	}, {
 		icon: 'fas fa-bell',
 		text: i18n.ts.notifications,
 		to: '/settings/notifications',
-		active: props.initialPage === 'notifications',
+		active: currentPage?.route.name === 'notifications',
 	}, {
 		icon: 'fas fa-envelope',
 		text: i18n.ts.email,
 		to: '/settings/email',
-		active: props.initialPage === 'email',
+		active: currentPage?.route.name === 'email',
 	}, {
 		icon: 'fas fa-share-alt',
 		text: i18n.ts.integration,
 		to: '/settings/integration',
-		active: props.initialPage === 'integration',
+		active: currentPage?.route.name === 'integration',
 	}, {
 		icon: 'fas fa-lock',
 		text: i18n.ts.security,
 		to: '/settings/security',
-		active: props.initialPage === 'security',
+		active: currentPage?.route.name === 'security',
 	}],
 }, {
 	title: i18n.ts.clientSettings,
@@ -107,32 +104,32 @@ const menuDef = computed(() => [{
 		icon: 'fas fa-cogs',
 		text: i18n.ts.general,
 		to: '/settings/general',
-		active: props.initialPage === 'general',
+		active: currentPage?.route.name === 'general',
 	}, {
 		icon: 'fas fa-palette',
 		text: i18n.ts.theme,
 		to: '/settings/theme',
-		active: props.initialPage === 'theme',
+		active: currentPage?.route.name === 'theme',
 	}, {
 		icon: 'fas fa-bars',
 		text: i18n.ts.navbar,
 		to: '/settings/navbar',
-		active: props.initialPage === 'navbar',
+		active: currentPage?.route.name === 'navbar',
 	}, {
 		icon: 'fas fa-bars-progress',
 		text: i18n.ts.statusbar,
-		to: '/settings/statusbars',
-		active: props.initialPage === 'statusbars',
+		to: '/settings/statusbar',
+		active: currentPage?.route.name === 'statusbar',
 	}, {
 		icon: 'fas fa-music',
 		text: i18n.ts.sounds,
 		to: '/settings/sounds',
-		active: props.initialPage === 'sounds',
+		active: currentPage?.route.name === 'sounds',
 	}, {
 		icon: 'fas fa-plug',
 		text: i18n.ts.plugins,
 		to: '/settings/plugin',
-		active: props.initialPage === 'plugin',
+		active: currentPage?.route.name === 'plugin',
 	}],
 }, {
 	title: i18n.ts.otherSettings,
@@ -140,37 +137,37 @@ const menuDef = computed(() => [{
 		icon: 'fas fa-boxes',
 		text: i18n.ts.importAndExport,
 		to: '/settings/import-export',
-		active: props.initialPage === 'import-export',
+		active: currentPage?.route.name === 'import-export',
 	}, {
 		icon: 'fas fa-volume-mute',
 		text: i18n.ts.instanceMute,
 		to: '/settings/instance-mute',
-		active: props.initialPage === 'instance-mute',
+		active: currentPage?.route.name === 'instance-mute',
 	}, {
 		icon: 'fas fa-ban',
 		text: i18n.ts.muteAndBlock,
 		to: '/settings/mute-block',
-		active: props.initialPage === 'mute-block',
+		active: currentPage?.route.name === 'mute-block',
 	}, {
 		icon: 'fas fa-comment-slash',
 		text: i18n.ts.wordMute,
 		to: '/settings/word-mute',
-		active: props.initialPage === 'word-mute',
+		active: currentPage?.route.name === 'word-mute',
 	}, {
 		icon: 'fas fa-key',
 		text: 'API',
 		to: '/settings/api',
-		active: props.initialPage === 'api',
+		active: currentPage?.route.name === 'api',
 	}, {
 		icon: 'fas fa-bolt',
 		text: 'Webhook',
 		to: '/settings/webhook',
-		active: props.initialPage === 'webhook',
+		active: currentPage?.route.name === 'webhook',
 	}, {
 		icon: 'fas fa-ellipsis-h',
 		text: i18n.ts.other,
 		to: '/settings/other',
-		active: props.initialPage === 'other',
+		active: currentPage?.route.name === 'other',
 	}],
 }, {
 	items: [{
@@ -198,77 +195,24 @@ const menuDef = computed(() => [{
 	}],
 }]);
 
-const pageProps = ref({});
-const component = computed(() => {
-	if (props.initialPage == null) return null;
-	switch (props.initialPage) {
-		case 'accounts': return defineAsyncComponent(() => import('./accounts.vue'));
-		case 'profile': return defineAsyncComponent(() => import('./profile.vue'));
-		case 'privacy': return defineAsyncComponent(() => import('./privacy.vue'));
-		case 'reaction': return defineAsyncComponent(() => import('./reaction.vue'));
-		case 'drive': return defineAsyncComponent(() => import('./drive.vue'));
-		case 'notifications': return defineAsyncComponent(() => import('./notifications.vue'));
-		case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue'));
-		case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
-		case 'instance-mute': return defineAsyncComponent(() => import('./instance-mute.vue'));
-		case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
-		case 'security': return defineAsyncComponent(() => import('./security.vue'));
-		case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));
-		case 'api': return defineAsyncComponent(() => import('./api.vue'));
-		case 'webhook': return defineAsyncComponent(() => import('./webhook.vue'));
-		case 'webhook/new': return defineAsyncComponent(() => import('./webhook.new.vue'));
-		case 'webhook/edit': return defineAsyncComponent(() => import('./webhook.edit.vue'));
-		case 'apps': return defineAsyncComponent(() => import('./apps.vue'));
-		case 'other': return defineAsyncComponent(() => import('./other.vue'));
-		case 'general': return defineAsyncComponent(() => import('./general.vue'));
-		case 'email': return defineAsyncComponent(() => import('./email.vue'));
-		case 'theme': return defineAsyncComponent(() => import('./theme.vue'));
-		case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue'));
-		case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue'));
-		case 'navbar': return defineAsyncComponent(() => import('./navbar.vue'));
-		case 'statusbars': return defineAsyncComponent(() => import('./statusbars.vue'));
-		case 'sounds': return defineAsyncComponent(() => import('./sounds.vue'));
-		case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue'));
-		case 'deck': return defineAsyncComponent(() => import('./deck.vue'));
-		case 'plugin': return defineAsyncComponent(() => import('./plugin.vue'));
-		case 'plugin/install': return defineAsyncComponent(() => import('./plugin.install.vue'));
-		case 'import-export': return defineAsyncComponent(() => import('./import-export.vue'));
-		case 'account-info': return defineAsyncComponent(() => import('./account-info.vue'));
-		case 'delete-account': return defineAsyncComponent(() => import('./delete-account.vue'));
-	}
-	return null;
-});
-
-watch(component, () => {
-	pageProps.value = {};
-
-	nextTick(() => {
-		scroll(el.value, { top: 0 });
-	});
-}, { immediate: true });
-
-watch(() => props.initialPage, () => {
-	if (props.initialPage == null && !narrow.value) {
-		router.push('/settings/profile');
-	} else {
-		if (props.initialPage == null) {
-			INFO.value = indexInfo;
-		}
-	}
-});
-
-watch(narrow, () => {
-	if (props.initialPage == null && !narrow.value) {
-		router.push('/settings/profile');
-	}
+watch($$(narrow), () => {
 });
 
 onMounted(() => {
 	ro.observe(el.value);
 
-	narrow.value = el.value.offsetWidth < NARROW_THRESHOLD;
-	if (props.initialPage == null && !narrow.value) {
-		router.push('/settings/profile');
+	narrow = el.value.offsetWidth < NARROW_THRESHOLD;
+
+	if (!narrow && currentPage?.route.name == null) {
+		router.replace('/settings/profile');
+	}
+});
+
+onActivated(() => {
+	narrow = el.value.offsetWidth < NARROW_THRESHOLD;
+
+	if (!narrow && currentPage?.route.name == null) {
+		router.replace('/settings/profile');
 	}
 });
 
diff --git a/packages/client/src/pages/settings/statusbars.statusbar.vue b/packages/client/src/pages/settings/statusbar.statusbar.vue
similarity index 100%
rename from packages/client/src/pages/settings/statusbars.statusbar.vue
rename to packages/client/src/pages/settings/statusbar.statusbar.vue
diff --git a/packages/client/src/pages/settings/statusbars.vue b/packages/client/src/pages/settings/statusbar.vue
similarity index 96%
rename from packages/client/src/pages/settings/statusbars.vue
rename to packages/client/src/pages/settings/statusbar.vue
index c81bd7fbdf..3f23ed470c 100644
--- a/packages/client/src/pages/settings/statusbars.vue
+++ b/packages/client/src/pages/settings/statusbar.vue
@@ -12,7 +12,7 @@
 <script lang="ts" setup>
 import { computed, onMounted, ref, watch } from 'vue';
 import { v4 as uuid } from 'uuid';
-import XStatusbar from './statusbars.statusbar.vue';
+import XStatusbar from './statusbar.statusbar.vue';
 import FormRadios from '@/components/form/radios.vue';
 import FormFolder from '@/components/form/folder.vue';
 import FormButton from '@/components/ui/button.vue';
diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts
index b61b77eeeb..f3ca521832 100644
--- a/packages/client/src/router.ts
+++ b/packages/client/src/router.ts
@@ -42,9 +42,97 @@ export const routes = [{
 	component: page(() => import('./pages/instance-info.vue')),
 }, {
 	name: 'settings',
-	path: '/settings/:initialPage(*)?',
+	path: '/settings',
 	component: page(() => import('./pages/settings/index.vue')),
 	loginRequired: true,
+	children: [{
+		path: '/profile',
+		name: 'profile',
+		component: page(() => import('./pages/settings/profile.vue')),
+	}, {
+		path: '/privacy',
+		name: 'privacy',
+		component: page(() => import('./pages/settings/privacy.vue')),
+	}, {
+		path: '/reaction',
+		name: 'reaction',
+		component: page(() => import('./pages/settings/reaction.vue')),
+	}, {
+		path: '/drive',
+		name: 'drive',
+		component: page(() => import('./pages/settings/drive.vue')),
+	}, {
+		path: '/notifications',
+		name: 'notifications',
+		component: page(() => import('./pages/settings/notifications.vue')),
+	}, {
+		path: '/email',
+		name: 'email',
+		component: page(() => import('./pages/settings/email.vue')),
+	}, {
+		path: '/integration',
+		name: 'integration',
+		component: page(() => import('./pages/settings/integration.vue')),
+	}, {
+		path: '/security',
+		name: 'security',
+		component: page(() => import('./pages/settings/security.vue')),
+	}, {
+		path: '/general',
+		name: 'general',
+		component: page(() => import('./pages/settings/general.vue')),
+	}, {
+		path: '/theme',
+		name: 'theme',
+		component: page(() => import('./pages/settings/theme.vue')),
+	}, {
+		path: '/navbar',
+		name: 'navbar',
+		component: page(() => import('./pages/settings/navbar.vue')),
+	}, {
+		path: '/statusbar',
+		name: 'statusbar',
+		component: page(() => import('./pages/settings/statusbar.vue')),
+	}, {
+		path: '/sounds',
+		name: 'sounds',
+		component: page(() => import('./pages/settings/sounds.vue')),
+	}, {
+		path: '/plugin',
+		name: 'plugin',
+		component: page(() => import('./pages/settings/plugin.vue')),
+	}, {
+		path: '/import-export',
+		name: 'import-export',
+		component: page(() => import('./pages/settings/import-export.vue')),
+	}, {
+		path: '/instance-mute',
+		name: 'instance-mute',
+		component: page(() => import('./pages/settings/instance-mute.vue')),
+	}, {
+		path: '/mute-block',
+		name: 'mute-block',
+		component: page(() => import('./pages/settings/mute-block.vue')),
+	}, {
+		path: '/word-mute',
+		name: 'word-mute',
+		component: page(() => import('./pages/settings/word-mute.vue')),
+	}, {
+		path: '/api',
+		name: 'api',
+		component: page(() => import('./pages/settings/api.vue')),
+	}, {
+		path: '/webhook',
+		name: 'webhook',
+		component: page(() => import('./pages/settings/webhook.vue')),
+	}, {
+		path: '/other',
+		name: 'other',
+		component: page(() => import('./pages/settings/other.vue')),
+	}, {
+		path: '/',
+		component: page(() => import('./pages/_empty_.vue')),
+	}],
 }, {
 	path: '/reset-password/:token?',
 	component: page(() => import('./pages/reset-password.vue')),
@@ -166,8 +254,84 @@ export const routes = [{
 	path: '/admin/file/:fileId',
 	component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')),
 }, {
-	path: '/admin/:initialPage(*)?',
+	path: '/admin',
 	component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')),
+	children: [{
+		path: '/overview',
+		name: 'overview',
+		component: page(() => import('./pages/admin/overview.vue')),
+	}, {
+		path: '/users',
+		name: 'users',
+		component: page(() => import('./pages/admin/users.vue')),
+	}, {
+		path: '/emojis',
+		name: 'emojis',
+		component: page(() => import('./pages/admin/emojis.vue')),
+	}, {
+		path: '/queue',
+		name: 'queue',
+		component: page(() => import('./pages/admin/queue.vue')),
+	}, {
+		path: '/files',
+		name: 'files',
+		component: page(() => import('./pages/admin/files.vue')),
+	}, {
+		path: '/announcements',
+		name: 'announcements',
+		component: page(() => import('./pages/admin/announcements.vue')),
+	}, {
+		path: '/ads',
+		name: 'ads',
+		component: page(() => import('./pages/admin/ads.vue')),
+	}, {
+		path: '/database',
+		name: 'database',
+		component: page(() => import('./pages/admin/database.vue')),
+	}, {
+		path: '/abuses',
+		name: 'abuses',
+		component: page(() => import('./pages/admin/abuses.vue')),
+	}, {
+		path: '/settings',
+		name: 'settings',
+		component: page(() => import('./pages/admin/settings.vue')),
+	}, {
+		path: '/email-settings',
+		name: 'email-settings',
+		component: page(() => import('./pages/admin/email-settings.vue')),
+	}, {
+		path: '/object-storage',
+		name: 'object-storage',
+		component: page(() => import('./pages/admin/object-storage.vue')),
+	}, {
+		path: '/security',
+		name: 'security',
+		component: page(() => import('./pages/admin/security.vue')),
+	}, {
+		path: '/relays',
+		name: 'relays',
+		component: page(() => import('./pages/admin/relays.vue')),
+	}, {
+		path: '/integrations',
+		name: 'integrations',
+		component: page(() => import('./pages/admin/integrations.vue')),
+	}, {
+		path: '/instance-block',
+		name: 'instance-block',
+		component: page(() => import('./pages/admin/instance-block.vue')),
+	}, {
+		path: '/proxy-account',
+		name: 'proxy-account',
+		component: page(() => import('./pages/admin/proxy-account.vue')),
+	}, {
+		path: '/other-settings',
+		name: 'other-settings',
+		component: page(() => import('./pages/admin/other-settings.vue')),
+	}, {
+		path: '/',
+		component: page(() => import('./pages/_empty_.vue')),
+	}],
 }, {
 	path: '/my/notifications',
 	component: page(() => import('./pages/notifications.vue')),
@@ -267,12 +431,16 @@ mainRouter.addListener('push', ctx => {
 	}
 });
 
+mainRouter.addListener('replace', ctx => {
+	window.history.replaceState({ key: ctx.key }, '', ctx.path);
+});
+
 mainRouter.addListener('same', () => {
 	window.scroll({ top: 0, behavior: 'smooth' });
 });
 
 window.addEventListener('popstate', (event) => {
-	mainRouter.change(location.pathname + location.search + location.hash, event.state?.key);
+	mainRouter.replace(location.pathname + location.search + location.hash, event.state?.key, false);
 	const scrollPos = scrollPosStore.get(event.state?.key) ?? 0;
 	window.scroll({ top: scrollPos, behavior: 'instant' });
 	window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール