🔄 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

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 { customElement, property, state } from 'lit/decorators.js';
@customElement('arx-phora-post-creator')
export class PhoraPostCreator extends LitElement {
@customElement('arx-arbor-post-creator')
export class ArborPostCreator extends LitElement {
@property({ type: String })
topicId = '';
@state()
private postContent = '';
@state()
private topic: NDKEvent | null = null;
@state()
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() {
if (this.isCreating) return;
if (!this.topic) return;
if (this.postContent.length < 10) {
this.error = 'Post content must be at least 10 characters long';
@ -78,26 +99,18 @@ export class PhoraPostCreator extends LitElement {
this.error = null;
this.isCreating = true;
const event = new NDKEvent(ndk);
event.kind = 893;
event.tags = [
['A', this.topic.tags.find((tag) => tag[0] === 'd')?.[1] || ''],
['E', this.topicId],
['p', this.topic.pubkey],
];
event.content = this.postContent;
try {
await getSigner();
const event = new NDKEvent(ndk);
event.kind = 1111;
event.tags = [['e', this.topicId]];
event.content = this.postContent;
await event.sign();
await event.publish();
this.dispatchEvent(
new CustomEvent('post-created', {
bubbles: true,
composed: true,
detail: {
postId: event.id,
topicId: this.topicId,
},
}),
);
// Reset form
this.postContent = '';
} catch (error) {
@ -130,19 +143,19 @@ export class PhoraPostCreator extends LitElement {
${this.error ? html` <div class="error">${this.error}</div> ` : null}
<div class="actions">
<arx-phora-button
<arx-arbor-button
@click=${() => this.dispatchEvent(new CustomEvent('cancel'))}
?disabled=${this.isCreating}
>
Cancel
</arx-phora-button>
</arx-arbor-button>
<arx-phora-button
<arx-arbor-button
@click=${this.doCreatePost}
?disabled=${this.isCreating}
>
${this.isCreating ? 'Creating...' : 'Create'}
</arx-phora-button>
</arx-arbor-button>
</div>
</div>
`;

View file

@ -3,8 +3,8 @@ import { NDKEvent } from '@nostr-dev-kit/ndk';
import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('arx-phora-topic-creator')
export class PhoraTopicCreator extends LitElement {
@customElement('arx-arbor-topic-creator')
export class ArborTopicCreator extends LitElement {
@property({ type: String })
categoryId = '';
@ -66,10 +66,10 @@ export class PhoraTopicCreator extends LitElement {
try {
await getSigner();
const event = new NDKEvent(ndk);
event.kind = 11;
event.kind = 892;
event.tags = [
['subject', this.newTopic],
['e', this.categoryId],
['name', this.newTopic],
['d', `60891:${this.categoryId}`],
];
event.content = this.topicContent;
await event.sign();
@ -124,19 +124,19 @@ export class PhoraTopicCreator extends LitElement {
></textarea>
<div class="button-group">
<arx-phora-button
<arx-arbor-button
@click=${() => this.dispatchEvent(new CustomEvent('cancel'))}
?disabled=${this.isCreating}
>
Cancel
</arx-phora-button>
</arx-arbor-button>
<arx-phora-button
<arx-arbor-button
@click=${this.doCreateTopic}
?disabled=${this.isCreating}
>
${this.isCreating ? 'Creating...' : 'Create'}
</arx-phora-button>
</arx-arbor-button>
</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,
href: 'phora',
name: 'Phora',
href: 'arbor',
name: 'Arbor',
color: '#FF3B30',
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/Home';
import '@routes/Profile';
import '@routes/Phora/Home';
import '@routes/Phora/NewCategory';
import '@routes/Phora/NewTopic';
import '@routes/Phora/TopicView';
import '@routes/Phora/NewPost';
import '@routes/Arbor/Home';
import '@routes/Arbor/NewTopic';
import '@routes/Arbor/TopicView';
import '@routes/Arbor/NewPost';
import '@components/InitialSetup';
import { spread } from '@open-wc/lit-helpers';
@ -40,29 +39,24 @@ export default class EveRouter extends LitElement {
component: literal`arx-profile-route`,
},
{
pattern: 'phora',
pattern: 'arbor',
params: {},
component: literal`arx-phora-home`,
component: literal`arx-arbor-home`,
},
{
pattern: 'phora/new-category',
pattern: 'arbor/new-topic/:categoryId',
params: {},
component: literal`arx-phora-category-creator`,
component: literal`arx-arbor-topic-creator`,
},
{
pattern: 'phora/new-topic/:categoryId',
pattern: 'arbor/topics/:topicId',
params: {},
component: literal`arx-phora-topic-creator`,
component: literal`arx-arbor-topic-view`,
},
{
pattern: 'phora/topics/:topicId',
pattern: 'arbor/new-post/:topicId',
params: {},
component: literal`arx-phora-topic-view`,
},
{
pattern: 'phora/new-post/:topicId',
params: {},
component: literal`arx-phora-post-creator`,
component: literal`arx-arbor-post-creator`,
},
{
pattern: '404',