Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,9 @@ testem.log
# System files
.DS_Store
Thumbs.db
.env
.env

# Generated build artifacts
/projects/budgetkey/src/assets/subject-dashboards/index.json

.playwright-mcp
961 changes: 896 additions & 65 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build-dev": "ng build --configuration staging",
"watch": "ng build --watch --configuration development",
"generate:subject-dashboards-index": "node projects/budgetkey/scripts/generate-subject-dashboards-index.js",
"build": "npm run generate:subject-dashboards-index && ng build",
"build-dev": "npm run generate:subject-dashboards-index && ng build --configuration staging",
"watch": "npm run generate:subject-dashboards-index && ng build --watch --configuration development",
"test": "ng test",
"serve:ssr:budgetkey": "node dist/budgetkey/server/server.mjs"
},
Expand All @@ -33,6 +34,7 @@
"d3-transition": "^3.0.1",
"dayjs": "^1.11.9",
"express": "^4.18.2",
"mermaid": "^11.16.0",
"mushonkey": "^0.3.5",
"rxjs": "~7.8.0",
"showdown": "^2.1.0",
Expand Down
74 changes: 74 additions & 0 deletions projects/budgetkey/scripts/generate-subject-dashboards-index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const fs = require('fs');
const path = require('path');

const ASSETS_DIR = path.join(__dirname, '..', 'src', 'assets', 'subject-dashboards');
const INDEX_PATH = path.join(ASSETS_DIR, 'index.json');
const REQUIRED_FIELDS = ['title', 'created', 'updated', 'model', 'path'];

function walkMarkdownFiles(dir) {
let results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results = results.concat(walkMarkdownFiles(fullPath));
} else if (entry.isFile() && entry.name.endsWith('.md')) {
results.push(fullPath);
}
}
return results;
}

function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) {
return null;
}

