diff --git a/frontend/src/app/lightning/group/group-preview.component.html b/frontend/src/app/lightning/group/group-preview.component.html
new file mode 100644
index 000000000..8a918be35
--- /dev/null
+++ b/frontend/src/app/lightning/group/group-preview.component.html
@@ -0,0 +1,56 @@
+
+
+ Lightning node group
+
+
+
+
+
{{ group.name }}
+
+
+
+
+
{{ group.description }}
+
+
+
+
+
+
+
+
+
+ Nodes |
+ {{ nodes.nodes.length }} |
+
+
+ Liquidity |
+
+ 100000000; else smallnode" [satoshis]="nodes.sumLiquidity" [digitsInfo]="'1.2-2'" [noFiat]="false">
+
+ {{ nodes.sumLiquidity | amountShortener: 1 }}
+ sats
+
+
+ |
+
+
+ Channels |
+ {{ nodes.sumChannels }} |
+
+ 0">
+ Average size |
+
+
+ |
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/app/lightning/group/group-preview.component.scss b/frontend/src/app/lightning/group/group-preview.component.scss
new file mode 100644
index 000000000..712112f5a
--- /dev/null
+++ b/frontend/src/app/lightning/group/group-preview.component.scss
@@ -0,0 +1,65 @@
+.table {
+ font-size: 32px;
+ margin-top: 0px;
+}
+
+.logo-wrapper {
+ position: relative;
+ width: 62px;
+ height: 62px;
+ margin-right: 1em;
+
+ img {
+ position: absolute;
+ right: 0;
+ top: 0;
+ }
+}
+
+.description-wrapper {
+ width: 100%;
+ margin: 16px 0 0;
+ padding: 20px 12px;
+ background: #181b2d;
+ font-size: 32px;
+}
+
+.description-text {
+ width: 100%;
+ line-height: 36px;
+ height: 72px;
+ max-height: 72px;
+ min-height: 72px;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ text-overflow: ellipsis;
+}
+
+.map-col {
+ flex-grow: 0;
+ flex-shrink: 0;
+ width: 470px;
+ height: 272px;
+ min-width: 470px;
+ min-height: 272px;
+ max-height: 272px;
+ padding: 0;
+ background: #181b2d;
+ overflow: hidden;
+ margin-top: 16px;
+}
+
+.row {
+ margin-right: 0;
+}
+
+.full-width-row {
+ padding-left: 15px;
+ flex-wrap: nowrap;
+}
+
+::ng-deep .symbol {
+ font-size: 24px;
+}
\ No newline at end of file
diff --git a/frontend/src/app/lightning/group/group-preview.component.ts b/frontend/src/app/lightning/group/group-preview.component.ts
new file mode 100644
index 000000000..be23e6178
--- /dev/null
+++ b/frontend/src/app/lightning/group/group-preview.component.ts
@@ -0,0 +1,124 @@
+import { Component, OnInit } from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { map, switchMap, Observable, catchError, of } from 'rxjs';
+import { SeoService } from '../../services/seo.service';
+import { OpenGraphService } from '../../services/opengraph.service';
+import { GeolocationData } from '../../shared/components/geolocation/geolocation.component';
+import { LightningApiService } from '../lightning-api.service';
+
+interface NodeGroup {
+ name: string;
+ description: string;
+}
+
+@Component({
+ selector: 'app-group-preview',
+ templateUrl: './group-preview.component.html',
+ styleUrls: ['./group-preview.component.scss']
+})
+export class GroupPreviewComponent implements OnInit {
+ nodes$: Observable;
+ group: NodeGroup = { name: '', description: '' };
+ slug: string;
+ groupId: string;
+
+ constructor(
+ private lightningApiService: LightningApiService,
+ private activatedRoute: ActivatedRoute,
+ private seoService: SeoService,
+ private openGraphService: OpenGraphService,
+ ) { }
+
+ ngOnInit(): void {
+ this.seoService.setTitle(`Mempool.Space Lightning Nodes`);
+
+ this.nodes$ = this.activatedRoute.paramMap
+ .pipe(
+ switchMap((params: ParamMap) => {
+ this.slug = params.get('slug');
+ this.openGraphService.waitFor('ln-group-map-' + this.slug);
+ this.openGraphService.waitFor('ln-group-data-' + this.slug);
+
+ if (this.slug === 'the-mempool-open-source-project') {
+ this.groupId = 'mempool.space';
+ this.group = {
+ name: 'The Mempool Open Source Project',
+ description: 'These are the Lightning nodes operated by The Mempool Open Source Project that provide data for the mempool.space website. Connect to us!',
+ };
+ } else {
+ this.group = {
+ name: this.slug.replace(/-/gi, ' '),
+ description: '',
+ };
+ this.openGraphService.fail('ln-group-map-' + this.slug);
+ this.openGraphService.fail('ln-group-data-' + this.slug);
+ return of(null);
+ }
+
+ return this.lightningApiService.getNodGroupNodes$(this.groupId);
+ }),
+ map((nodes) => {
+ for (const node of nodes) {
+ const socketsObject = [];
+ for (const socket of node.sockets.split(',')) {
+ if (socket === '') {
+ continue;
+ }
+ let label = '';
+ if (socket.match(/(?:[0-9]{1,3}\.){3}[0-9]{1,3}/)) {
+ label = 'IPv4';
+ } else if (socket.indexOf('[') > -1) {
+ label = 'IPv6';
+ } else if (socket.indexOf('onion') > -1) {
+ label = 'Tor';
+ }
+ socketsObject.push({
+ label: label,
+ socket: node.public_key + '@' + socket,
+ });
+ }
+ // @ts-ignore
+ node.socketsObject = socketsObject;
+
+ if (!node?.country && !node?.city &&
+ !node?.subdivision) {
+ // @ts-ignore
+ node.geolocation = null;
+ } else {
+ // @ts-ignore
+ node.geolocation = {
+ country: node.country?.en,
+ city: node.city?.en,
+ subdivision: node.subdivision?.en,
+ iso: node.iso_code,
+ };
+ }
+ }
+ const sumLiquidity = nodes.reduce((partialSum, a) => partialSum + parseInt(a.capacity, 10), 0);
+ const sumChannels = nodes.reduce((partialSum, a) => partialSum + a.opened_channel_count, 0);
+
+ this.openGraphService.waitOver('ln-group-data-' + this.slug);
+
+ return {
+ nodes: nodes,
+ sumLiquidity: sumLiquidity,
+ sumChannels: sumChannels,
+ };
+ }),
+ catchError(() => {
+ this.openGraphService.fail('ln-group-map-' + this.slug);
+ this.openGraphService.fail('ln-group-data-' + this.slug);
+ return of({
+ nodes: [],
+ sumLiquidity: 0,
+ sumChannels: 0,
+ });
+ })
+ );
+ }
+
+ onMapReady(): void {
+ this.openGraphService.waitOver('ln-group-map-' + this.slug);
+ }
+
+}
diff --git a/frontend/src/app/lightning/lightning-previews.module.ts b/frontend/src/app/lightning/lightning-previews.module.ts
index 0400acc55..c41ba8d20 100644
--- a/frontend/src/app/lightning/lightning-previews.module.ts
+++ b/frontend/src/app/lightning/lightning-previews.module.ts
@@ -9,11 +9,13 @@ import { NodePreviewComponent } from './node/node-preview.component';
import { LightningPreviewsRoutingModule } from './lightning-previews.routing.module';
import { ChannelPreviewComponent } from './channel/channel-preview.component';
import { NodesPerISPPreview } from './nodes-per-isp/nodes-per-isp-preview.component';
+import { GroupPreviewComponent } from './group/group-preview.component';
@NgModule({
declarations: [
NodePreviewComponent,
ChannelPreviewComponent,
NodesPerISPPreview,
+ GroupPreviewComponent,
],
imports: [
CommonModule,
diff --git a/frontend/src/app/lightning/lightning-previews.routing.module.ts b/frontend/src/app/lightning/lightning-previews.routing.module.ts
index 11250214d..6cce90766 100644
--- a/frontend/src/app/lightning/lightning-previews.routing.module.ts
+++ b/frontend/src/app/lightning/lightning-previews.routing.module.ts
@@ -3,6 +3,7 @@ import { RouterModule, Routes } from '@angular/router';
import { NodePreviewComponent } from './node/node-preview.component';
import { ChannelPreviewComponent } from './channel/channel-preview.component';
import { NodesPerISPPreview } from './nodes-per-isp/nodes-per-isp-preview.component';
+import { GroupPreviewComponent } from './group/group-preview.component';
const routes: Routes = [
{
@@ -17,6 +18,10 @@ const routes: Routes = [
path: 'nodes/isp/:isp',
component: NodesPerISPPreview,
},
+ {
+ path: 'group/:slug',
+ component: GroupPreviewComponent,
+ },
{
path: '**',
redirectTo: ''
diff --git a/unfurler/src/routes.ts b/unfurler/src/routes.ts
index 3dfa66b5f..f9280369c 100644
--- a/unfurler/src/routes.ts
+++ b/unfurler/src/routes.ts
@@ -56,6 +56,13 @@ const routes = {
}
}
}
+ },
+ group: {
+ render: true,
+ params: 1,
+ getTitle(path) {
+ return `Lightning Node Group: ${path[0]}`;
+ }
}
}
},