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>
`;
}
}