Initial version

This commit is contained in:
Danny Morabito 2025-02-20 19:28:48 +01:00
commit da9428f059
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
49 changed files with 5506 additions and 0 deletions

67
src/components/AppGrid.ts Normal file
View file

@ -0,0 +1,67 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import "@components/AppIcon";
@customElement("arx-app-grid")
export class AppGrid extends LitElement {
@property()
apps: {
icon: string | undefined;
color: string;
href: string;
name: string;
}[] = [];
static override styles = [
css`
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 20px;
padding: 30px;
width: minmax(800px, 100cqw);
margin-top: 10px;
}
@media (min-width: 1024px) {
.app-grid {
width: 500px;
}
}
@media (max-width: 768px) {
.app-grid {
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
gap: 15px;
padding: 20px;
}
}
@media (max-width: 480px) {
.app-grid {
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 10px;
padding: 15px;
}
}
`,
];
override render() {
return html`
<div class="app-grid">
${this.apps.map(
(app) => html`
<arx-app-icon
.icon=${app.icon}
.color=${app.color}
.href=${app.href}
.name=${app.name}
></arx-app-icon>
`
)}
</div>
`;
}
}

100
src/components/AppIcon.ts Normal file
View file

@ -0,0 +1,100 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import "@components/EveLink";
@customElement("arx-app-icon")
export class AppIcon extends LitElement {
@property()
icon: string | undefined;
@property()
color = "#ff9900";
@property()
href = "#";
@property()
name = "App";
static override styles = [
css`
.app-name {
font-size: 12px;
color: #000;
text-shadow: 0px 0px 5px #ffffff;
text-align: center;
max-width: 70px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: all 0.2s ease-in-out;
}
.app-icon {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
color: #fff;
text-align: center;
&:hover {
.icon {
transform: scale(1.1);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
border: 2px solid #000;
border-radius: 10px;
}
.app-name {
color: white;
text-shadow: 0px 0px 5px black;
}
}
}
.icon {
width: clamp(48px, 8vw, 64px);
height: clamp(48px, 8vw, 64px);
border-radius: 15px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: center;
align-items: center;
border: 1px solid #aaa;
transition: all 0.2s ease-in-out;
.app-icon {
width: 75%;
height: 75%;
}
}
@media (max-width: 480px) {
.app-name {
font-size: 11px;
}
}
`,
];
override render() {
return html`
<arx-eve-link href="${this.href}" class="app-icon">
<div class="icon" style="background-color: ${this.color}">
${this.icon
? html`<iconify-icon
icon="${this.icon}"
class="app-icon"
width="48"
height="48"
color="white"
></iconify-icon>`
: ""}
</div>
<span class="app-name">${this.name}</span>
</arx-eve-link>
`;
}
}

View file

@ -0,0 +1,46 @@
import { html, css, LitElement } from 'lit';
import { property, customElement } from 'lit/decorators.js';
import './BreadcrumbsItem';
@customElement('arx-breadcrumbs')
export class Breadcrumbs extends LitElement {
@property({ type: Array }) items: { text: string; href?: string }[] = [];
static override styles = [
css`
nav {
max-width: 1200px;
margin: 1rem auto;
padding-inline: 1rem;
font-size: 0.9rem;
}
ol {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
}
`,
];
override render() {
return html`
<nav aria-label="Breadcrumb">
<ol>
${this.items.map(
(item, index) => html`
<arx-breadcrumbs-item
.text=${item.text}
.href=${item.href}
.index=${index}
></arx-breadcrumbs-item>
`,
)}
</ol>
</nav>
`;
}
}

View file

@ -0,0 +1,51 @@
import { html, css, LitElement } from "lit";
import { property, customElement } from "lit/decorators.js";
import "@components/EveLink";
@customElement("arx-breadcrumbs-item")
export class BreadcrumbsItem extends LitElement {
@property() text = "";
@property() href?: string;
@property() index = 0;
static override styles = [
css`
li {
display: inline-block;
margin-right: 0.5rem;
}
.separator {
margin-inline: 0.5rem;
color: var(--secondary);
user-select: none;
}
.link {
color: var(--accent);
text-decoration: none;
transition: text-decoration 0.2s;
}
.secondary {
color: var(--secondary);
}
`,
];
override render() {
return html`
<li>
${this.index > 0
? html`<span class="separator" aria-hidden="true">/</span>`
: ""}
${this.href
? html`<arx-eve-link class="link" href=${this.href}
>${this.text}</arx-eve-link
>`
: html`<span class="secondary">${this.text}</span>`}
</li>
`;
}
}

View file