const frontmatter = {};
for (const line of match[1].split(/\r?\n/)) {
const fieldMatch = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (!fieldMatch) {
continue;
}
const [, key, rawValue] = fieldMatch;
frontmatter[key] = rawValue.trim().replace(/^["'](.*)["']$/, '$1');
}
return frontmatter;
}

function toSlug(filePath) {
return path
.relative(ASSETS_DIR, filePath)
.replace(/\.md$/, '')
.split(path.sep)
.join('/');
}

function buildIndex() {
const entries = [];

for (const filePath of walkMarkdownFiles(ASSETS_DIR)) {
const content = fs.readFileSync(filePath, 'utf8');
const frontmatter = parseFrontmatter(content);
const slug = toSlug(filePath);

if (!frontmatter || REQUIRED_FIELDS.some((field) => !frontmatter[field])) {
console.warn(`[generate-subject-dashboards-index] Skipping ${slug}: missing required frontmatter field(s).`);
continue;
}

entries.push({
slug,
title: frontmatter.title,
created: frontmatter.created,
updated: frontmatter.updated,
model: frontmatter.model,
path: frontmatter.path,
});
}

fs.writeFileSync(INDEX_PATH, JSON.stringify(entries, null, 2), 'utf8');
console.log(`[generate-subject-dashboards-index] Wrote ${entries.length} entries to ${INDEX_PATH}`);
}

buildIndex();
1 change: 1 addition & 0 deletions projects/budgetkey/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const routes: Routes = [
{ path: 'p', loadChildren: () => import('./profile/profile.module').then(m => m.ProfileModule) },
{ path: 'l', loadChildren: () => import('./list-page/list-page.module').then(m => m.ListPageModule) },
{ path: 'dashboards', loadChildren: () => import('./dashboards/dashboards.module').then(m => m.DashboardsModule) },
{ path: 'subject-dashboards', loadChildren: () => import('./subject-dashboards/subject-dashboards.module').then(m => m.SubjectDashboardsModule) },
{ path: 'not-found', component: PageNotFoundComponent },
{ path: '**', pathMatch: 'full', component: PageNotFoundComponent },
];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<app-container [showHeader]="true" [showSearchBar]="true">
<div class="main" role="main">
<ng-container *ngIf="!notFound; else notFoundState">
<ng-container *ngIf="meta">
<header class="dashboard-meta">
<h1>{{ meta.title }}</h1>
<dl>
<dt>נוצר</dt><dd>{{ meta.created }}</dd>
<dt>עודכן</dt><dd>{{ meta.updated }}</dd>
<dt>מודל</dt><dd>{{ meta.model }}</dd>
<dt>נתיב</dt><dd>{{ meta.path }}</dd>
</dl>
</header>
<div class="md" dir="auto" [innerHtml]="html" #mdContainer></div>
</ng-container>
</ng-container>
<ng-template #notFoundState>
<p class="not-found">הדף המבוקש לא נמצא.</p>
</ng-template>
</div>
</app-container>
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
div.main {
align-items: center;
display: flex;
flex-flow: column;
width: 100%;
border-top: solid 1px #2389FF;
margin-top: -9px;
padding: 0 10px;
padding-top: 50px;
}

div.dashboard-meta {
max-width: 770px;
width: 100%;

h1 { color: #2389FF; font-family: "Miriam Libre"; font-size: 36px; font-weight: bold; line-height: 47px; }

dl {
display: flex;
flex-flow: row wrap;
column-gap: 16px;
row-gap: 4px;
font-family: "Abraham TRIAL";
font-size: 14px;
color: #3C4948;
margin: 0 0 20px 0;
}
dt { font-weight: bold; margin: 0; }
dd { margin: 0; }
}

p.not-found {
color: #3C4948;
font-family: "Abraham TRIAL";
font-size: 20px;
line-height: 26px;
}

div.md { max-width: 770px; width: 100%; }
::ng-deep {
div.md {
h1 { color: #2389FF; font-family: "Miriam Libre"; font-size: 36px; font-weight: bold; line-height: 47px; }
h2 { color: #2389FF; font-family: "Miriam Libre"; font-size: 24px; font-weight: 300; line-height: 47px; }
h3 { color: #2389FF; font-family: "Miriam Libre"; font-size: 20px; font-weight: 300; line-height: 47px; }
h4 { color: #3C4948; font-family: "Abraham TRIAL"; font-size: 20px; line-height: 26px; text-align: center; }
h4 img { margin: 10px; }
p { color: #3C4948; font-family: "Abraham TRIAL"; font-size: 20px; line-height: 26px; }
li { color: #3C4948; font-family: "Abraham TRIAL"; font-size: 20px; line-height: 26px; }
a { text-decoration: underline; }
pre > code { direction: ltr; display: block; }

table {
display: block;
overflow-x: auto;
border-collapse: collapse;
width: 100%;
margin: 20px 0;
font-family: "Abraham TRIAL";
font-size: 16px;
color: #3C4948;
}
thead { display: table-header-group; }
tbody { display: table-row-group; }
tr { display: table-row; }
th, td {
display: table-cell;
border: 1px solid #D7DBE0;
padding: 8px 12px;
text-align: start;
white-space: nowrap;
}
th {
background: #EAF4FF;
color: #2389FF;
font-weight: bold;
}
tbody tr:nth-child(even) td {
background: #F7FAFC;
}

div.mermaid-diagram {
display: flex;
justify-content: center;
margin: 20px 0;
overflow-x: auto;

svg { max-width: 100%; }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { HttpClient } from '@angular/common/http';
import { Component, ElementRef, ViewChild } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute, UrlSegment } from '@angular/router';
import mermaid from 'mermaid';
import { catchError, of, switchMap, timer } from 'rxjs';
import * as Showdown from 'showdown';

import { PlatformService } from '../../common-components/platform.service';

let mermaidInitialized = false;
function ensureMermaidInitialized(): void {
if (mermaidInitialized) {
return;
}
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' });
mermaidInitialized = true;
}

let mermaidDiagramCounter = 0;

interface SubjectDashboardMeta {
title: string;
created: string;
updated: string;
model: string;
path: string;
}

interface ParsedDashboardFile {
meta: SubjectDashboardMeta;
body: string;
}

const REQUIRED_META_FIELDS: (keyof SubjectDashboardMeta)[] = ['title', 'created', 'updated', 'model', 'path'];

function parseFrontmatter(content: string): ParsedDashboardFile | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) {
return null;
}

const frontmatter: Record<string, string> = {};
for (const line of match[1].split(/\r?\n/)) {
const fieldMatch = line.match(/^([a-zA-Z_]+):\s*(.*)$/);
if (!fieldMatch) {
continue;
}
const [, key, rawValue] = fieldMatch;
frontmatter[key] = rawValue.trim().replace(/^["'](.*)["']$/, '$1');
}

if (REQUIRED_META_FIELDS.some((field) => !frontmatter[field])) {
return null;
}

return {
meta: frontmatter as unknown as SubjectDashboardMeta,
body: match[2],
};
}

@Component({
selector: 'app-subject-dashboard-page',
templateUrl: './subject-dashboard-page.component.html',
styleUrls: ['./subject-dashboard-page.component.less'],
standalone: false
})
export class SubjectDashboardPageComponent {
converter: Showdown.Converter;
meta: SubjectDashboardMeta | null = null;
html: SafeHtml | null = null;
notFound = false;

@ViewChild('mdContainer') mdContainer?: ElementRef<HTMLDivElement>;

constructor(
private http: HttpClient,
private domSanitizer: DomSanitizer,
private ps: PlatformService,
private route: ActivatedRoute
) {
this.converter = new Showdown.Converter({
tables: true,
customizedHeaderId: true,
openLinksInNewWindow: true,
});

this.route.url.pipe(
switchMap((segments: UrlSegment[]) => {
const slug = segments.map((segment) => segment.path).join('/');
return this.http.get(this.ps.BASE + `/assets/subject-dashboards/${slug}.md`, { responseType: 'text' }).pipe(
catchError(() => of(null))
);
})
).subscribe((text) => {
const parsed = text === null ? null : parseFrontmatter(text);
if (!parsed) {
this.notFound = true;
return;
}
this.meta = parsed.meta;
this.html = this.domSanitizer.bypassSecurityTrustHtml(this.converter.makeHtml(parsed.body));
this.ps.browser(() => {
timer(0).subscribe(() => this.renderMermaidDiagrams());
});
});
}

private renderMermaidDiagrams(): void {
const container = this.mdContainer?.nativeElement;
if (!container) {
return;
}
ensureMermaidInitialized();
const codeBlocks = Array.from(container.querySelectorAll('pre > code.language-mermaid'));
codeBlocks.forEach((codeEl) => {
const source = codeEl.textContent || '';
const id = `subject-dashboard-mermaid-${mermaidDiagramCounter++}`;
mermaid.render(id, source)
.then(({ svg }) => {
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram';
wrapper.innerHTML = svg;
codeEl.parentElement?.replaceWith(wrapper);
})
.catch(() => {
// leave the original code block rendered as-is if the diagram source is invalid
});
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<app-container [showHeader]="true" [showSearchBar]="true">
<div class="main" role="main">
<h1>לוחות מחוונים לפי נושא</h1>
<ng-container *ngTemplateOutlet="nodeList; context: { nodes: tree }"></ng-container>

<ng-template #nodeList let-nodes="nodes">
<ul class="dashboard-tree">
<li *ngFor="let node of nodes">
<ng-container *ngIf="node.isLeaf; else folder">
<a [routerLink]="['/subject-dashboards'].concat(node.slugSegments)">{{ node.name }}</a>
</ng-container>
<ng-template #folder>
<span class="folder-label">{{ node.name }}</span>
<ng-container *ngTemplateOutlet="nodeList; context: { nodes: node.children }"></ng-container>
</ng-template>
</li>
</ul>
</ng-template>
</div>
</app-container>
Loading