Eve/src/routes/router.ts

314 lines
7.7 KiB
TypeScript

import '@components/InitialSetup';
import '@routes/404Page';
import '@routes/Arbor/Home';
import '@routes/Arbor/NewPost';
import '@routes/Arbor/NewTopic';
import '@routes/Arbor/TopicView';
import '@routes/Home';
import '@routes/Profile';
import '@routes/Settings';
import { spread } from '@open-wc/lit-helpers';
import { LitElement, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { keyed } from 'lit/directives/keyed.js';
import { type StaticValue, html, literal } from 'lit/static-html.js';
export interface RouteParams {
[key: string]: string;
}
interface Route {
pattern: string;
params: RouteParams;
component: StaticValue;
// component: typeof LitElement | ((params: RouteParams) => typeof LitElement);
title?: string;
meta?: Record<string, string>;
}
@customElement('arx-eve-router')
export default class EveRouter extends LitElement {
private static routes: Route[] = [
{
pattern: 'home',
params: {},
component: literal`arx-eve-home`,
},
{
pattern: 'profile/:npub',
params: {},
component: literal`arx-profile-route`,
},
{
pattern: 'arbor',
params: {},
component: literal`arx-arbor-home`,
},
{
pattern: 'arbor/new-topic/:categoryId',
params: {},
component: literal`arx-arbor-topic-creator`,
},
{
pattern: 'arbor/topics/:topicId',
params: {},
component: literal`arx-arbor-topic-view`,
},
{
pattern: 'arbor/new-post/:topicId',
params: {},
component: literal`arx-arbor-post-creator`,
},
{
pattern: 'settings',
params: {},
component: literal`arx-settings`,
},
{
pattern: '404',
params: {},
component: literal`arx-404-page`,
},
];
@state()
private history: string[] = [];
@state()
private currentIndex = -1;
@property()
public ccnSetup = false;
private beforeEachGuards: ((to: Route, from: Route | null) => boolean)[] = [];
private afterEachHooks: ((to: Route, from: Route | null) => void)[] = [];
static override styles = css`
:host {
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: var(--color-base-200);
border-radius: var(--radius-field);
}
::-webkit-scrollbar-thumb {
background: var(--color-base-300);
border-radius: var(--radius-field);
border: 2px solid var(--color-base-200);
transition: var(--transition);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral);
}
.window {
overflow: auto;
}
.window-content {
max-width: 1200px;
overflow: visible;
height: 100%;
margin: 0 auto;
padding: 1rem;
}
.hide-overflow {
overflow: hidden;
}
`;
constructor() {
super();
this.initializeRouter();
if (this.ccnSetup) window.relay.start(localStorage.getItem('encryption_key')!);
}
override connectedCallback(): void {
super.connectedCallback();
this.setupEventListeners();
}
override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener('hashchange', this.handleHashChange);
window.removeEventListener('popstate', this.handlePopState);
}
private initializeRouter(): void {
const initialPath = this.currentPath;
this.history = [initialPath];
this.currentIndex = 0;
this.navigate(initialPath);
}
private setupEventListeners(): void {
window.addEventListener('hashchange', this.handleHashChange.bind(this));
window.addEventListener('popstate', this.handlePopState.bind(this));
}
private handleHashChange(): void {
const newPath = this.currentPath;
if (newPath !== this.history[this.currentIndex]) {
this.updateHistory(newPath);
this.requestUpdate();
}
}
private handlePopState(): void {
this.requestUpdate();
}
private updateHistory(newPath: string): void {
this.history = this.history.slice(0, this.currentIndex + 1);
this.history.push(newPath);
this.currentIndex = this.history.length - 1;
}
private matchRoute(pattern: string, path: string): { isMatch: boolean; params: RouteParams } {
const patternParts = pattern.split('/').filter(Boolean);
const pathParts = path.split('/').filter(Boolean);
const params: RouteParams = {};
if (patternParts.length !== pathParts.length) {
return { isMatch: false, params };
}
const isMatch = patternParts.every((patternPart, index) => {
const pathPart = pathParts[index];
if (patternPart.startsWith(':')) {
const paramName = patternPart.slice(1);
params[paramName] = decodeURIComponent(pathPart);
return true;
}
return patternPart === pathPart;
});
return { isMatch, params };
}
get currentPath(): string {
const hash = window.location.hash?.slice(1);
return hash === '' ? 'home' : hash;
}
get currentRoute(): Route {
const route = EveRouter.routes.find((route) => {
const { isMatch, params } = this.matchRoute(route.pattern, this.currentPath);
if (isMatch) {
route.params = params;
return true;
}
return false;
});
return route || this.getNotFoundRoute();
}
private getNotFoundRoute(): Route {
return {
pattern: '404',
params: {},
component: literal`arx-404-page`,
};
}
beforeEach(guard: (to: Route, from: Route | null) => boolean): void {
this.beforeEachGuards.push(guard);
}
afterEach(hook: (to: Route, from: Route | null) => void): void {
this.afterEachHooks.push(hook);
}
async navigate(path: string): Promise<void> {
const from = this.currentRoute;
window.location.hash = path;
const to = this.currentRoute;
const canProceed = this.beforeEachGuards.every((guard) => guard(to, from));
if (canProceed) {
this.requestUpdate();
for (const hook of this.afterEachHooks) {
hook(to, from);
}
if (to.title) {
document.title = to.title;
}
}
}
goBack(): void {
if (this.currentIndex > 0) {
this.currentIndex--;
this.navigate(this.history[this.currentIndex]);
}
}
goForward(): void {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
this.navigate(this.history[this.currentIndex]);
}
}
finishSetup() {
this.ccnSetup = true;
}
renderSetup() {
return html`
<div class="window">
<div class="window-content">
<arx-initial-setup
@finish=${() => this.finishSetup()}
></arx-initial-setup>
</div>
</div>
`;
}
override render() {
if (!this.ccnSetup) return this.renderSetup();
return html`
<arx-header
?canGoBack=${this.currentIndex > 0}
?canGoForward=${this.currentIndex < this.history.length - 1}
url="eve://${this.currentPath}"
@navigate=${(e: CustomEvent) => this.navigate(e.detail)}
@go-back=${this.goBack}
@go-forward=${this.goForward}
title="Eve"
></arx-header>
<div class="window ${this.currentRoute.pattern === 'home' ? 'hide-overflow' : ''}">
<div class="window-content">
${keyed(
this.currentRoute.params,
html`
<${this.currentRoute.component}
${spread(this.currentRoute.params)}
path=${this.currentPath}
@navigate=${this.navigate}
@go-back=${this.goBack}
@go-forward=${this.goForward}
></${this.currentRoute.component}>
`,
)}
</div>
</div>
`;
}
}