@ -0,0 +1,23 @@
import { html, LitElement, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
@customElement('arx-error-view')
export class ErrorView extends LitElement {
@property()
error!: string;
static override styles = css`
.error {
color: var(--error);
}`;
override render() {
return html`<span class="error">${this.error}</span>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'arx-error-view': ErrorView;
}
}

37
src/components/EveLink.ts Normal file
View file

@ -0,0 +1,37 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("arx-eve-link")
export class EveLink extends LitElement {
@property({ type: String }) href = "#";
@property({ type: String }) target = "";
@property({ type: String }) rel = "";
get hrefValue() {
let href = this.href;
if (href.startsWith("javascript:")) return href;
if (href.startsWith("eve://")) href = href.replace("eve://", "#");
if (href.startsWith("/")) href = href.replace("/", "#");
if (!href.startsWith("#")) href = `#${href}`;
return href;
}
static override styles = css`
a {
part: link;
}
`;
override render() {
return html`
<a
part="link"
.href=${this.hrefValue}
.target=${this.target}
.rel=${this.rel}
>
<slot></slot>
</a>
`;
}
}

108
src/components/ForumPost.ts Normal file
View file

@ -0,0 +1,108 @@
import { html, css, LitElement } from "lit";
import { property, customElement } from "lit/decorators.js";
import formatDateTime from "@utils/formatDateTime";
import "@components/MarkdownContent";
@customElement("arx-forum-post")
export class ForumPost extends LitElement {
@property({ type: String }) override id = "";
@property({ type: String }) topicId = "";
@property({ type: String }) npub = "";
@property({ type: Date }) date = new Date();
@property({ type: String }) content = "";
static override styles = [
css`
.post {
grid-column: span 2;
display: grid;
grid-template-columns: subgrid;
gap: 1rem;
padding: 1.5em;
background: oklch(from var(--accent) l c h / 0.4);
& > :first-child {
padding-right: 1.5rem;
border-right: 2px solid var(--border);
}
}
.post-content {
display: grid;
gap: 1em;
& > :first-child {
display: flex;
align-items: center;
gap: 0.5rem;
border-top: 2px solid var(--border);
border-bottom: 2px solid var(--border);
padding: 1em;
}
& > :nth-child(2) {
margin-bottom: 1em;
}
& > :nth-child(3) {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1em;
border-top: 2px solid var(--border);
padding: 1em;
@media (max-width: 400px) {
grid-template-columns: 1fr;
}
}
}
`,
];
override render() {
const permalink = `eve://phora/topics/${this.topicId}#post-${this.id}`;
return html`
<div class="post" id="post-${this.id}">
<arx-nostr-profile
.npub=${this.npub}
renderType="large"
></arx-nostr-profile>
<div class="post-content">
<div>
<iconify-icon icon="mdi:clock"></iconify-icon>
${formatDateTime(this.date)}
</div>
<div>
<arx-markdown-content
.content=${this.content || "no content"}
></arx-markdown-content>
</div>
<div>
<arx-phora-button href="#">
<iconify-icon size="32" icon="mdi:reply"></iconify-icon>
Reply
</arx-phora-button>
<arx-phora-button href=${permalink}>
<iconify-icon size="32" icon="mdi:link"></iconify-icon>
Permalink
</arx-phora-button>
<arx-phora-button href="#">
<iconify-icon size="32" icon="bxs:zap"></iconify-icon>
Zap
</arx-phora-button>
<arx-phora-button href="#">
<iconify-icon
size="32"
icon="bi:cloud-lightning-rain-fill"
></iconify-icon>
Downzap
</arx-phora-button>
</div>
</div>
</div>
`;
}
}

133
src/components/Header.ts Normal file
View file

@ -0,0 +1,133 @@
import { html, css, LitElement } from "lit";
import { property, customElement } from "lit/decorators.js";
@customElement("arx-header")
export class Header extends LitElement {
@property({ type: String }) override title = "Eve";
@property({ type: String }) url = "eve://home";
@property({ type: Boolean }) canGoBack = false;
@property({ type: Boolean }) canGoForward = false;
private searchQuery = "";
private searchInput: HTMLInputElement | null = null;
static override styles = [
css`
header {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
z-index: 999999;
background: var(--primary);
height: var(--font-2xl);
font-size: var(--header-height);
transition: all 0.3s ease;
display: flex;
align-items: center;
padding: 0 var(--space-md);
}
.nav-buttons {
display: flex;
gap: var(--space-xs);
padding-right: var(--space-xs);
button {
text-decoration: none;
color: var(--light);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: var(--space-xs);
border-radius: 100%;
font-size: var(--font-md);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&.disabled {
opacity: 0.5;
pointer-events: none;
}
}
}
.search-container {
flex: 1;
position: relative;
input {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
color: var(--light);
width: 100%;
text-align: center;
cursor: text;
font-size: 0.9rem;
width: 100%;
padding: var(--space-xs);
text-align: center;
&:not(:focus)::placeholder {
color: var(--light);
opacity: 1;
}
&:focus {
background: rgba(0, 0, 0, 0.2);
}
}
}
`,
];
override render() {
return html`
<header>
<div class="nav-buttons">
<button
class=${this.canGoBack ? "" : "disabled"}
@click=${() => this.dispatchEvent(new CustomEvent("go-back"))}
aria-label="Go back"
>
<iconify-icon icon="material-symbols:arrow-back"></iconify-icon>
</button>
<button
class=${this.canGoForward ? "" : "disabled"}
@click=${() => this.dispatchEvent(new CustomEvent("go-forward"))}
aria-label="Go forward"
>
<iconify-icon icon="material-symbols:arrow-forward"></iconify-icon>
</button>
</div>
<div class="search-container" @click=${this.focusSearch}>
<input
ref=${this.searchInput}
type="text"
.value=${this.searchQuery}
@keyup=${this.handleSearch}
/>
</div>
</header>
`;
}
private focusSearch() {
this.searchInput?.focus();
}
private handleSearch(e: KeyboardEvent) {
if (e.key !== "Enter") return;
const hash = (e.target as HTMLInputElement).value.replace("eve://", "#");
window.location.hash = hash;
}
}

View file

@ -0,0 +1,687 @@
import { LitElement, html, css } from "lit";
import { customElement, property, query, state } from "lit/decorators.js";
import { animate } from "@lit-labs/motion";
import * as nostrTools from "nostr-tools/pure";
import * as nip06 from "nostr-tools/nip06";
import * as nip19 from "nostr-tools/nip19";
import * as nip49 from "nostr-tools/nip49";
import { ndk, setSigner } from "@/ndk";
import { NDKEvent, NDKKind, NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
@customElement("arx-initial-setup")
export class InitialSetup extends LitElement {
@state() private currentPage = 1;
@state() private isAnimating = false;
@state() private seedPhrase = "";
@state() private userName = "";
@state() private profileImage = "";
@state() private lightningAddress = "";
static override styles = css`
:host {
display: block;
width: 100%;
--primary-color: var(--eve-primary-color, #4a90e2);
--primary-hover: var(--eve-primary-hover, #357abd);
--secondary-color: var(--eve-secondary-color, #6c757d);
--secondary-hover: var(--eve-secondary-hover, #5a6268);
--text-color: var(--eve-text-color, #2c3e50);
--text-secondary: var(--eve-text-secondary, #64748b);
--background-color: var(--eve-background-color, #ffffff);
--error-color: var(--eve-error-color, #dc3545);
--success-color: var(--eve-success-color, #28a745);
--spacing-unit: 0.25rem;
--border-radius: 8px;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
font-family: system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
color: var(--text-color);
}
.welcome-container {
max-width: min(800px, 90vw);
margin: 0 auto;
padding: calc(var(--spacing-unit) * 8);
animation: fadeIn var(--transition-normal);
}
section {
margin-bottom: calc(var(--spacing-unit) * 12);
}
.alpha-badge {
display: inline-flex;
align-items: center;
background: var(--primary-color);
color: white;
padding: 0.15em 0.5em;
border-radius: 999px;
font-size: 0.75em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
h1,
h2,
h3 {
margin: 0;
line-height: 1.2;
}
h1 {
font-size: clamp(2rem, 5vw, 2.5rem);
font-weight: 700;
background: linear-gradient(
45deg,
var(--primary-color),
var(--primary-hover)
);
-webkit-background-clip: text;
color: transparent;
margin-bottom: calc(var(--spacing-unit) * 8);
}
h2 {
font-size: clamp(1.5rem, 3vw, 1.75rem);
font-weight: 600;
margin-bottom: calc(var(--spacing-unit) * 4);
}
h3 {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: calc(var(--spacing-unit) * 4);
}
p {
margin: 0 0 calc(var(--spacing-unit) * 4);
line-height: 1.6;
color: var(--text-secondary);
}
.input-group {
display: flex;
gap: calc(var(--spacing-unit) * 4);
margin-top: calc(var(--spacing-unit) * 6);
}
input {
flex: 1;
padding: calc(var(--spacing-unit) * 4);
border: 2px solid var(--text-secondary);
border-radius: var(--border-radius);
font-size: 1rem;
transition: all var(--transition-fast);
}
input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.2);
}
.button {
position: relative;
display: inline-flex;
align-items: center;
gap: calc(var(--spacing-unit) * 2);
padding: calc(var(--spacing-unit) * 3) calc(var(--spacing-unit) * 6);
border: none;
border-radius: var(--border-radius);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.button.primary {
background: linear-gradient(
45deg,
var(--primary-color),
var(--primary-hover)
);
color: white;
box-shadow: var(--shadow-md);
}
.button.secondary {
background: linear-gradient(
45deg,
var(--secondary-color),
var(--secondary-hover)
);
color: white;
}
.button:hover {
transform: translateY(-2px);
}
.button:active {
transform: translateY(0);
}
.navigation {
display: flex;
justify-content: space-between;
margin-top: calc(var(--spacing-unit) * 8);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.welcome-container {
padding: calc(var(--spacing-unit) * 4);
}
.input-group {
flex-direction: column;
}
.button {
width: 100%;
justify-content: center;
}
}
pre {
white-space: normal;
}
code {
white-space: pre;
}
.note {
display: block;
color: #666;
font-size: 0.875rem;
margin-top: 0.5rem;
padding-left: 0.5rem;
border-left: 2px solid #ddd;
font-style: italic;
line-height: 1.4;
max-width: 600px;
opacity: 0.9;
}
.note:hover {
opacity: 1;
border-left-color: #999;
}
fieldset {
border: 2px solid #3498db;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background-color: #f8f9fa;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
legend {
padding: 0 10px;
background-color: #3498db;
color: white;
font-weight: bold;
border-radius: 4px;
font-size: 1.1em;
}
fieldset:hover {
border-color: #2980b9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
}
.external-link {
color: #2970ff;
text-decoration: none;
font-weight: 500;
transition: color 0.2s ease;
}
.external-link:hover {
color: #1a56db;
text-decoration: underline;
}
.external-link:after {
content: "↗";
display: inline-block;
margin-left: 0.25rem;
font-size: 0.875rem;
}
`;
private handleNavigation(page: number) {
if (this.isAnimating) return;
this.isAnimating = true;
this.currentPage = page;
setTimeout(() => (this.isAnimating = false), 300);
}
private onSeedPhraseInput(event: Event) {
this.seedPhrase = (event.target as HTMLInputElement).value;
}
private generateSeedPhrase() {
this.seedPhrase = nip06.generateSeedWords();
}
private isValidSeedPhrase() {
const words = this.seedPhrase.split(" ");
if (words.length !== 12) return false;
if (!nip06.validateWords(words.join(" "))) return false;
return true;
}
private renderPageOne() {
return html`
<main class="welcome-container" ${animate()}>
<section>
<h1>Welcome to Eve</h1>
<h2>Your Private Community Network</h2>
<p>
Connect, share, and engage with your community in a secure,
members-only space designed just for you.
</p>
</section>
<section>
<div class="alpha-badge">
<iconify-icon icon="mdi:alpha"></iconify-icon> Alpha Preview
</div>
<p>
We're actively developing Eve to create the best possible
experience. Your feedback helps shape our platform.
</p>
</section>
<section>
<div class="action-buttons">
<a href="https://arx-ccn.com/eve-feedback" class="button primary">
<iconify-icon icon="mdi:feedback"></iconify-icon>Share Feedback
</a>
<a
href="https://arx-ccn.com/report-eve-bug"
class="button secondary"
>
<iconify-icon icon="mdi:bug"></iconify-icon>Report a Bug
</a>
</div>
</section>
<div class="navigation">
<span></span>
<button
@click=${() => this.handleNavigation(2)}
class="button primary"
>
Next
</button>
</div>
</main>
`;
}
private renderPageTwo() {
return html`
<main class="welcome-container" ${animate()}>
<section>
<h1>Getting Started</h1>
<h2>Creating a Community</h2>
<p>
Connect with others by joining an existing community or creating
your own.
</p>
<p>
During this alpha phase, community setup requires a few manual
steps. We're actively working to streamline this process in future
updates.
</p>
</section>
<section>
<h3>Seed Phrase</h3>
<p>
Enter your community's seed phrase below or generate a new one to
create a community.
</p>
<p>
<b>Important</b>: Keep your seed phrase secure. Anyone with access
to it can join your community.
</p>
<p>
In an upcoming release, we'll implement MLS (Messaging Layer
Security) to enable simpler invitation-based community access, as
well as improve your community's security.
</p>
<div class="input-group">
<input
@input=${this.onSeedPhraseInput}
.value=${this.seedPhrase}
id="seed-input"
type="text"
placeholder="Enter seed phrase..."
/>
<button
@click=${() => this.generateSeedPhrase()}
class="button secondary"
>
Generate
</button>
</div>
</section>
<div class="navigation">
<button
@click=${() => this.handleNavigation(1)}
class="button secondary"
>
Back
</button>
<button
@click=${() => this.handleNavigation(3)}
?disabled=${!this.isValidSeedPhrase()}
class="button primary"
>
Continue
</button>
</div>
</main>
`;
}
private getSetupCode() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.includes("mac")) {
return `
mkdir -p ~/.config/arx/eve && cd ~/.config/arx/eve
echo "${this.seedPhrase}" > ccn.seed
launchctl load ~/Library/LaunchAgents/com.user.eve-relay.plist
launchctl start com.user.eve-relay
`
.split("\n")
.map((x) => x.trim())
.join("\n");
}
if (userAgent.includes("linux")) {
return `
mkdir -p ~/.config/arx/eve && cd ~/.config/arx/eve
echo "${this.seedPhrase}" > ccn.seed
systemctl --user enable eve-relay.service
systemctl --user start eve-relay.service
`
.split("\n")
.map((x) => x.trim())
.join("\n");
}
return "Unsupported OS";
}
private renderPageThree() {
return html`
<main class="welcome-container" ${animate()}>
<section>
<h2>Configure Eve Relay</h2>
<p>
During this alpha phase, manual relay configuration is required.
This process will be automated in future releases.
</p>
<p>Open your terminal and run following commands:</p>
<pre>
<code>${this.getSetupCode()}</code>
</pre
>
<p>
Having trouble? Our team is here to help if you encounter any
issues.
</p>
<p>Click Continue once the relay is running.</p>
</section>
<div class="navigation">
<button
@click=${() => this.handleNavigation(2)}
class="button secondary"
>
Back
</button>
<button
@click=${() => this.handleNavigation(4)}
class="button primary"
>
Continue
</button>
</div>
</main>
`;
}
private onUserNameInput(e: Event) {
this.userName = (e.target as HTMLInputElement).value;
}
private onProfileImageInput(e: Event) {
this.profileImage = (e.target as HTMLInputElement).value;
}
private onLightningAddressInput(e: Event) {
this.lightningAddress = (e.target as HTMLInputElement).value;
}
private renderPageFour() {
return html`
<main class="welcome-container" ${animate()}>
<section>
<h2>Complete Your Profile</h2>
<p>Great progress! Let's set up your community profile.</p>
<p>
Your profile information will be encrypted and visible only to
community members.
</p>
<div class="profile-form">
<fieldset>
<legend>Display Name</legend>
<input
id="username"
type="text"
.value=${this.userName}
@input=${this.onUserNameInput}
placeholder="Enter your name"
/>
</fieldset>
<fieldset>
<legend>Profile Picture</legend>
<input
id="profile-image"
type="text"
.value=${this.profileImage}
@input=${this.onProfileImageInput}
placeholder="Enter image URL"
/>
<small class="note">
Direct file uploads will be supported in a future update. For
now, please provide an image URL or leave blank.
</small>
</fieldset>
</div>
</section>
<div class="navigation">
<button
@click=${() => this.handleNavigation(3)}
class="button secondary"
>
Back
</button>
<button
@click=${() => this.handleNavigation(5)}
class="button primary"
>
Final Step
</button>
</div>
</main>
`;
}
private renderPageFive() {
return html`
<main class="welcome-container" ${animate()}>
<section>
<h2>Payment Setup</h2>
<p>Almost done! Let's set up your payment options.</p>
<p>
Enter your existing Lightning address below for payments within the
community. If you don't have one, leave this field blank and we'll
automatically generate one for you through
<a target="_blank" href="https://npub.cash" class="external-link"
>npub.cash</a
>.
</p>
<input
id="lightning-address"
type="text"
.value=${this.lightningAddress}
@input=${this.onLightningAddressInput}
placeholder="your@lightning.address"
/>
<small class="note">
Your Lightning address enables secure, instant payments within the
community.
</small>
</section>
<div class="navigation">
<button
@click=${() => this.handleNavigation(4)}
class="button secondary"
>
Back
</button>
<button @click=${() => this.goToFinalStep()} class="button primary">
Next
</button>
</div>
</main>
`;
}
private async goToFinalStep() {
let encryptionPassphrase = localStorage.getItem("encryption_key");
if (!encryptionPassphrase) {
encryptionPassphrase =
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
localStorage.setItem("encryption_key", encryptionPassphrase);
}
const randomPrivateKey = nostrTools.generateSecretKey();
const encryptedNsec = nip49.encrypt(randomPrivateKey, encryptionPassphrase);
const npub = nip19.npubEncode(nostrTools.getPublicKey(randomPrivateKey));
if (!this.lightningAddress) this.lightningAddress = `${npub}@npub.cash`;
localStorage.setItem("ncryptsec", encryptedNsec);
setSigner(new NDKPrivateKeySigner(randomPrivateKey));
const event = new NDKEvent(ndk);
event.kind = NDKKind.Metadata;
event.content = JSON.stringify({
name: this.userName,
image: this.profileImage || undefined,
lud16: this.lightningAddress,
});
await event.sign();
await event.publish();
this.handleNavigation(6);
}
private renderPageSix() {
return html`
<main class="welcome-container" ${animate()}>
<section>
<h2>Done!</h2>
<p>
That's it! You're all set to start using the community. We hope you
enjoy it!
</p>
<p>Your community's seed phrase is: <b>${this.seedPhrase}</b></p>
<p>
Please store this seed phrase somewhere safe and secure. It will be
required to invite new members to the community.
</p>
<p>Your username is: <b>${this.userName}</b></p>
${this.profileImage
? html` <p>
Your profile image is: <img .src=${this.profileImage} />
</p>`
: ""}
<p>Your lightning address is: <b>${this.lightningAddress}</b></p>
</section>
<div class="navigation">
<button
@click=${() => this.handleNavigation(5)}
class="button secondary"
>
Back
</button>
<button @click=${this.finish} class="button primary">Finish</button>
</div>
</main>
`;
}
finish() {
this.dispatchEvent(
new CustomEvent("finish", {
detail: {
userName: this.userName,
profileImage: this.profileImage,
lightningAddress: this.lightningAddress,
},
bubbles: true,
composed: true,
})
);
}
override render() {
switch (this.currentPage) {
case 1:
return this.renderPageOne();
case 2:
return this.renderPageTwo();
case 3:
return this.renderPageThree();
case 4:
return this.renderPageFour();
case 5:
return this.renderPageFive();
case 6:
return this.renderPageSix();
default:
return html`<div class="welcome-container">Loading...</div>`;
}
}
}

