🔄 Forum overhaul: Phora → Arbor (#1)

🔧 Replace legacy system with NIP-BB implementation
🚀 Enhance forum user experience with improved navigation and interactions
🎨 Redesign UI for forum
🏷️ Rebrand from "Phora" to "Arbor"
This commit is contained in:
Danny Morabito 2025-03-18 15:07:48 +00:00
parent 2a1447cd77
commit 5afeb4d01a
19 changed files with 1233 additions and 610 deletions

View file

@ -51,7 +51,7 @@ EVE provides building blocks for digital sovereignty, which we call Arxlets:
### Core Arxlets ### Core Arxlets
- [x] **Phora**: Threaded discussions and knowledge sharing - [x] **Arbor**: Threaded discussions and knowledge sharing
- [ ] **Nexus**: Central community hub - [ ] **Nexus**: Central community hub
- [ ] **Whisper**: One-to-one and group messaging - [ ] **Whisper**: One-to-one and group messaging
- [ ] **Vault**: Secure file storage and sharing - [ ] **Vault**: Secure file storage and sharing

View file

@ -73,7 +73,7 @@ This security policy applies to the following official EVE repositories and comp
- Main EVE application: https://git.arx-ccn.com/Arx/Eve - Main EVE application: https://git.arx-ccn.com/Arx/Eve
- EVE Relay: https://git.arx-ccn.com/Arx/Eve-Relay - EVE Relay: https://git.arx-ccn.com/Arx/Eve-Relay
- All published Arxlets (Phora, Nexus, etc.) - All published Arxlets (Arbor, Nexus, etc.)
### Out of Scope ### Out of Scope

View file

@ -3,8 +3,8 @@ import { customElement, property } from 'lit/decorators.js';
import '@components/EveLink'; import '@components/EveLink';
@customElement('arx-phora-button') @customElement('arx-arbor-button')
export class PhoraButton extends LitElement { export class ArborButton extends LitElement {
@property({ type: String }) href = ''; @property({ type: String }) href = '';
@property({ type: String }) target = ''; @property({ type: String }) target = '';
@property({ type: String }) rel = ''; @property({ type: String }) rel = '';
@ -18,13 +18,16 @@ export class PhoraButton extends LitElement {
static override styles = [ static override styles = [
css` css`
arx-eve-link::part(link) { arx-eve-link::part(link) {
color: white;
text-decoration: none;
}
arx-eve-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: var(--accent); background: var(--accent);
color: white;
border: none; border: none;
text-decoration: none;
padding: 0.75rem 0.75rem; padding: 0.75rem 0.75rem;
border-radius: 0.25rem; border-radius: 0.25rem;
text-decoration: none; text-decoration: none;

View file

@ -1,8 +1,8 @@
import { LitElement, css, html } from 'lit'; import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
@customElement('arx-phora-forum-category') @customElement('arx-arbor-forum-category')
export class PhoraForumCategory extends LitElement { export class ArborForumCategory extends LitElement {
@property({ type: String }) override title = ''; @property({ type: String }) override title = '';
@property({ type: String }) override id = ''; @property({ type: String }) override id = '';
@ -33,7 +33,9 @@ export class PhoraForumCategory extends LitElement {
<div class="forum-category"> <div class="forum-category">
<div class="category-header"> <div class="category-header">
<span>${this.title}</span> <span>${this.title}</span>
<arx-phora-button href="/phora/new-topic/${this.id}">New Topic</phora-button> <arx-arbor-button href="/arbor/new-topic/${this.id}">
New Topic
</arx-arbor-button>
</div> </div>
<slot> <slot>
<div style="padding: 1rem 1.5rem">No topics...</div> <div style="padding: 1rem 1.5rem">No topics...</div>

View file

@ -0,0 +1,341 @@
import formatDateTime from '@utils/formatDateTime';
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
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 = '';
@property({ type: Boolean }) isHighlighted = false;
static override styles = [
css`
.post {
display: flex;
flex-direction: row;
border-radius: 16px;
background: #ffffff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04),
0 1px 3px rgba(0, 0, 0, 0.03);
margin-block-end: 0.75rem;
overflow: hidden;
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
isolation: isolate;
will-change: transform;
&:hover {
transform: translateY(2px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.07),
0 2px 4px rgba(0, 0, 0, 0.04);
}
}
.post--highlighted {
position: relative;
border-inline-start: none;
&::before {
content: "";
position: absolute;
inset-inline-start: 0;
top: 0;
bottom: 0;
width: 4px;
background: #4361ee;
border-radius: 4px 0 0 4px;
}
}
.post__sidebar {
padding: clamp(1rem, 4vw, 1.5rem);
border-right: 1px solid rgba(0, 0, 0, 0.06);
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
background: rgba(0, 0, 0, 0.01);
}
.post__main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.post__header {
display: flex;
align-items: center;
padding: 1rem clamp(1rem, 4vw, 1.5rem);
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.01);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.post__time {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: rgba(0, 0, 0, 0.6);
font-weight: 500;
& iconify-icon {
color: rgba(0, 0, 0, 0.4);
}
}
.post__permalink {
margin-inline-start: auto;
color: #4361ee;
border: none;
background: transparent;
cursor: pointer;
border-radius: 50%;
width: 2.25rem;
height: 2.25rem;
display: grid;
place-items: center;
transition: all 0.2s ease;
&:hover {
background: rgba(67, 97, 238, 0.08);
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid #4361ee;
outline-offset: 2px;
}
}
.post__content {
padding: clamp(1.25rem, 5vw, 1.75rem);
line-height: 1.7;
color: rgba(0, 0, 0, 0.87);
overflow-wrap: break-word;
flex: 1;
font-size: clamp(0.95rem, 2vw, 1rem);
letter-spacing: 0.01em;
}
.post__actions {
display: flex;
padding: 0.75rem clamp(0.75rem, 3vw, 1.25rem);
border-top: 1px solid rgba(0, 0, 0, 0.06);
gap: clamp(0.5rem, 2vw, 0.75rem);
background: rgba(0, 0, 0, 0.01);
}
.action-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 10px;
background: transparent;
border: none;
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
color: rgba(0, 0, 0, 0.6);
cursor: pointer;
transition: all 0.2s ease;
& iconify-icon {
font-size: 1.25rem;
}
&:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.04);
color: rgba(0, 0, 0, 0.87);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid rgba(0, 0, 0, 0.2);
outline-offset: 1px;
}
&:active:not(:disabled) {
transform: translateY(1px) scale(0.98);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.action-button--primary {
color: #4361ee;
&:hover:not(:disabled) {
background: rgba(67, 97, 238, 0.08);
}
&:focus-visible {
outline-color: #4361ee;
}
}
@media (max-width: 768px) {
.post {
flex-direction: column;
margin-inline: -1rem;
border-radius: 0;
margin-block-end: 1.5rem;
}
.post__sidebar {
border-right: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
padding: 1rem;
flex-direction: row;
justify-content: flex-start;
gap: 1rem;
}
.action-button {
padding: 0.625rem;
justify-content: center;
flex: 1;
border-radius: 10px;
& span {
display: none;
}
}
}
@media (max-width: 480px) {
.post__actions {
justify-content: space-between;
padding: 0.5rem 0.75rem;
}
.post__content {
padding: 1rem;
}
}
`,
];
private _handleReply() {
alert('Replying is not yet implemented');
this.dispatchEvent(
new CustomEvent('reply', {
detail: { postId: this.id, npub: this.npub },
bubbles: true,
composed: true,
}),
);
}
private _handleZap() {
alert('Zapping is not yet implemented');
this.dispatchEvent(
new CustomEvent('zap', {
detail: { postId: this.id, npub: this.npub },
bubbles: true,
composed: true,
}),
);
}
private _handleDownzap() {
alert('Downzapping is not yet implemented');
this.dispatchEvent(
new CustomEvent('downzap', {
detail: { postId: this.id, npub: this.npub },
bubbles: true,
composed: true,
}),
);
}
private _copyPermalink() {
const permalink = `eve://phora/topics/${this.topicId}#post-${this.id}`;
navigator.clipboard.writeText(permalink);
}
override render() {
const permalink = `eve://phora/topics/${this.topicId}#post-${this.id}`;
const postClasses = {
post: true,
'post--highlighted': this.isHighlighted,
};
return html`
<div class=${classMap(postClasses)} id="post-${this.id}">
<div class="post__sidebar">
<arx-nostr-profile
.npub=${this.npub}
renderType="large"
></arx-nostr-profile>
</div>
<div class="post__main">
<div class="post__header">
<div class="post__time">
<iconify-icon icon="mdi:clock-outline"></iconify-icon>
${formatDateTime(this.date)}
</div>
<button
class="post__permalink"
title="Copy permalink"
@click=${this._copyPermalink}
>
<iconify-icon icon="mdi:link-variant"></iconify-icon>
</button>
</div>
<div class="post__content">
<arx-markdown-content
.content=${this.content || 'No content available'}
></arx-markdown-content>
</div>
<div class="post__actions">
<button
class="action-button action-button--primary"
@click=${this._handleReply}
disabled
>
<iconify-icon icon="mdi:reply"></iconify-icon>
<span>Reply</span>
</button>
<a href=${permalink} class="action-button">
<iconify-icon icon="mdi:link-variant"></iconify-icon>
<span>Permalink</span>
</a>
<button class="action-button" @click=${this._handleZap} disabled>
<iconify-icon icon="mdi:lightning-bolt"></iconify-icon>
<span>Zap</span>
</button>
<button
class="action-button"
@click=${this._handleDownzap}
disabled
>
<iconify-icon icon="mdi:lightning-bolt-outline"></iconify-icon>
<span>Downzap</span>
</button>
</div>
</div>
</div>
`;
}
}

View file

@ -2,9 +2,10 @@ import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import '@components/EveLink'; import '@components/EveLink';
import formatDateTime from '@/utils/formatDateTime';
@customElement('arx-phora-forum-topic') @customElement('arx-arbor-forum-topic')
export class PhoraForumTopic extends LitElement { export class ArborForumTopic extends LitElement {
static override styles = [ static override styles = [
css` css`
.topic { .topic {
@ -113,19 +114,6 @@ export class PhoraForumTopic extends LitElement {
border-left: 2px solid var(--border); 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 { .last-post-section {
background: oklch(from var(--primary) 98% calc(c * 0.2) h); background: oklch(from var(--primary) 98% calc(c * 0.2) h);
padding: 0.875rem; padding: 0.875rem;
@ -168,7 +156,6 @@ export class PhoraForumTopic extends LitElement {
@property({ type: String }) override id = ''; @property({ type: String }) override id = '';
@property({ type: String }) override title = ''; @property({ type: String }) override title = '';
@property({ type: String }) description = ''; @property({ type: String }) description = '';
@property({ type: Number }) posts = 0;
@property({ type: String }) lastPostBy = ''; @property({ type: String }) lastPostBy = '';
@property({ type: String }) lastPostTime = ''; @property({ type: String }) lastPostTime = '';
@property({ type: Boolean }) isNew = false; @property({ type: Boolean }) isNew = false;
@ -188,7 +175,7 @@ export class PhoraForumTopic extends LitElement {
<div class="topic-details"> <div class="topic-details">
<arx-eve-link <arx-eve-link
class="${this.status}" class="${this.status}"
href="/phora/topics/${this.id}" href="/arbor/topics/${this.id}"
> >
${this.title} ${this.title}
</arx-eve-link> </arx-eve-link>
@ -196,14 +183,11 @@ export class PhoraForumTopic extends LitElement {
</div> </div>
</div> </div>
<div class="stats-container"> <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 class="last-post-section">
<div>Latest activity</div>
<arx-nostr-profile npub="${this.lastPostBy}"></arx-nostr-profile> <arx-nostr-profile npub="${this.lastPostBy}"></arx-nostr-profile>
<div class="last-post-info">${this.lastPostTime}</div> <div class="last-post-info">
${formatDateTime(this.lastPostTime)}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,7 @@
import { LitElement, css, html } from 'lit'; import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import './BreadcrumbsItem'; import './BreadcrumbsItem';
import { map } from 'lit/directives/map.js';
@customElement('arx-breadcrumbs') @customElement('arx-breadcrumbs')
export class Breadcrumbs extends LitElement { export class Breadcrumbs extends LitElement {
@ -8,11 +9,29 @@ export class Breadcrumbs extends LitElement {
static override styles = [ static override styles = [
css` css`
:host {
display: block;
--breadcrumb-bg: var(--surface, #ffffff);
--breadcrumb-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
--breadcrumb-border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
--breadcrumb-radius: 8px;
--breadcrumb-padding: 0.75rem 1.25rem;
--breadcrumb-font: var(--font-family, system-ui, sans-serif);
--breadcrumb-text-color: var(--text-primary, #333);
--breadcrumb-separator-color: var(--text-secondary, #666);
--breadcrumb-accent-color: var(--accent, #0066cc);
--breadcrumb-hover-color: var(--accent-dark, #004c99);
--breadcrumb-active-bg: var(--accent-light, rgba(0, 102, 204, 0.1));
}
nav { nav {
max-width: 1200px; max-width: 1200px;
margin: 1rem auto; margin: 1.25rem auto;
padding-inline: 1rem; background-color: var(--breadcrumb-bg);
font-size: 0.9rem; border-radius: var(--breadcrumb-radius);
box-shadow: var(--breadcrumb-shadow);
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
transition: all 0.3s ease;
} }
ol { ol {
@ -20,8 +39,17 @@ export class Breadcrumbs extends LitElement {
padding: 0; padding: 0;
margin: 0; margin: 0;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: center;
overflow-x: auto;
scrollbar-width: none;
}
@media (max-width: 640px) {
nav {
padding: 0.5rem 1rem;
margin: 0.75rem auto;
}
} }
`, `,
]; ];
@ -30,12 +58,14 @@ export class Breadcrumbs extends LitElement {
return html` return html`
<nav aria-label="Breadcrumb"> <nav aria-label="Breadcrumb">
<ol> <ol>
${this.items.map( ${map(
this.items,
(item, index) => html` (item, index) => html`
<arx-breadcrumbs-item <arx-breadcrumbs-item
.text=${item.text} .text=${item.text}
.href=${item.href} .href=${item.href}
.index=${index} .index=${index}
.isLast=${index === this.items.length - 1}
></arx-breadcrumbs-item> ></arx-breadcrumbs-item>
`, `,
)} )}

View file

@ -2,49 +2,131 @@ import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import '@components/EveLink'; import '@components/EveLink';
import { when } from 'lit/directives/when.js';
@customElement('arx-breadcrumbs-item') @customElement('arx-breadcrumbs-item')
export class BreadcrumbsItem extends LitElement { export class BreadcrumbsItem extends LitElement {
@property() text = ''; @property() text = '';
@property() href?: string; @property() href?: string;
@property() index = 0; @property() index = 0;
@property({ type: Boolean }) isLast = false;
static override styles = [ static override styles = [
css` css`
li { li {
display: inline-block; display: inline-flex;
margin-right: 0.5rem; align-items: center;
white-space: nowrap;
animation: fadeIn 0.3s ease-out forwards;
animation-delay: calc(var(--index, 0) * 0.05s);
opacity: 0;
} }
.separator { .separator {
margin-inline: 0.5rem; display: flex;
color: var(--secondary); align-items: center;
margin-inline: 0.75rem;
color: var(--breadcrumb-separator);
user-select: none; user-select: none;
font-size: 0.85rem;
}
svg.chevron {
width: 16px;
height: 16px;
fill: currentColor;
opacity: 0.75;
}
.breadcrumb-content {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.7rem;
border-radius: 6px;
transition: all 0.2s ease;
}
.active {
background-color: var(--breadcrumb-active-bg);
font-weight: 500;
color: var(--breadcrumb-text);
} }
.link { .link {
color: var(--accent); color: var(--breadcrumb-accent);
text-decoration: none; text-decoration: none;
transition: text-decoration 0.2s;
} }
.secondary { .link:hover .breadcrumb-content {
color: var(--secondary); background-color: var(--breadcrumb-active-bg);
}
.link:focus-visible {
outline: 2px solid var(--breadcrumb-accent);
outline-offset: 2px;
border-radius: 6px;
}
.item-text {
position: relative;
}
.link .item-text::after {
content: "";
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background-color: var(--breadcrumb-accent);
transition: width 0.25s ease;
}
.link:hover .item-text::after {
width: 100%;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
`, `,
]; ];
override render() { override render() {
return html` return html`
<li> <li style="--index: ${this.index}">
${this.index > 0 ? html`<span class="separator" aria-hidden="true">/</span>` : ''} ${when(
${ this.index > 0,
this.href () => html`
? html`<arx-eve-link class="link" href=${this.href} <span class="separator" aria-hidden="true">
>${this.text}</arx-eve-link <svg class="chevron" viewBox="0 0 24 24">
>` <path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6-6-6z" />
: html`<span class="secondary">${this.text}</span>` </svg>
} </span>
`,
)}
${when(
this.href && !this.isLast,
() => html`
<arx-eve-link class="link" href=${this.href}>
<span class="breadcrumb-content">
<span class="item-text">${this.text}</span>
</span>
</arx-eve-link>
`,
() => html`
<span class="breadcrumb-content ${this.isLast ? 'active' : ''}">
<span class="item-text">${this.text}</span>
</span>
`,
)}
</li> </li>
`; `;
} }

View file

@ -1,125 +0,0 @@
import formatDateTime from '@utils/formatDateTime';
import { LitElement, css, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
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;
}
}
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
`,
];
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="#"
@click=${() => alert('TODO')}
class="disabled"
>
<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="#"
@click=${() => alert('TODO')}
class="disabled"
>
<iconify-icon size="32" icon="bxs:zap"></iconify-icon>
Zap
</arx-phora-button>
<arx-phora-button
href="#"
@click=${() => alert('TODO')}
class="disabled"
>
<iconify-icon
size="32"
icon="bi:cloud-lightning-rain-fill"
></iconify-icon>
Downzap
</arx-phora-button>
</div>
</div>
</div>
`;
}
}

261
src/components/Prompt.ts Normal file
View file

@ -0,0 +1,261 @@
import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@customElement('arx-prompt')
export class EvePrompt extends LitElement {
@property({ type: String }) promptText = 'Please provide input';
@property({ type: String }) cancelText = 'Cancel';
@property({ type: String }) saveText = 'Save';
@property({ type: Boolean }) open = false;
@property({ type: Boolean }) showInput = false;
@property({ type: String }) placeholder = 'Enter your response';
@property({ type: String }) defaultValue = '';
@state() private _inputValue = '';
private _previousFocus: HTMLElement | null = null;
static override styles = css`
:host {
--prompt-primary: #3b82f6;
--prompt-primary-hover: #2563eb;
--prompt-bg: #ffffff;
--prompt-text: #1f2937;
--prompt-border: #e5e7eb;
--prompt-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
--prompt-cancel-bg: #f3f4f6;
--prompt-cancel-hover: #e5e7eb;
--prompt-overlay: rgba(15, 23, 42, 0.6);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--prompt-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
backdrop-filter: blur(4px);
}
.overlay.active {
opacity: 1;
pointer-events: all;
}
.prompt-container {
background-color: var(--prompt-bg);
border-radius: 12px;
box-shadow: var(--prompt-shadow);
width: 90%;
max-width: 420px;
padding: 28px;
transform: scale(0.95) translateY(10px);
transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1);
color: var(--prompt-text);
}
.overlay.active .prompt-container {
transform: scale(1) translateY(0);
}
.prompt-header {
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
line-height: 1.4;
}
.input-container {
margin: 20px 0;
}
.input-field {
width: 100%;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--prompt-border);
font-size: 15px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
color: inherit;
background-color: transparent;
outline: none;
box-sizing: border-box;
}
.input-field:focus {
border-color: var(--prompt-primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
.buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
button {
padding: 10px 18px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
outline: none;
}
button:focus-visible {
box-shadow: 0 0 0 2px var(--prompt-bg), 0 0 0 4px var(--prompt-primary);
}
.cancel-btn {
background-color: var(--prompt-cancel-bg);
color: var(--prompt-text);
}
.cancel-btn:hover {
background-color: var(--prompt-cancel-hover);
}
.save-btn {
background-color: var(--prompt-primary);
color: white;
}
.save-btn:hover {
background-color: var(--prompt-primary-hover);
}
@media (prefers-color-scheme: dark) {
:host {
--prompt-bg: #1e1e1e;
--prompt-text: #e5e7eb;
--prompt-border: #374151;
--prompt-cancel-bg: #374151;
--prompt-cancel-hover: #4b5563;
}
}
`;
constructor() {
super();
this._handleKeyDown = this._handleKeyDown.bind(this);
}
override connectedCallback() {
super.connectedCallback();
document.addEventListener('keydown', this._handleKeyDown);
}
override disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('keydown', this._handleKeyDown);
}
override updated(changedProps: Map<string, unknown>) {
if (changedProps.has('open') && this.open) {
this._inputValue = this.defaultValue;
this._previousFocus = document.activeElement as HTMLElement;
// Focus the input or save button after rendering
setTimeout(() => {
if (this.showInput) {
const input = this.shadowRoot?.querySelector('.input-field') as HTMLElement;
if (input) input.focus();
} else {
const saveBtn = this.shadowRoot?.querySelector('.save-btn') as HTMLElement;
if (saveBtn) saveBtn.focus();
}
}, 50);
} else if (changedProps.has('open') && !this.open && this._previousFocus) {
this._previousFocus.focus();
}
}
private _handleKeyDown(e: KeyboardEvent) {
if (!this.open) return;
if (e.key === 'Escape') this._handleCancel();
if (e.key === 'Enter' && !e.shiftKey) this._handleSave();
}
private _handleInputChange(e: Event) {
const target = e.target as HTMLInputElement;
this._inputValue = target.value;
}
private _handleCancel() {
this.open = false;
this.dispatchEvent(new CustomEvent('cancel'));
}
private _handleSave() {
this.open = false;
this.dispatchEvent(
new CustomEvent('save', {
detail: { value: this._inputValue },
}),
);
}
override render() {
return html`
<div
class="${classMap({ overlay: true, active: this.open })}"
@click="${(e: MouseEvent) => e.target === e.currentTarget && this._handleCancel()}"
>
<div class="prompt-container">
<div class="prompt-header">${this.promptText}</div>
${
this.showInput
? html`
<div class="input-container">
<input
type="text"
class="input-field"
.value=${this._inputValue}
@input=${this._handleInputChange}
placeholder=${this.placeholder}
/>
</div>
`
: ''
}
<div class="buttons">
<button @click=${this._handleCancel} class="cancel-btn">
${this.cancelText}
</button>
<button @click=${this._handleSave} class="save-btn">
${this.saveText}
</button>
</div>
</div>
</div>
`;
}
show() {
this.open = true;
}
hide() {
this.open = false;
}
getValue(): string {
return this._inputValue;
}
}

202
src/routes/Arbor/Home.ts Normal file
View file

@ -0,0 +1,202 @@
import { getSigner, ndk } from '@/ndk';
import { NDKEvent, type NDKKind, type NDKSubscription } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@components/Breadcrumbs';
import '@components/Arbor/ForumTopic';
import '@components/Arbor/ForumCategory';
import '@components/Arbor/Button';
import '@components/Prompt';
import type { EvePrompt } from '@components/Prompt';
import { map } from 'lit/directives/map.js';
import { when } from 'lit/directives/when.js';
interface ForumTopic {
id: string;
title: string;
author: string;
description: string;
created_at: string;
}
interface ForumCategory {
id: string;
name: string;
topics: ForumTopic[];
}
@customElement('arx-arbor-home')
export class ArborForum extends LitElement {
@state()
private categories: ForumCategory[] = [];
private topicsQuery: NDKSubscription | undefined;
@state()
private forum: NDKEvent | null = null;
static override styles = css`
:host {
display: block;
}
`;
override async connectedCallback() {
super.connectedCallback();
await this.loadCategories();
}
newCategoryButtonClicked() {
const prompt = this.shadowRoot?.getElementById('new-category-prompt') as EvePrompt;
if (prompt) prompt.show();
}
private async doCreateCategory() {
if (!this.forum) return;
const prompt = this.shadowRoot?.getElementById('new-category-prompt') as EvePrompt;
const newCategory = prompt.getValue();
if (newCategory.length < 3) {
alert('Category name must be at least 3 characters long');
return;
}
try {
await getSigner();
const dtag = (Date.now() + Math.floor(Math.random() * 1000)).toString(32);
const newForum = new NDKEvent(ndk);
newForum.kind = 60890;
newForum.tags = [...this.forum.tags, ['forum', `60891:${dtag}`, newCategory]];
newForum.content = this.forum.content;
await newForum.sign();
await newForum.publish();
this.forum = newForum;
const categoryEvent = new NDKEvent(ndk);
categoryEvent.kind = 60891;
categoryEvent.tags = [
['d', dtag],
['name', newCategory],
];
categoryEvent.content = '';
await categoryEvent.sign();
await categoryEvent.publish();
this.categories = [
...this.categories,
{
id: dtag,
name: newCategory,
topics: [],
},
];
this.loadTopics();
} catch (error) {
console.error('Failed to create category:', error);
alert('Failed to create category');
}
}
private async loadCategories() {
await getSigner();
this.forum = await ndk.fetchEvent({
kinds: [60890 as NDKKind],
'#d': ['arbor'],
});
if (!this.forum) {
const event = new NDKEvent(ndk);
event.kind = 60890;
event.tags.push(['d', 'arbor']);
event.content = '';
await event.sign();
await event.publish();
this.forum = event;
}
for (const tag of this.forum.tags) {
if (tag[0] === 'forum') {
const [_, categoryId] = tag[1].split(':');
this.categories = [
...this.categories,
{
id: categoryId,
name: tag[2],
topics: [],
},
];
}
}
this.loadTopics();
}
private async loadTopics() {
if (this.topicsQuery) this.topicsQuery.stop();
this.topicsQuery = ndk
.subscribe({
kinds: [892 as NDKKind],
'#d': this.categories.map((category) => `60891:${category.id}`),
})
.on('event', (event) => {
const categoryId = this.categories.findIndex(
(category) => category.id === event.tags.find((tag) => tag[0] === 'd')?.[1].split(':')[1],
);
if (categoryId === -1) return;
this.categories[categoryId].topics = [
...this.categories[categoryId].topics,
{
id: event.id,
title: event.tags.find((tag) => tag[0] === 'name')?.[1] || '',
author: event.pubkey,
description: event.content,
created_at: new Date((event.created_at || 0) * 1000).toString(),
},
];
this.categories = [...this.categories];
});
}
override render() {
return html`
<arx-breadcrumbs
.items=${[{ text: 'Home', href: '/' }, { text: 'Arbor' }]}
>
</arx-breadcrumbs>
<arx-arbor-button @click=${this.newCategoryButtonClicked}>
New Category
</arx-arbor-button>
<arx-prompt
id="new-category-prompt"
promptText="What would you like to call this category?"
showInput
placeholder="New Category"
@save=${this.doCreateCategory}
></arx-prompt>
${map(
this.categories,
(category) => html`
<arx-arbor-forum-category id=${category.id} title=${category.name}>
${when(category.topics.length === 0, () => html` <div style="padding: 1rem 1.5rem">No topics...</div> `)}
${map(
category.topics,
(topic) => html`
<arx-arbor-forum-topic
id=${topic.id}
title=${topic.title}
description=${topic.description}
lastPostBy=${topic.author}
lastPostTime=${topic.created_at}
>
</arx-arbor-forum-topic>
`,
)}
</arx-arbor-forum-category>
`,
)}
`;
}
}

View file

@ -3,14 +3,17 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit'; import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
@customElement('arx-phora-post-creator') @customElement('arx-arbor-post-creator')
export class PhoraPostCreator extends LitElement { export class ArborPostCreator extends LitElement {
@property({ type: String }) @property({ type: String })
topicId = ''; topicId = '';
@state() @state()
private postContent = ''; private postContent = '';
@state()
private topic: NDKEvent | null = null;
@state() @state()
private isCreating = false; private isCreating = false;
@ -67,8 +70,26 @@ export class PhoraPostCreator extends LitElement {
} }
`; `;
override connectedCallback() {
super.connectedCallback();
this.loadTopic();
}
private async loadTopic() {
try {
await getSigner();
this.topic = await ndk.fetchEvent(this.topicId);
if (!this.topic) {
throw new Error('Could not load topic');
}
} catch (error) {
console.error('Failed to load topic:', error);
}
}
private async doCreatePost() { private async doCreatePost() {
if (this.isCreating) return; if (this.isCreating) return;
if (!this.topic) return;
if (this.postContent.length < 10) { if (this.postContent.length < 10) {
this.error = 'Post content must be at least 10 characters long'; this.error = 'Post content must be at least 10 characters long';
@ -78,26 +99,18 @@ export class PhoraPostCreator extends LitElement {
this.error = null; this.error = null;
this.isCreating = true; this.isCreating = true;
try {
await getSigner();
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 1111; event.kind = 893;
event.tags = [['e', this.topicId]]; event.tags = [
['A', this.topic.tags.find((tag) => tag[0] === 'd')?.[1] || ''],
['E', this.topicId],
['p', this.topic.pubkey],
];
event.content = this.postContent; event.content = this.postContent;
try {
await event.sign(); await event.sign();
await event.publish(); await event.publish();
this.dispatchEvent(
new CustomEvent('post-created', {
bubbles: true,
composed: true,
detail: {
postId: event.id,
topicId: this.topicId,
},
}),
);
// Reset form // Reset form
this.postContent = ''; this.postContent = '';
} catch (error) { } catch (error) {
@ -130,19 +143,19 @@ export class PhoraPostCreator extends LitElement {
${this.error ? html` <div class="error">${this.error}</div> ` : null} ${this.error ? html` <div class="error">${this.error}</div> ` : null}
<div class="actions"> <div class="actions">
<arx-phora-button <arx-arbor-button
@click=${() => this.dispatchEvent(new CustomEvent('cancel'))} @click=${() => this.dispatchEvent(new CustomEvent('cancel'))}
?disabled=${this.isCreating} ?disabled=${this.isCreating}
> >
Cancel Cancel
</arx-phora-button> </arx-arbor-button>
<arx-phora-button <arx-arbor-button
@click=${this.doCreatePost} @click=${this.doCreatePost}
?disabled=${this.isCreating} ?disabled=${this.isCreating}
> >
${this.isCreating ? 'Creating...' : 'Create'} ${this.isCreating ? 'Creating...' : 'Create'}
</arx-phora-button> </arx-arbor-button>
</div> </div>
</div> </div>
`; `;

View file

@ -3,8 +3,8 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit'; import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
@customElement('arx-phora-topic-creator') @customElement('arx-arbor-topic-creator')
export class PhoraTopicCreator extends LitElement { export class ArborTopicCreator extends LitElement {
@property({ type: String }) @property({ type: String })
categoryId = ''; categoryId = '';
@ -66,10 +66,10 @@ export class PhoraTopicCreator extends LitElement {
try { try {
await getSigner(); await getSigner();
const event = new NDKEvent(ndk); const event = new NDKEvent(ndk);
event.kind = 11; event.kind = 892;
event.tags = [ event.tags = [
['subject', this.newTopic], ['name', this.newTopic],
['e', this.categoryId], ['d', `60891:${this.categoryId}`],
]; ];
event.content = this.topicContent; event.content = this.topicContent;
await event.sign(); await event.sign();
@ -124,19 +124,19 @@ export class PhoraTopicCreator extends LitElement {
></textarea> ></textarea>
<div class="button-group"> <div class="button-group">
<arx-phora-button <arx-arbor-button
@click=${() => this.dispatchEvent(new CustomEvent('cancel'))} @click=${() => this.dispatchEvent(new CustomEvent('cancel'))}
?disabled=${this.isCreating} ?disabled=${this.isCreating}
> >
Cancel Cancel
</arx-phora-button> </arx-arbor-button>
<arx-phora-button <arx-arbor-button
@click=${this.doCreateTopic} @click=${this.doCreateTopic}
?disabled=${this.isCreating} ?disabled=${this.isCreating}
> >
${this.isCreating ? 'Creating...' : 'Create'} ${this.isCreating ? 'Creating...' : 'Create'}
</arx-phora-button> </arx-arbor-button>
</div> </div>
`; `;
} }

View file

@ -0,0 +1,209 @@
import { getSigner, ndk } from '@/ndk';
import type { NDKKind, NDKSubscription } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import '@components/Breadcrumbs';
import '@components/Arbor/ForumPost';
import '@components/Arbor/Button';
import { map } from 'lit/directives/map.js';
import { when } from 'lit/directives/when.js';
interface ForumPost {
id: string;
npub: string;
date: Date;
content: string;
}
@customElement('arx-arbor-topic-view')
export class ArborTopicView extends LitElement {
@property({ type: String })
topicId = '';
@state()
override title = '';
@state()
private posts: ForumPost[] = [];
private subscription: NDKSubscription | undefined;
static override styles = css`
:host {
display: block;
margin: 0 auto;
padding: 1rem;
color: #1a1a1a;
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
}
.topic-container {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
overflow: hidden;
margin-top: 1.5rem;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.header {
background: var(--primary);
color: oklch(100% 0 0);
padding: 1.5rem 2rem;
font-weight: 600;
font-size: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
letter-spacing: 0.01em;
}
.posts-container {
display: flex;
flex-direction: column;
gap: 0;
background: #f9f9f9;
padding: 1.5rem;
min-height: 300px;
}
.actions {
background: #f9f9f9;
padding: 1.25rem 1.5rem;
display: flex;
justify-content: flex-end;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.empty-state {
padding: 3rem;
text-align: center;
color: #666;
font-style: italic;
}
@media (max-width: 768px) {
.header {
padding: 1.25rem 1.5rem;
font-size: 1.1rem;
}
.posts-container {
padding: 1rem;
}
.actions {
padding: 1rem;
}
}
@media (max-width: 480px) {
:host {
padding: 0.5rem;
}
.header {
padding: 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] === 'name')?.[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: [893 as NDKKind],
'#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: 'Arbor', href: '/arbor' }, { text: this.title }];
return html`
<arx-breadcrumbs .items=${breadcrumbItems}></arx-breadcrumbs>
<div class="topic-container">
<div class="header">
<span>${this.title || 'Loading topic...'}</span>
</div>
<div class="posts-container">
${when(
this.posts.length === 0,
() => html`<div class="empty-state">No posts available</div>`,
() =>
map(
this.posts,
(post) => html`
<arx-forum-post
id=${post.id}
.topicId=${this.topicId}
.npub=${post.npub}
.date=${post.date}
.content=${post.content}
></arx-forum-post>
`,
),
)}
</div>
<div class="actions">
<arx-arbor-button href="/arbor/new-post/${this.topicId}">
New Post
</arx-arbor-button>
</div>
</div>
`;
}
}

View file

@ -39,8 +39,8 @@ export class Home extends LitElement {
}, },
{ {
id: 3, id: 3,
href: 'phora', href: 'arbor',
name: 'Phora', name: 'Arbor',
color: '#FF3B30', color: '#FF3B30',
icon: 'bxs:conversation', icon: 'bxs:conversation',
}, },

View file

@ -1,122 +0,0 @@
import { getSigner, ndk } from '@/ndk';
import type { NDKSubscription } from '@nostr-dev-kit/ndk';
import formatDateTime from '@utils/formatDateTime';
import { LitElement, css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
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

@ -1,97 +0,0 @@
import { getSigner, ndk } from '@/ndk';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
@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>
`;
}
}

View file

@ -1,154 +0,0 @@
import { getSigner, ndk } from '@/ndk';
import type { NDKSubscription } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
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>
`;
}
}

View file

@ -1,11 +1,10 @@
import '@routes/404Page'; import '@routes/404Page';
import '@routes/Home'; import '@routes/Home';
import '@routes/Profile'; import '@routes/Profile';
import '@routes/Phora/Home'; import '@routes/Arbor/Home';
import '@routes/Phora/NewCategory'; import '@routes/Arbor/NewTopic';
import '@routes/Phora/NewTopic'; import '@routes/Arbor/TopicView';
import '@routes/Phora/TopicView'; import '@routes/Arbor/NewPost';
import '@routes/Phora/NewPost';
import '@components/InitialSetup'; import '@components/InitialSetup';
import { spread } from '@open-wc/lit-helpers'; import { spread } from '@open-wc/lit-helpers';
@ -40,29 +39,24 @@ export default class EveRouter extends LitElement {
component: literal`arx-profile-route`, component: literal`arx-profile-route`,
}, },
{ {
pattern: 'phora', pattern: 'arbor',
params: {}, params: {},
component: literal`arx-phora-home`, component: literal`arx-arbor-home`,
}, },
{ {
pattern: 'phora/new-category', pattern: 'arbor/new-topic/:categoryId',
params: {}, params: {},
component: literal`arx-phora-category-creator`, component: literal`arx-arbor-topic-creator`,
}, },
{ {
pattern: 'phora/new-topic/:categoryId', pattern: 'arbor/topics/:topicId',
params: {}, params: {},
component: literal`arx-phora-topic-creator`, component: literal`arx-arbor-topic-view`,
}, },
{ {
pattern: 'phora/topics/:topicId', pattern: 'arbor/new-post/:topicId',
params: {}, params: {},
component: literal`arx-phora-topic-view`, component: literal`arx-arbor-post-creator`,
},
{
pattern: 'phora/new-post/:topicId',
params: {},
component: literal`arx-phora-post-creator`,
}, },
{ {
pattern: '404', pattern: '404',