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 @@ 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; 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 | 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 = shallowRef(); + public currentRoute: ShallowRef = shallowRef(); private currentPath: string; - private currentComponent: Component | null = null; - private currentProps: Map | null = null; private currentKey = Date.now().toString(); - public currentRoute: ShallowRef = 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; } | 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(); - forEachRouteLoop: - for (const route of this.routes) { - let parts = [ ..._parts ]; - const props = new Map(); - - 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 @@ + + + + + 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 @@ - + @@ -12,12 +12,12 @@ {{ $ts.noBotProtectionWarning }} {{ $ts.configure }} {{ $ts.noEmailServerWarning }} {{ $ts.configure }} - + - - + + @@ -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 @@ - + {{ $ts.emailNotConfiguredWarning }} {{ $ts.configure }} - + - + - + @@ -22,7 +22,7 @@