View file

@ -0,0 +1,17 @@
import { html, LitElement } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('arx-loading-view')
export class LoadingView extends LitElement {
override render() {
return html`
<span>Loading...</span>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'arx-loading-view': LoadingView;
}
}

View file

@ -0,0 +1,113 @@
import { css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
import MarkdownIt, { type StateCore, type Token } from 'markdown-it';
function nostrPlugin(md: MarkdownIt): void {
const npubRegex = /(npub[0-9a-zA-Z]{59})/g;
md.core.ruler.after('inline', 'nostr_npub', (state: StateCore): boolean => {
for (const token of state.tokens) {
if (token.type === 'inline' && token.children) {
for (let i = 0; i < token.children.length; i++) {
const child = token.children[i];
if (child.type === 'text') {
const matches = child.content.match(npubRegex);
if (!matches) continue;
const newTokens: Token[] = [];
let lastIndex = 0;
child.content.replace(
npubRegex,
(match: string, npub: string, offset: number) => {
if (offset > lastIndex) {
const textToken = new state.Token('text', '', 0);
textToken.content = child.content.slice(lastIndex, offset);
newTokens.push(textToken);
}
const linkOpen = new state.Token('link_open', 'a', 1);
linkOpen.attrs = [['href', `nostr:${npub}`]];
const text = new state.Token('text', '', 0);
text.content = npub;
const linkClose = new state.Token('link_close', 'a', -1);
newTokens.push(linkOpen, text, linkClose);
lastIndex = offset + match.length;
},
);
if (lastIndex < child.content.length) {
const textToken = new state.Token('text', '', 0);
textToken.content = child.content.slice(lastIndex);
newTokens.push(textToken);
}
token.children.splice(i, 1, ...newTokens);
i += newTokens.length - 1;
}
}
}
}
return true;
});
}
@customElement('arx-markdown-content')
export class MarkdownContent extends LitElement {
private md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
});
@property({ type: String })
content = '';
static override styles = [
css`
:host {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-start;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
text-wrap: normal;
}
* {
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
text-wrap: normal;
}
.text-content {
display: contents;
}
h1, h2, h3, h4, h5, h6, code, ul, ol, blockquote {
width: 100%;
margin: 0;
display: block;
}
`,
];
constructor() {
super();
this.md.use(nostrPlugin);
}
override render() {
return unsafeHTML(this.md.render(this.content));
}
}

View file

@ -0,0 +1,48 @@
import type { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { css, html, LitElement } from 'lit-element';
import { customElement, property } from 'lit/decorators.js';
type AvatarSize = 'short' | 'medium' | 'large' | 'huge';
@customElement('arx-nostr-avatar')
export class ArxNostrAvatar extends LitElement {
@property({ type: Object }) profile!: NDKUserProfile;
@property({ type: String }) size!: AvatarSize;
static override styles = [
css`
img {
border-radius: 50%;
width: var(--avatar-size);
height: var(--avatar-size);
}
.short-avatar {
--avatar-size: 2rem;
}
.medium-avatar {
--avatar-size: 3rem;
}
.large-avatar {
--avatar-size: 4rem;
}
.huge-avatar {
--avatar-size: 5rem;
}
`,
];
override render() {
return html`
<img
class=${this.size}-avatar
src=${this.profile.image || '/default-avatar.png'}
alt=${this.profile.name || this.profile.displayName || ''}
@error=${this.handleError}
/>
`;
}
handleError(event: Event) {
event.preventDefault();
(event.target as HTMLImageElement).src = '/default-avatar.png';
}
}

View file

@ -0,0 +1,70 @@
import { html, css, LitElement } from 'lit';
import { property, customElement, state } from 'lit/decorators.js';
import type { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { getUserProfile } from '../ndk';
import '@components/profiles/ShortProfile';
import '@components/profiles/MediumProfile';
import '@components/profiles/LargeProfile';
import '@components/profiles/CardProfile';
@customElement('arx-nostr-profile')
export class NostrProfile extends LitElement {
@property() npub = '';
@property({ reflect: true }) renderType:
| 'short'
| 'medium'
| 'large'
| 'card' = 'short';
@state()
private profile: NDKUserProfile | undefined = undefined;
@state()
private error: string | null = null;
static override styles = [
css`
.nostr-profile {
display: inline-block;
}
`,
];
override async connectedCallback() {
super.connectedCallback();
await this.loadProfile();
}
async loadProfile() {
try {
this.profile = await getUserProfile(this.npub);
} catch (error) {
this.error = 'Failed to load profile';
console.error(error);
}
}
override render() {
if (this.error) {
return html`<arx-error-view .error=${this.error}></arx-error-view>`;
}
if (!this.profile) {
return html`<arx-loading-view></arx-loading-view>`;
}
switch (this.renderType) {
case 'short':
return html`<arx-nostr-short-profile .profile=${this.profile} npub=${this.npub}></arx-nostr-short-profile>`;
case 'medium':
return html`<arx-nostr-medium-profile .profile=${this.profile} npub=${this.npub}></arx-nostr-medium-profile>`;
case 'large':
return html`<arx-nostr-large-profile .profile=${this.profile} npub=${this.npub}></arx-nostr-large-profile>`;
case 'card':
return html`<arx-nostr-card-profile .profile=${this.profile} npub=${this.npub}></arx-nostr-card-profile>`;
default:
return html`<p>Invalid render type</p>`;
}
}
}

View file

@ -0,0 +1,75 @@
import { html, css, LitElement } from "lit";
import { property, customElement } from "lit/decorators.js";
import "@components/EveLink";
@customElement("arx-phora-button")
export class PhoraButton extends LitElement {
@property({ type: String }) href = "";
@property({ type: String }) target = "";
@property({ type: String }) rel = "";
@property({ type: Boolean }) disabled = false;
get hrefValue() {
if (!this.href || this.disabled) return "javascript:void(0)";
return this.href;
}
static override styles = [
css`
arx-eve-link::part(link) {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: white;
border: none;
text-decoration: none;
padding: 0.75rem 0.75rem;
border-radius: 0.25rem;
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
line-height: 1.2;
cursor: pointer;
transition: all 0.2s ease-in-out;
box-shadow: var(--shadow-sm);
gap: 5px;
}
arx-eve-link:hover {
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
background: color-mix(in oklch, var(--accent), black 15%);
}
arx-eve-link:focus {
outline: none;
box-shadow: var(--shadow-md);
}
arx-eve-link:active {
transform: translateY(0);
box-shadow: var(--shadow-md);
}
arx-eve-link.disabled {
opacity: 0.6;
cursor: not-allowed;
}
`,
];
override render() {
return html`
<arx-eve-link
.href=${this.hrefValue}
.target=${this.target}
.rel=${this.rel}
class="${this.disabled ? "disabled" : ""}"
>
<slot></slot>
</arx-eve-link>
`;
}
}

View file

@ -0,0 +1,44 @@
import { html, css, LitElement } from "lit";
import { property, customElement } from "lit/decorators.js";
@customElement("arx-phora-forum-category")
export class PhoraForumCategory extends LitElement {
@property({ type: String }) override title = "";
@property({ type: String }) override id = "";
static override styles = [
css`
.forum-category {
background: oklch(100% 0 0);
border-radius: 0.5rem;
box-shadow: var(--shadow-xl);
margin-block-end: 1.5rem;
}
.category-header {
background: var(--primary);
color: oklch(100% 0 0);
padding: 1rem 1.5rem;
border-radius: 0.5rem 0.5rem 0 0;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
override render() {
return html`
<div class="forum-category">
<div class="category-header">
<span>${this.title}</span>
<arx-phora-button href="/phora/new-topic/${this.id}">New Topic</phora-button>
</div>
<slot>
<div style="padding: 1rem 1.5rem">No topics...</div>
</slot>
</div>
`;
}
}

View file

@ -0,0 +1,212 @@
import { html, css, LitElement } from "lit";
import { property, customElement } from "lit/decorators.js";
import "@components/EveLink";
@customElement("arx-phora-forum-topic")
export class PhoraForumTopic extends LitElement {
static override styles = [
css`
.topic {
display: grid;
grid-template-columns: 3fr 1fr;
padding: 1.75rem;
border-radius: 12px;
margin-bottom: 1rem;
box-shadow: var(--shadow-md);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.topic:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.topic-icon {
inline-size: 40px;
block-size: 40px;
background: var(--accent);
border-radius: 10px;
flex-shrink: 0;
position: relative;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.topic-icon::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(
45deg,
transparent,
oklch(from var(--accent) l c h / 0.2)
);
}
.topic-info {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.topic-details {
flex: 1;
}
arx-eve-link::part(link) {
display: inline-block;
margin-bottom: 0.875rem;
font-size: 1.2rem;
font-weight: 600;
letter-spacing: -0.01em;
text-decoration: none;
color: var(--primary);
transition: all 0.2s ease;
&:hover {
color: var(--secondary);
transform: translateX(4px);
}
}
.topic-details p {
margin: 0;
font-size: 0.975rem;
line-height: 1.6;
color: var(--secondary);
}
.new-status {
position: relative;
color: var(--accent);
}
.new-status::after {
content: "New";
position: absolute;
top: -8px;
right: -40px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
background: var(--accent);
color: var(--light);
padding: 2px 8px;
border-radius: 12px;
box-shadow: var(--shadow-sm);
}
.hot-status {
color: var(--error);
}
.hot-status::before {
content: "🔥";
margin-right: 0.5rem;
}
.stats-container {
display: flex;
flex-direction: column;
gap: 1rem;
padding-left: 1.5rem;
border-left: 2px solid var(--border);
}
.post-count {
display: flex;
align-items: center;
gap: 0.625rem;
color: var(--secondary);
font-weight: 500;
}
.post-count :deep(iconify-icon) {
font-size: 1.375rem;
color: var(--accent);
}
.last-post-section {
background: oklch(from var(--primary) 98% calc(c * 0.2) h);
padding: 0.875rem;
border-radius: 8px;
box-shadow: var(--shadow-sm);
}
.last-post-section > div:first-of-type {
font-size: 0.875rem;
font-weight: 500;
color: var(--secondary);
margin-bottom: 0.5rem;
}
.last-post-info {
color: var(--secondary);
font-size: 0.875rem;
margin-top: 0.625rem;
padding-top: 0.625rem;
border-top: 1px solid var(--border);
}
@media (max-width: 968px) {
.topic {
grid-template-columns: 1fr;
gap: 1.5rem;
padding: 1.25rem;
}
.stats-container {
padding-left: 0;
border-left: none;
padding-top: 1rem;
border-top: 2px solid var(--border);
}
}
`,
];
@property({ type: String }) override id = "";
@property({ type: String }) override title = "";
@property({ type: String }) description = "";
@property({ type: Number }) posts = 0;
@property({ type: String }) lastPostBy = "";
@property({ type: String }) lastPostTime = "";
@property({ type: Boolean }) isNew = false;
@property({ type: Boolean }) isHot = false;
get status() {
if (this.isNew) return "new-status";
if (this.isHot) return "hot-status";
return "";
}
override render() {
return html`
<div class="topic">
<div class="topic-info">
<div class="topic-icon"></div>
<div class="topic-details">
<arx-eve-link
class="${this.status}"
href="/phora/topics/${this.id}"
>
${this.title}
</arx-eve-link>
<p>${this.description}</p>
</div>
</div>
<div class="stats-container">
<div class="post-count">
<iconify-icon icon="material-symbols:forum-rounded"></iconify-icon>
<span>${this.posts.toLocaleString()} posts</span>
</div>
<div class="last-post-section">
<div>Latest activity</div>
<arx-nostr-profile npub="${this.lastPostBy}"></arx-nostr-profile>
<div class="last-post-info">${this.lastPostTime}</div>
</div>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,101 @@
import { html, css, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getLastBlockHeight } from "@utils/lastBlockHeight";
@customElement("arx-bitcoin-block-widget")
export class BitcoinBlockWidget extends LitElement {
@state()
private lastBlock: number | null = null;
@state()
private isLoading = true;
@state()
private error: string | null = null;
private REFRESH_INTERVAL = 5000;
@state()
private timer: number | null = null;
constructor() {
super();
this.loadBlockHeight();
this.timer = window.setInterval(
this.loadBlockHeight,
this.REFRESH_INTERVAL
);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.timer) clearInterval(this.timer);
}
async loadBlockHeight() {
try {
const response = await getLastBlockHeight();
this.lastBlock = response.height;
this.error = null;
} catch (error) {
this.error = "Failed to load block height";
console.error(error);
} finally {
this.isLoading = false;
}
}
static override styles = [
css`
.error {
color: #dc3545;
padding: 0.5rem;
border-radius: 4px;
background: #f8d7da;
}
.loading {
display: flex;
align-items: center;
gap: 0.5rem;
}
.block-height {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-weight: 500;
}
.value {
font-size: 1.25rem;
font-weight: 600;
}
`,
];
override render() {
if (this.error) {
return html`<div class="error">${this.error}</div>`;
}
if (this.isLoading) {
return html`
<div class="loading">
<span class="loader"></span>
Loading latest block...
</div>
`;
}
return html`
<div class="block-height">
<span class="label">Last Block:</span>
<span class="value">${this.lastBlock?.toLocaleString()}</span>
</div>
`;
}
}

View file

@ -0,0 +1,121 @@
import { html, css, LitElement } from 'lit';
import { property, customElement, state } from 'lit/decorators.js';
import type { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { getProfile } from '@utils/profileUtils';
@customElement('arx-nostr-card-profile')
export class CardProfile extends LitElement {
@property() profile!: NDKUserProfile;
@property() npub = '';
@state()
private displayName = '';
@state()
private profileUrl = '';
@state()
private website = '';
@state()
private about = '';
@state()
private firstLineOfAbout = '';
static override styles = [
css`
.card {
padding: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
max-width: 300px;
}
a {
color: var(--primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
h3 {
margin: 0;
font-size: 1.2rem;
}
p {
margin: 0.25rem 0;
color: var(--secondary);
font-size: 0.9rem;
}
.bio {
white-space: pre-line;
font-size: 0.9rem;
color: var(--primary);
margin: 0.5rem 0;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
}
.website-link {
display: block;
margin-top: 0.5rem;
font-size: 0.9rem;
color: var(--accent);
}
.website-link:hover {
text-decoration: underline;
}
`,
];
override firstUpdated() {
const { displayName, profileUrl } = getProfile(this);
this.displayName = displayName;
this.profileUrl = profileUrl;
this.website = this.profile.website || '';
const lines = (this.profile.about || '').split('\n');
let firstLine = lines[0].trim();
let remainingContent = lines
.slice(1)
.join('\n')
.trim()
.split(/-+\n/, 2)
.filter(section => section.trim() !== '')
.join('\n')
.trim();
if (firstLine.length > 20) {
remainingContent = `${firstLine}\n${remainingContent}`;
firstLine = '';
}
this.about = remainingContent;
this.firstLineOfAbout = firstLine;
}
override render() {
return html`
<div class="card">
<router-link to=${this.profileUrl}>
<arx-nostr-avatar .profile=${this.profile} size="large"></arx-nostr-avatar>
<div>
<h3>${this.displayName}</h3>
<p v-if=${this.firstLineOfAbout}>${this.firstLineOfAbout}</p>
</div>
</router-link>
<div v-if=${this.about} class="bio">${this.about}</div>
<a v-if=${this.website} href=${this.website} target="_blank" rel="noreferrer noopener" class="website-link">
${this.website}
</a>
</div>
`;
}
}

View file

@ -0,0 +1,61 @@
import { html, css, LitElement } from 'lit';
import { property, customElement, state } from 'lit/decorators.js';
import type { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { getProfile } from '@utils/profileUtils';
import firstLine from '@utils/firstLine';
@customElement('arx-nostr-large-profile')
export class LargeProfile extends LitElement {
@property() profile!: NDKUserProfile;
@property() npub = '';
@state()
private displayName = '';
@state()
private profileUrl = '';
@state()
private about = '';
static override styles = [
css`
a {
color: var(--primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.bio {
white-space: pre-line;
font-size: 0.9rem;
color: var(--primary);
margin: 0.5rem 0;
}
`,
];
override firstUpdated() {
const { displayName, profileUrl } = getProfile({
profile: this.profile,
npub: this.npub,
});
this.displayName = displayName;
this.profileUrl = profileUrl;
this.about = firstLine(this.profile.about);
}
override render() {
return html`
<router-link to=${this.profileUrl}>
<arx-nostr-avatar .profile=${this.profile} size="large"></arx-nostr-avatar>
<div>
<h3>${this.displayName}</h3>
<div class="bio">${this.about}</div>
</div>
</router-link>
`;
}
}

View file

@ -0,0 +1,64 @@
import { html, css, LitElement } from "lit";
import { property, customElement, state } from "lit/decorators.js";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { getProfile } from "@utils/profileUtils";
import firstLine from "@utils/firstLine";
import "@components/EveLink";
@customElement("arx-nostr-medium-profile")
export class MediumProfile extends LitElement {
@property() profile!: NDKUserProfile;
@property() npub = "";
@state()
private displayName = "";
@state()
private profileUrl = "";
@state()
private truncatedAbout = "";
static override styles = [
css`
arx-eve-link::part(link) {
color: var(--primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
`,
];
override firstUpdated() {
const { displayName, profileUrl } = getProfile({
profile: this.profile,
npub: this.npub,
});
this.displayName = displayName;
this.profileUrl = profileUrl;
this.truncatedAbout = this.getTruncatedAbout();
}
getTruncatedAbout() {
const about = firstLine(this.profile.about);
return about?.length > 80 ? `${about.substring(0, 80)}...` : about;
}
override render() {
return html`
<arx-eve-link href=${this.profileUrl}>
<arx-nostr-avatar
.profile=${this.profile}
size="medium"
></arx-nostr-avatar>
<div>
<div>${this.displayName}</div>
<div class="bio">${this.truncatedAbout}</div>
</div>
</arx-eve-link>
`;
}
}

View file

@ -0,0 +1,51 @@
import { html, css, LitElement } from "lit";
import { property, customElement, state } from "lit/decorators.js";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { getProfile } from "@utils/profileUtils";
import "@components/EveLink";
@customElement("arx-nostr-short-profile")
export class ShortProfile extends LitElement {
@property() profile!: NDKUserProfile;
@property() npub = "";
@state()
private displayName = "";
@state()
private profileUrl = "";
static override styles = [
css`
arx-eve-link::part(link) {
color: var(--primary);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
`,
];
protected override firstUpdated(): void {
const { displayName, profileUrl } = getProfile({
profile: this.profile,
npub: this.npub,
});
this.displayName = displayName;
this.profileUrl = profileUrl;
}
override render() {
return html`
<arx-eve-link href=${this.profileUrl}>
<arx-nostr-avatar
.profile=${this.profile}
size="short"
></arx-nostr-avatar>
<span>${this.displayName}</span>
</arx-eve-link>
`;
}
}

14
src/main.ts Normal file
View file

@ -0,0 +1,14 @@
import "./style.scss";
import "@components/ErrorView";
import "@components/NostrAvatar";
import "@components/LoadingView";
import "@components/NostrProfile";
import "@components/Breadcrumbs";
import "@components/Header";
import "@routes/router";
import type EveRouter from "@routes/router";
const router = document.createElement("arx-eve-router") as EveRouter;
router.ccnSetup = localStorage.getItem("ncryptsec");
document.body.appendChild(router);

39
src/ndk.ts Normal file
View file

@ -0,0 +1,39 @@
import NDK, { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
import * as nip49 from "nostr-tools/nip49";
export const ndk = new NDK({
explicitRelayUrls: ["ws://localhost:6942"],
enableOutboxModel: false,
autoConnectUserRelays: false,
clientName: "Arx",
clientNip89: "arx",
});
export async function getSigner() {
await ndk.connect();
if (ndk.signer) return;
const encryptionPassphrase = localStorage.getItem("encryption_key");
const signer = new NDKPrivateKeySigner(
nip49.decrypt(localStorage.getItem("ncryptsec"), encryptionPassphrase)
);
setSigner(signer);
}
export async function getNpub() {
await getSigner();
const user = await ndk.signer?.user();
if (user) return user.npub;
throw new Error("Could not get npub");
}
export function setSigner(signer: NDKPrivateKeySigner) {
ndk.signer = signer;
}
export async function getUserProfile(npub: string) {
await ndk.connect();
const query = npub.startsWith("npub") ? { npub } : { pubkey: npub };
const user = ndk.getUser(query);
await user.fetchProfile();
return user.profile;
}

199
src/routes/404Page.ts Normal file
View file

@ -0,0 +1,199 @@
import { html, css, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { RouteParams } from "./router";
import "@components/EveLink";
@customElement("arx-404-page")
export class FourOhFourPage extends LitElement {
@property({ type: Object })
params: RouteParams = {};
@property({ type: String })
path = "";
@property({ type: Boolean })
canGoBack = false;
static override styles = [
css`
.not-found {
display: flex;
align-items: center;
justify-content: center;
font-family: "Inter", sans-serif;
padding: 1rem;
}
.content {
max-width: 600px;
text-align: center;
}
.error-container h1 {
margin: 0;
}
.error-container h1 * {
position: relative;
margin: 0 -20px;
}
.spinning-gear {
animation: spin 5s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.path-container {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
margin-bottom: 2rem;
padding: 1rem;
backdrop-filter: blur(10px);
}
.path-text {
color: var(--secondary);
margin: 0 0 0.5rem 0;
font-size: 0.9rem;
}
.path {
font-family: "JetBrains Mono", monospace;
color: var(--primary);
word-break: break-all;
font-size: 1.1rem;
padding: 0.5rem;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
display: inline-block;
max-width: 100%;
}
h1 {
font-size: 8rem;
font-weight: 800;
}
.message-text {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.message-text .inline-icon {
font-size: 1.25rem;
flex-shrink: 0;
}
.status {
font-size: 1.25rem;
color: #94a3b8;
margin: 1rem 0;
}
.sub-text {
color: #64748b;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 2rem;
}
.inline-icon {
font-size: 1.25rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.primary-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
text-decoration: none;
background: var(--accent);
color: white;
&:hover {
background: var(--secondary);
transform: translateY(-2px);
}
}
arx-eve-link::part(link) {
color: white;
text-decoration: none;
}
`,
];
override render() {
return html`
<div class="not-found">
<div class="content">
<div class="error-container">
<h1>
<span class="four">4</span>
<iconify-icon
icon="fluent-emoji:gear"
class="spinning-gear"
></iconify-icon>
<span class="four">4</span>
</h1>
</div>
<div class="path-container">
<div class="path-text">Path:</div>
<div class="path">${this.path}</div>
</div>
<div class="message-text">
<div class="status">Page not found.</div>
<div class="sub-text">
The page you are looking for does not exist.
</div>
</div>
<div class="actions">
<a
href="javascript:void(0)"
@click="${() =>
this.dispatchEvent(
new CustomEvent("go-back", {
bubbles: true,
composed: true,
})
)}"
class="primary-button"
>
<iconify-icon icon="material-symbols:arrow-back"></iconify-icon>
Go back
</a>
<arx-eve-link href="home" class="primary-button">
<iconify-icon icon="material-symbols:home"></iconify-icon>
Home
</arx-eve-link>
</div>
</div>
</div>
`;
}
}

252
src/routes/Home.ts Normal file
View file

@ -0,0 +1,252 @@
import { getNpub, getUserProfile } from "@/ndk";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { css, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { html, literal } from "lit/static-html.js";
import "@widgets/BitcoinBlockWidget";
import "@components/AppGrid";
@customElement("arx-eve-home")
export class Home extends LitElement {
@state()
private npub: string | undefined;
@state()
private profile: NDKUserProfile | undefined;
@state()
private username: string | undefined;
apps = [
{
id: 0,
href: "letters",
name: "Letters",
color: "#FF33BB",
icon: "bxs:envelope",
},
{
id: 1,
href: "messages",
name: "Messages",
color: "#34C759",
icon: "bxs:chat",
},
{
id: 2,
href: "calendar",
name: "Calendar",
color: "#FF9500",
icon: "bxs:calendar",
},
{
id: 3,
href: "phora",
name: "Phora",
color: "#FF3B30",
icon: "bxs:conversation",
},
{
id: 5,
href: "agora",
name: "Agora",
color: "#5856D6",
icon: "bxs:store",
},
{
id: 6,
href: "wallet",
name: "Wallet",
color: "#007AFF",
icon: "bxs:wallet",
},
{
id: 7,
href: "consortium",
name: "Consortium",
color: "#FFCC00",
icon: "bxs:landmark",
},
{
id: 8,
href: "settings",
name: "Settings",
color: "#deadbeef",
icon: "bxs:wrench",
},
];
widgets = [
{
title: "Bitcoin Block",
content: literal`arx-bitcoin-block-widget`,
},
];
async loadProperties() {
const npub = await getNpub();
if (!npub) return alert("No npub?");
this.npub = npub;
this.profile = (await getUserProfile(this.npub)) as NDKUserProfile;
this.username = this.profile?.name || this.npub.substring(0, 8);
}
static override styles = [
css`
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.content-wrapper {
display: flex;
gap: 20px;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.home {
min-height: calc(100vh - var(--font-2xl));
width: 100%;
position: absolute;
right: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.home-container {
flex: 1;
background: rgba(255, 255, 255, 0.5);
border-radius: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.widgets-container {
width: 300px;
display: flex;
flex-direction: column;
gap: 20px;
}
.widget {
background: rgba(255, 255, 255, 0.5);
border-radius: 15px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
h3 {
margin: 0 0 10px 0;
font-size: 18px;
color: #1c1c1e;
}
p {
margin: 0;
font-size: 14px;
color: #333;
line-height: 1.4;
}
}
@media (max-width: 1024px) {
.content-wrapper {
flex-direction: column;
}
.widgets-container {
width: calc(100vw - 40px);
flex-direction: row;
overflow-x: auto;
padding-bottom: 10px;
}
.widget {
min-width: 250px;
}
}
@media (max-width: 768px) {
.widgets-container {
flex-direction: column;
}
.widget {
width: auto;
}
}
.welcome-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
padding: 20px;
background: rgba(255, 255, 255, 0.5);
border-radius: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.welcome-text h1 {
margin: 0;
font-size: 24px;
color: #1c1c1e;
}
.welcome-text p {
margin: 5px 0 0;
color: #666;
font-size: 14px;
}
`,
];
override connectedCallback() {
super.connectedCallback();
this.loadProperties();
}
override render() {
return html`
<div class="home">
<div class="content-wrapper">
<div class="home-container">
<div class="welcome-section">
<div class="avatar">
<arx-nostr-profile
.npub=${this.npub}
render-type="avatar"
></arx-nostr-profile>
</div>
<div class="welcome-text">
<h1>Welcome, ${this.username}</h1>
</div>
</div>
<arx-app-grid .apps=${this.apps}></arx-app-grid>
</div>
<div class="widgets-container">
${this.widgets.map(
(widget) => html`
<div class="widget">
<h3>${widget.title}</h3>
<${widget.content}></${widget.content}>
</div>
`
)}
</div>
</div>
</div>
`;
}
}

126
src/routes/Phora/Home.ts Normal file
View file

@ -0,0 +1,126 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getSigner, ndk } from "@/ndk";
import formatDateTime from "@utils/formatDateTime";
import type { NDKSubscription } from "@nostr-dev-kit/ndk";
import "@components/Breadcrumbs";
import "@components/PhoraForumCategory";
import "@components/PhoraForumTopic";
import "@components/PhoraButton";
interface ForumTopic {
id: string;
title: string;
author: string;
description: string;
created_at: string;
}
interface ForumCategory {
id: string;
name: string;
description: string;
topics: ForumTopic[];
}
@customElement("arx-phora-home")
export class PhoraForum extends LitElement {
@state()
private categories: ForumCategory[] = [];
private categoriesQuery: NDKSubscription | undefined;
static override styles = css`
:host {
display: block;
}
`;
override async connectedCallback() {
super.connectedCallback();
await this.loadCategories();
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.categoriesQuery) this.categoriesQuery.stop();
}
private async loadCategories() {
await getSigner();
this.categoriesQuery = ndk
.subscribe({
kinds: [11],
})
.on("event", (event) => {
const subject = event.tags.find(
(tag: string[]) => tag[0] === "subject"
);
const parent = event.tags.find((tag: string[]) => tag[0] === "e");
if (!subject) return;
if (parent) {
const categoryIndex = this.categories.findIndex(
(category) => category.id === parent[1]
);
if (categoryIndex === -1) return;
const updatedCategories = [...this.categories];
updatedCategories[categoryIndex].topics.push({
id: event.id,
title: subject[1],
author: event.pubkey,
created_at: formatDateTime((event.created_at || 0) * 1000),
description: event.content,
});
this.categories = updatedCategories;
return;
}
this.categories = [
...this.categories,
{
id: event.id,
name: subject[1],
description: event.content.substring(0, 100),
topics: [],
},
];
});
}
override render() {
return html`
<arx-breadcrumbs
.items=${[{ text: "Home", href: "/" }, { text: "Phora" }]}
>
</arx-breadcrumbs>
<arx-phora-button href="/phora/new-category">
New Category
</arx-phora-button>
${this.categories.map(
(category) => html`
<arx-phora-forum-category id=${category.id} title=${category.name}>
${category.topics.map(
(topic) => html`
<arx-phora-forum-topic
id=${topic.id}
title=${topic.title}
description=${topic.description}
lastPostBy=${topic.author}
lastPostTime=${topic.created_at}
>
</arx-phora-forum-topic>
`
)}
</arx-phora-forum-category>
`
)}
`;
}
}

View file

@ -0,0 +1,97 @@
import { LitElement, html, css } from "lit";
import { customElement, state } from "lit/decorators.js";
import { getSigner, ndk } from "@/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
@customElement("arx-phora-category-creator")
export class PhoraCategoryCreator extends LitElement {
@state()
private newCategory = "";
@state()
private categoryDescription = "";
static override styles = css`
:host {
display: block;
}
input,
textarea {
width: 100%;
margin-bottom: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
textarea {
min-height: 100px;
resize: vertical;
}
`;
private async doCreateCategory() {
if (this.newCategory.length < 3) {
alert("Category name must be at least 3 characters long");
return;
}
if (this.categoryDescription.length < 10) {
alert("Category description must be at least 10 characters long");
return;
}
try {
await getSigner();
const event = new NDKEvent(ndk);
event.kind = 11;
event.tags = [["subject", this.newCategory]];
event.content = this.categoryDescription;
await event.sign();
await event.publish();
this.dispatchEvent(
new CustomEvent("category-created", {
bubbles: true,
composed: true,
})
);
this.newCategory = "";
this.categoryDescription = "";
} catch (error) {
console.error("Failed to create category:", error);
alert("Failed to create category");
}
}
private handleCategoryInput(e: InputEvent) {
this.newCategory = (e.target as HTMLInputElement).value;
}
private handleDescriptionInput(e: InputEvent) {
this.categoryDescription = (e.target as HTMLTextAreaElement).value;
}
override render() {
return html`
<input
type="text"
placeholder="New Category"
.value=${this.newCategory}
@input=${this.handleCategoryInput}
/>
<textarea
placeholder="Category Description"
.value=${this.categoryDescription}
@input=${this.handleDescriptionInput}
></textarea>
<arx-phora-button @click=${this.doCreateCategory}>
Create
</arx-phora-button>
`;
}
}

150
src/routes/Phora/NewPost.ts Normal file
View file

@ -0,0 +1,150 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getSigner, ndk } from "@/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
@customElement("arx-phora-post-creator")
export class PhoraPostCreator extends LitElement {
@property({ type: String })
topicId = "";
@state()
private postContent = "";
@state()
private isCreating = false;
@state()
private error: string | null = null;
static override styles = css`
:host {
display: block;
}
.container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.topic-id {
color: #666;
font-family: monospace;
}
textarea {
width: 100%;
min-height: 200px;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 0.5rem;
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
textarea:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.error {
color: #dc2626;
font-size: 0.875rem;
}
.actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
private async doCreatePost() {
if (this.isCreating) return;
if (this.postContent.length < 10) {
this.error = "Post content must be at least 10 characters long";
return;
}
this.error = null;
this.isCreating = true;
try {
await getSigner();
const event = new NDKEvent(ndk);
event.kind = 1111;
event.tags = [["e", this.topicId]];
event.content = this.postContent;
await event.sign();
await event.publish();
this.dispatchEvent(
new CustomEvent("post-created", {
bubbles: true,
composed: true,
detail: {
postId: event.id,
topicId: this.topicId,
},
})
);
// Reset form
this.postContent = "";
} catch (error) {
console.error("Failed to create post:", error);
this.error = "Failed to create post. Please try again.";
} finally {
this.isCreating = false;
}
}
private handleContentInput(e: InputEvent) {
this.postContent = (e.target as HTMLTextAreaElement).value;
if (this.error && this.postContent.length >= 10) {
this.error = null;
}
}
override render() {
return html`
<div class="container">
<div class="topic-id">Topic ID: ${this.topicId}</div>
<textarea
placeholder="Post. You can use Markdown here."
.value=${this.postContent}
@input=${this.handleContentInput}
?disabled=${this.isCreating}
></textarea>
${this.error ? html` <div class="error">${this.error}</div> ` : null}
<div class="actions">
<arx-phora-button
@click=${() => this.dispatchEvent(new CustomEvent("cancel"))}
?disabled=${this.isCreating}
>
Cancel
</arx-phora-button>
<arx-phora-button
@click=${this.doCreatePost}
?disabled=${this.isCreating}
>
${this.isCreating ? "Creating..." : "Create"}
</arx-phora-button>
</div>
</div>
`;
}
}

View file

@ -0,0 +1,143 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getSigner, ndk } from "@/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
@customElement("arx-phora-topic-creator")
export class PhoraTopicCreator extends LitElement {
@property({ type: String })
categoryId = "";
@state()
private newTopic = "";
@state()
private topicContent = "";
@state()
private isCreating = false;
static override styles = css`
:host {
display: block;
}
input,
textarea {
width: 100%;
margin-bottom: 1rem;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}
textarea {
min-height: 200px;
resize: vertical;
}
.error {
color: red;
margin-bottom: 1rem;
}
.category-id {
color: #666;
margin-bottom: 1rem;
font-family: monospace;
}
`;
private async doCreateTopic() {
if (this.isCreating) return;
if (this.newTopic.length < 3) {
alert("Topic title must be at least 3 characters long");
return;
}
if (this.topicContent.length < 10) {
alert("Topic content must be at least 10 characters long");
return;
}
this.isCreating = true;
try {
await getSigner();
const event = new NDKEvent(ndk);
event.kind = 11;
event.tags = [
["subject", this.newTopic],
["e", this.categoryId],
];
event.content = this.topicContent;
await event.sign();
await event.publish();
this.dispatchEvent(
new CustomEvent("topic-created", {
bubbles: true,
composed: true,
detail: {
topicId: event.id,
categoryId: this.categoryId,
},
})
);
this.newTopic = "";
this.topicContent = "";
} catch (error) {
console.error("Failed to create topic:", error);
alert("Failed to create topic");
} finally {
this.isCreating = false;
}
}
private handleTopicInput(e: InputEvent) {
this.newTopic = (e.target as HTMLInputElement).value;
}
private handleContentInput(e: InputEvent) {
this.topicContent = (e.target as HTMLTextAreaElement).value;
}
override render() {
return html`
<div class="category-id">Category ID: ${this.categoryId}</div>
<input
type="text"
placeholder="New Topic"
.value=${this.newTopic}
@input=${this.handleTopicInput}
?disabled=${this.isCreating}
/>
<textarea
placeholder="Topic. You can use Markdown here."
.value=${this.topicContent}
@input=${this.handleContentInput}
?disabled=${this.isCreating}
></textarea>
<div class="button-group">
<arx-phora-button
@click=${() => this.dispatchEvent(new CustomEvent("cancel"))}
?disabled=${this.isCreating}
>
Cancel
</arx-phora-button>
<arx-phora-button
@click=${this.doCreateTopic}
?disabled=${this.isCreating}
>
${this.isCreating ? "Creating..." : "Create"}
</arx-phora-button>
</div>
`;
}
}

View file

@ -0,0 +1,158 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { getSigner, ndk } from "@/ndk";
import type { NDKSubscription } from "@nostr-dev-kit/ndk";
import "@components/Breadcrumbs";
import "@components/ForumPost";
import "@components/PhoraButton";
interface ForumPost {
id: string;
npub: string;
date: Date;
content: string;
}
@customElement("arx-phora-topic-view")
export class PhoraTopicView extends LitElement {
@property({ type: String })
topicId = "";
@state()
override title = "";
@state()
private posts: ForumPost[] = [];
private subscription: NDKSubscription | undefined;
static override styles = css`
:host {
display: block;
}
.topic {
max-width: 1200px;
padding: 1em;
background: rgba(255, 255, 255, 0.8);
border-radius: 0.5rem;
display: grid;
gap: 1rem;
grid-template-columns: [column-1] auto [column-2] 1fr;
}
.header {
background: var(--primary);
color: oklch(100% 0 0);
padding: 1rem 1.5rem;
border-radius: 0.5rem 0.5rem 0 0;
font-weight: 500;
display: flex;
justify-content: space-between;
align-items: center;
}
.posts-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.actions {
margin-top: 1rem;
}
`;
override async connectedCallback() {
super.connectedCallback();
await this.loadTopic();
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.subscription) {
this.subscription.stop();
}
}
private async loadTopic() {
try {
await getSigner();
const event = await ndk.fetchEvent(this.topicId);
if (!event) {
throw new Error("Could not load topic");
}
this.title = event.tags.find((tag) => tag[0] === "subject")?.[1] || "";
this.posts = [
{
id: event.id,
npub: event.pubkey,
date: new Date((event.created_at || 0) * 1000),
content: event.content,
},
];
// Subscribe to new posts
this.subscription = ndk
.subscribe({
kinds: [1111],
"#e": [this.topicId],
})
.on("event", (event) => {
this.posts = [
...this.posts,
{
id: event.id,
npub: event.pubkey,
date: new Date((event.created_at || 0) * 1000),
content: event.content,
},
];
});
} catch (error) {
console.error("Failed to load topic:", error);
alert("Could not load topic");
}
}
override render() {
const breadcrumbItems = [
{ text: "Home", href: "/" },
{ text: "Phora", href: "/phora" },
{ text: this.title },
];
return html`
<arx-breadcrumbs .items=${breadcrumbItems}></arx-breadcrumbs>
<div class="header">
<span>${this.title}</span>
</div>
<div class="posts-container">
${this.posts.map(
(post) => html`
<arx-forum-post
class="topic"
id=${post.id}
.topicId=${this.topicId}
.npub=${post.npub}
.date=${post.date}
.content=${post.content}
></arx-forum-post>
`
)}
</div>
<div class="actions">
<arx-phora-button href="/phora/new-post/${this.topicId}">
New Post
</arx-phora-button>
</div>
`;
}
}

411
src/routes/Profile.ts Normal file
View file

@ -0,0 +1,411 @@
import { LitElement, html, css } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { when } from "lit/directives/when.js";
import { styleMap } from "lit/directives/style-map.js";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
import { getUserProfile } from "../ndk";
@customElement("arx-profile-route")
export class NostrProfile extends LitElement {
@property({ type: String })
npub = "";
@state()
private profile: NDKUserProfile | undefined;
@state()
private error: string | null = null;
static override styles = css`
:host {
display: block;
}
.banner-container {
position: relative;
height: 20rem;
overflow: hidden;
}
.banner-image {
position: absolute;
inset: 0;
transform: scale(1);
transition: transform 700ms;
}
.banner-image:hover {
transform: scale(1.05);
}
.banner-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.banner-overlay {
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
transparent,
rgba(0, 0, 0, 0.2),
rgba(0, 0, 0, 0.4)
);
}
.profile-container {
max-width: 64rem;
margin: 0 auto;
padding: 0 1rem;
position: relative;
z-index: 10;
}
.profile-container.with-banner {
margin-top: -8rem;
}
.profile-container.no-banner {
margin-top: 4rem;
}
.profile-card {
background-color: rgba(255, 255, 255, 0.95);
border-radius: 0.75rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
backdrop-filter: blur(8px);
}
.profile-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 768px) {
.profile-content {
flex-direction: row;
align-items: flex-start;
}
}
.profile-image-container {
position: relative;
}
.profile-image {
width: 10rem;
height: 10rem;
border-radius: 50%;
object-fit: cover;
border: 4px solid white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 300ms;
}
.profile-image:hover {
transform: scale(1.05);
}
.profile-image-placeholder {
width: 10rem;
height: 10rem;
border-radius: 50%;
background: linear-gradient(to bottom right, #e5e7eb, #d1d5db);
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-icon {
width: 5rem;
height: 5rem;
color: #9ca3af;
}
.profile-info {
flex: 1;
}
.profile-header {
display: flex;
flex-direction: column;
justify-content: space-between;
}
@media (min-width: 768px) {
.profile-header {
flex-direction: row;
align-items: center;
}
}
.display-name {
font-size: 1.875rem;
font-weight: bold;
color: #111827;
display: flex;
align-items: center;
gap: 0.5rem;
}
.verified-icon {
color: #3b82f6;
}
.nip05 {
color: #4b5563;
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.25rem;
}
.action-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
@media (min-width: 768px) {
.action-buttons {
margin-top: 0;
}
}
.follow-button {
padding: 0.5rem 1.5rem;
border-radius: 9999px;
font-weight: 500;
transition: all 300ms;
background-color: #3b82f6;
color: white;
}
.follow-button:hover {
background-color: #2563eb;
}
.follow-button.following {
background-color: #e5e7eb;
color: #1f2937;
}
.follow-button.following:hover {
background-color: #d1d5db;
}
.copy-button {
padding: 0.5rem;
border-radius: 9999px;
background-color: #f3f4f6;
transition: background-color 300ms;
}
.copy-button:hover {
background-color: #e5e7eb;
}
.copy-icon {
width: 1.25rem;
height: 1.25rem;
}
.links-section {
display: grid;
grid-auto-columns: minmax(300px, 1fr);
gap: 1.5rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
}
.link-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
border-radius: 0.5rem;
background-color: #f9fafb;
transition: background-color 300ms;
}
.link-item:hover {
background-color: #f3f4f6;
}
.link-icon {
width: 1.5rem;
height: 1.5rem;
}
.link-icon.website {
color: #3b82f6;
}
.link-icon.lightning {
color: #eab308;
}
.bio {
white-space: pre-line;
font-size: 0.9rem;
color: #4b5563;
margin: 1rem 0;
padding-top: 1rem;
line-height: 1.5;
}
.animate-gradient {
background-size: 200% 200%;
animation: gradient 15s ease infinite;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
`;
protected override async firstUpdated() {
try {
this.profile = await getUserProfile(this.npub);
} catch (err) {
this.error = "Failed to load profile";
console.error(err);
}
}
private get displayName() {
if (!this.profile) return this.npub;
return (
this.profile.displayName || this.profile.name || this.npub.substring(0, 8)
);
}
override render() {
if (this.error) {
return html`<arx-error-view .error=${this.error}></arx-error-view>`;
}
if (!this.profile) {
return html`<arx-loading-view></arx-loading-view>`;
}
return html`
${when(
this.profile.banner,
() => html`
<div class="banner-container">
<div class="banner-image">
<img src=${this.profile!.banner} alt="Banner" />
</div>
<div class="banner-overlay"></div>
</div>
`
)}
<div
class=${this.profile.banner
? "profile-container with-banner"
: "profile-container no-banner"}
>
<div class="profile-card">
<div class="profile-content">
<div class="profile-image-container">
${when(
this.profile.image,
() =>
html`<img
src=${this.profile!.image}
alt="Profile"
class="profile-image"
/>`,
() => html`
<div class="profile-image-placeholder">
<svg-icon
icon="mdi:account"
class="placeholder-icon"
></svg-icon>
</div>
`
)}
</div>
<div class="profile-info">
<div class="profile-header">
<div>
<h1 class="display-name">
${this.displayName}
${when(
this.profile.verified,
() => html`
<span class="verified-icon">
<svg-icon icon="mdi:check-decagram"></svg-icon>
</span>
`
)}
</h1>
${when(
this.profile.nip05,
() => html`
<p class="nip05">
<svg-icon icon="mdi:at"></svg-icon>
${this.profile!.nip05}
</p>
`
)}
</div>
</div>
${when(
this.profile.about,
() => html` <p class="bio">${this.profile!.about}</p> `
)}
</div>
</div>
<div class="links-section">
${when(
this.profile.website,
() => html`
<a
href=${this.profile!.website}
target="_blank"
class="link-item"
>
<svg-icon icon="mdi:web" class="link-icon website"></svg-icon>
<span>${this.profile!.website}</span>
</a>
`
)}
${when(
this.profile.lud16,
() => html`
<a href="lightning:${this.profile!.lud16}" class="link-item">
<svg-icon
icon="mdi:lightning-bolt"
class="link-icon lightning"
></svg-icon>
<span>${this.profile!.lud16}</span>
</a>
`
)}
</div>
</div>
</div>
`;
}
}

297
src/routes/router.ts Normal file
View file

@ -0,0 +1,297 @@
import "@routes/404Page";
import "@routes/Home";
import "@routes/Profile";
import "@routes/Phora/Home";
import "@routes/Phora/NewCategory";
import "@routes/Phora/NewTopic";
import "@routes/Phora/TopicView";
import "@routes/Phora/NewPost";
import "@components/InitialSetup";
import { css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { html, literal, type StaticValue } from "lit/static-html.js";
import { spread } from "@open-wc/lit-helpers";
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: "phora",
params: {},
component: literal`arx-phora-home`,
},
{
pattern: "phora/new-category",
params: {},
component: literal`arx-phora-category-creator`,
},
{
pattern: "phora/new-topic/:categoryId",
params: {},
component: literal`arx-phora-topic-creator`,
},
{
pattern: "phora/topics/:topicId",
params: {},
component: literal`arx-phora-topic-view`,
},
{
pattern: "phora/new-post/:topicId",
params: {},
component: literal`arx-phora-post-creator`,
},
{
pattern: "404",
params: {},
component: literal`arx-404-page`,
},
];
@state()
private history: string[] = [];
@state()
private currentIndex = -1;
@property()
private 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;
}
input {
width: 100%;
}
button {
background: var(--primary);
color: white;
border: 1px solid var(--border);
margin: 1rem 0;
width: 100%;
}
.window {
max-width: 1200px;
overflow: auto;
height: 100%;
position: relative;
left: 50%;
transform: translateX(-50%);
}
`;
constructor() {
super();
this.initializeRouter();
}
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) => this.navigate(e.detail)}
@go-back=${this.goBack}
@go-forward=${this.goForward}
title="Eve"
></arx-header>
<div class="window">
<div class="window-content">
<${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>
`;
}
}

443
src/style.scss Normal file
View file

@ -0,0 +1,443 @@
:root {
--primary: oklch(25% 0.09 210);
--secondary: oklch(from var(--primary) calc(l + 0.05) calc(c * 1.1) h);
--accent: oklch(from var(--primary) calc(l + 0.35) calc(c + 0.15) h);
--light: oklch(from var(--primary) 96% calc(c * 0.3) h);
--dark: oklch(from var(--primary) 20% calc(c * 0.3) h);
--border: oklch(from var(--primary) 80% calc(c * 0.5) h);
--success: oklch(from var(--primary) 70% calc(c + 0.2) 142);
--warning: oklch(from var(--primary) 85% calc(c + 0.2) 85);
--error: oklch(from var(--primary) 65% calc(c + 0.2) 25);
--shadow-color: oklch(from var(--primary) 20% calc(c * 0.3) h);
--shadow-sm: 0 1px 2px rgba(var(--shadow-color), 0.1);
--shadow-md: 0 2px 4px oklch(from var(--shadow-color) l c h / 0.1),
0 1px 2px oklch(from var(--shadow-color) l c h / 0.05);
--shadow-lg: 0 4px 6px oklch(from var(--shadow-color) l c h / 0.1),
0 2px 4px oklch(from var(--shadow-color) l c h / 0.06);
--shadow-xl: 0 10px 15px oklch(from var(--shadow-color) l c h / 0.1),
0 4px 6px oklch(from var(--shadow-color) l c h / 0.08);
--font-xs: clamp(0.65rem, 0.7vw, 0.8rem);
--font-sm: clamp(0.8rem, 0.9vw, 0.9rem);
--font-base: clamp(1rem, 1.1vw, 1.125rem);
--font-md: clamp(1.333rem, 1.5vw, 1.5rem);
--font-lg: clamp(1.777rem, 2vw, 2rem);
--font-xl: clamp(2.369rem, 2.7vw, 2.666rem);
--font-2xl: clamp(3.157rem, 3.6vw, 3.555rem);
--font-3xl: clamp(4.209rem, 4.8vw, 4.74rem);
--space-xs: clamp(0.5rem, 0.75vw, 0.75rem);
--space-sm: clamp(1rem, 1.5vw, 1.5rem);
--space-md: clamp(2rem, 3vw, 3rem);
--space-lg: clamp(3rem, 4.5vw, 4.5rem);
--header-height: var(--font-2xl);
}
.window {
display: grid;
gap: 0;
grid-template-rows: var(--header-height) 1fr;
height: 100vh;
width: 100vw;
overflow: hidden;
}
header {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.6);
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
z-index: 999999;
margin-bottom: var(--font-2xl);
background: var(--primary);
height: var(--font-2xl);
font-size: var(--header-height);
transition: all 0.3s ease;
display: flex;
align-items: center;
padding: 0 var(--space-md);
.search-container {
flex: 1;
position: relative;
input {
background: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 16px;
color: var(--light);
width: 100%;
text-align: center;
cursor: text;
font-size: 0.9rem;
width: 100%;
padding: var(--space-xs);
text-align: center;
&:not(:focus)::placeholder {
color: var(--light);
opacity: 1;
}
&:focus {
background: rgba(0, 0, 0, 0.2);
}
}
}
.nav-buttons {
display: flex;
gap: var(--space-xs);
padding-right: var(--space-xs);
button {
text-decoration: none;
color: var(--light);
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
padding: var(--space-xs);
border-radius: 100%;
font-size: var(--font-md);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
&.disabled {
pointer-events: none;
opacity: 0.5;
}
}
}
}
::selection {
background: oklch(from var(--accent) l c h / 0.2);
color: var(--accent);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: "Inter var", system-ui, -apple-system, sans-serif;
font-size: var(--font-base);
line-height: 1.7;
font-feature-settings: "liga" 1, "kern" 1, "calt" 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
background: oklch(from var(--primary) 97% calc(c * 0.25) h);
color: oklch(from var(--primary) 20% calc(c * 0.1) h);
letter-spacing: -0.01em;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: "Inter var", system-ui, sans-serif;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.03em;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
h1 {
font-size: var(--font-2xl);
font-weight: 800;
letter-spacing: -0.04em;
}
h2 {
font-size: var(--font-xl);
font-weight: 700;
}
h3 {
font-size: var(--font-lg);
font-weight: 600;
}
section {
max-width: 1200px;
margin-inline: auto;
padding-inline: 1rem;
display: flex;
justify-content: center;
place-items: center;
align-items: center;
}
ul,
ol {
margin: var(--space-sm) 0;
padding-left: var(--space-md);
& li {
margin-bottom: var(--space-xs);
position: relative;
}
& li::marker {
color: var(--accent);
}
}
code {
font-family: "JetBrains Mono", "SF Mono", monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
border-radius: 4px;
background: oklch(from var(--primary) 97% calc(c * 0.15) h);
border: 1px solid oklch(from var(--primary) 90% calc(c * 0.2) h);
}
blockquote {
position: relative;
padding: var(--space-md);
border-radius: 1rem;
background: linear-gradient(135deg,
oklch(from var(--primary) calc(l + 0.02) calc(c * 0.8) h),
oklch(from var(--secondary) calc(l + 0.02) calc(c * 0.8) h));
backdrop-filter: blur(10px);
box-shadow: var(--shadow-lg), inset 0 2px 4px oklch(from var(--primary) 100% 0 h / 0.1);
color: var(--light);
& p {
font-size: var(--font-md);
font-weight: 500;
font-style: italic;
line-height: 1.6;
margin-bottom: var(--space-sm);
color: var(--light);
}
&::before {
position: absolute;
font-family: Georgia, serif;
opacity: 0.15;
content: "";
bottom: -1rem;
right: 1rem;
font-size: 5rem;
color: var(--light);
}
}
small {
font-size: var(--font-xs);
line-height: var(--leading-normal);
}
p {
margin: 0 auto;
margin-bottom: var(--space-md);
max-width: 80ch;
opacity: 0.9;
}
a {
color: var(--accent);
text-decoration: none;
background-image: linear-gradient(transparent calc(100% - 2px),
var(--accent) 2px);
background-size: 0% 100%;
background-repeat: no-repeat;
transition: background-size 0.3s ease-in-out;
&:hover {
background-size: 100% 100%;
}
}
hr {
height: 5px;
border: 0;
margin: var(--space-md);
background: linear-gradient(90deg,
transparent,
var(--primary) 20%,
var(--accent) 50%,
var(--primary) 80%,
transparent);
position: relative;
&::after {
content: "";
position: absolute;
width: 100%;
height: 1px;
background: inherit;
top: 2px;
filter: blur(2px);
opacity: 0.7;
}
}
.blurfade-enter-active {
transition: all 0.5s ease-out;
}
.blurfade-leave-active {
transition: all 0.25s cubic-bezier(1, 0.5, 0.8, 1);
}
.blurfade-enter-from,
.blurfade-leave-to {
overflow: hidden;
position: fixed;
width: 100vw;
height: 100vh;
left: 0;
right: 0;
top: 0;
bottom: 0;
transform: translate(0, 0) !important;
transform-style: preserve-3d;
z-index: 9999;
opacity: 0;
backdrop-filter: blur(1000px);
filter: blur(1000px);
}
.w-full {
width: 100%;
}
input {
font-family: "Inter var", system-ui, -apple-system, sans-serif;
font-size: var(--font-base);
line-height: 1.7;
color: oklch(from var(--primary) 20% calc(c * 0.1) h);
background: oklch(from var(--primary) 97% calc(c * 0.25) h);
border: 1px solid oklch(from var(--primary) 90% calc(c * 0.2) h);
border-radius: 6px;
padding: var(--space-xs) var(--space-sm);
outline: none;
transition: all 0.2s ease-in-out;
&:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px oklch(from var(--accent) l c h / 0.2);
}
&:hover {
border-color: oklch(from var(--primary) 80% calc(c * 0.3) h);
}
&::placeholder {
color: oklch(from var(--primary) 60% calc(c * 0.2) h);
opacity: 0.7;
}
&[type="submit"],
&[type="button"] {
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
color: var(--light);
border: none;
font-weight: 600;
cursor: pointer;
&:hover {
background: linear-gradient(135deg, var(--secondary) 0%, var(--accent) 100%);
}
&:active {
transform: scale(0.98);
}
}
&[type="checkbox"],
&[type="radio"] {
width: 1.2em;
height: 1.2em;
accent-color: var(--accent);
margin-right: var(--space-xs);
}
&[type="range"] {
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
&::-webkit-slider-runnable-track {
height: 6px;
background: oklch(from var(--primary) 90% calc(c * 0.2) h);
border-radius: 3px;
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--accent);
border-radius: 50%;
margin-top: -5px;
}
&::-moz-range-track {
height: 6px;
background: oklch(from var(--primary) 90% calc(c * 0.2) h);
border-radius: 3px;
}
&::-moz-range-thumb {
width: 16px;
height: 16px;
background: var(--accent);
border-radius: 50%;
}
}
}
textarea {
font-family: "Inter var", system-ui, -apple-system, sans-serif;
font-size: var(--font-base);
line-height: 1.7;
color: oklch(from var(--primary) 20% calc(c * 0.1) h);
background: oklch(from var(--primary) 97% calc(c * 0.25) h);
border: 1px solid oklch(from var(--primary) 90% calc(c * 0.2) h);
border-radius: 6px;
padding: var(--space-xs) var(--space-sm);
outline: none;
transition: all 0.2s ease-in-out;
resize: vertical;
min-height: 120px;
&:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px oklch(from var(--accent) l c h / 0.2);
}
&:hover {
border-color: oklch(from var(--primary) 80% calc(c * 0.3) h);
}
&::placeholder {
color: oklch(from var(--primary) 60% calc(c * 0.2) h);
opacity: 0.7;
}
}

4
src/utils/firstLine.ts Normal file
View file

@ -0,0 +1,4 @@
export default function firstLine(str: string | undefined): string {
if (!str) return '';
return str.split('\n')[0];
}

View file

@ -0,0 +1,12 @@
export default function formatDateTime(date: Date | string | number): string {
const properDate = new Date(date);
return properDate.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'long',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}

View file

@ -0,0 +1,26 @@
const MEMPOOL_API = 'https://mempool.space/api/blocks/tip/height';
export interface LastBlockHeight {
height: number;
error?: string;
}
export async function getLastBlockHeight(): Promise<LastBlockHeight> {
try {
const response = await fetch(MEMPOOL_API);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.text();
return {
height: Number.parseInt(data, 10),
};
} catch (e) {
return {
height: 0,
error: e instanceof Error ? e.message : 'Failed to fetch block height',
};
}
}

11
src/utils/profileUtils.ts Normal file
View file

@ -0,0 +1,11 @@
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
interface ProfileProps {
profile: NDKUserProfile;
npub: string;
}
export const getProfile = ({ profile, npub }: ProfileProps) => ({
displayName: profile.displayName || profile.name || npub.substring(0, 8),
profileUrl: `/profile/${npub}`,
});

47
src/utils/satsComma.ts Normal file
View file

@ -0,0 +1,47 @@
export enum SatcommaFormat {
BTC = 'BTC',
SATS = 'SATS',
}
const SATS_PER_BTC = 100_000_000;
function formatSatsGroup(sats: number): string {
return (
sats
.toString()
.split('')
.reverse()
.join('')
.match(/.{1,3}/g)
?.reverse()
.map(group => group.split('').reverse().join(''))
.join(' ') || '0'
);
}
function formatBtcGroup(sats: number): string {
const btc = sats / SATS_PER_BTC;
const [whole, decimal = ''] = btc.toFixed(8).split('.');
const paddedDecimal = decimal.padEnd(8, '0');
return `${whole}.${paddedDecimal.slice(0, 2)} ${paddedDecimal
.slice(2)
.match(/.{1,3}/g)
?.join(' ')} BTC`.trim();
}
export default function satsComma(
sats: number,
format: SatcommaFormat = SatcommaFormat.SATS,
): string {
if (!Number.isFinite(sats)) {
throw new Error('Invalid input: sats must be a finite number');
}
if (sats < 0) {
return `-${satsComma(Math.abs(sats), format)}`;
}
return format === SatcommaFormat.SATS
? formatSatsGroup(sats)
: formatBtcGroup(sats);
}