From 45e6003d41309c91c614d3e54edcf60becaafb2d Mon Sep 17 00:00:00 2001 From: Danny Morabito <danny@arx-ccn.com> Date: Tue, 18 Mar 2025 16:02:16 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20=E2=9C=A8=20Forum=20overhaul:=20?= =?UTF-8?q?Phora=20=E2=86=92=20Arbor=20=20=20=20=20=F0=9F=94=A7=20Replace?= =?UTF-8?q?=20legacy=20system=20with=20NIP-BB=20implementation=20=20=20=20?= =?UTF-8?q?=20=F0=9F=9A=80=20Enhance=20forum=20user=20experience=20with=20?= =?UTF-8?q?improved=20navigation=20and=20interactions=20=20=20=20=20?= =?UTF-8?q?=F0=9F=8E=A8=20Redesign=20UI=20for=20forum=20=20=20=20=20?= =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Rebrand=20from=20"Phora"=20to=20"Arbor"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Readme.md | 2 +- Security.md | 2 +- .../{PhoraButton.ts => Arbor/Button.ts} | 11 +- .../ForumCategory.ts} | 8 +- src/components/Arbor/ForumPost.ts | 341 ++++++++++++++++++ .../ForumTopic.ts} | 30 +- src/components/Breadcrumbs.ts | 52 ++- src/components/BreadcrumbsItem.ts | 116 +++++- src/components/ForumPost.ts | 125 ------- src/components/Prompt.ts | 261 ++++++++++++++ src/routes/Arbor/Home.ts | 202 +++++++++++ src/routes/{Phora => Arbor}/NewPost.ts | 59 +-- src/routes/{Phora => Arbor}/NewTopic.ts | 18 +- src/routes/Arbor/TopicView.ts | 209 +++++++++++ src/routes/Home.ts | 4 +- src/routes/Phora/Home.ts | 122 ------- src/routes/Phora/NewCategory.ts | 97 ----- src/routes/Phora/TopicView.ts | 154 -------- src/routes/router.ts | 30 +- 19 files changed, 1233 insertions(+), 610 deletions(-) rename src/components/{PhoraButton.ts => Arbor/Button.ts} (94%) rename src/components/{PhoraForumCategory.ts => Arbor/ForumCategory.ts} (82%) create mode 100644 src/components/Arbor/ForumPost.ts rename src/components/{PhoraForumTopic.ts => Arbor/ForumTopic.ts} (85%) delete mode 100644 src/components/ForumPost.ts create mode 100644 src/components/Prompt.ts create mode 100644 src/routes/Arbor/Home.ts rename src/routes/{Phora => Arbor}/NewPost.ts (75%) rename src/routes/{Phora => Arbor}/NewTopic.ts (90%) create mode 100644 src/routes/Arbor/TopicView.ts delete mode 100644 src/routes/Phora/Home.ts delete mode 100644 src/routes/Phora/NewCategory.ts delete mode 100644 src/routes/Phora/TopicView.ts diff --git a/Readme.md b/Readme.md index 454eaf4..dda8136 100644 --- a/Readme.md +++ b/Readme.md @@ -51,7 +51,7 @@ EVE provides building blocks for digital sovereignty, which we call Arxlets: ### Core Arxlets -- [x] **Phora**: Threaded discussions and knowledge sharing +- [x] **Arbor**: Threaded discussions and knowledge sharing - [ ] **Nexus**: Central community hub - [ ] **Whisper**: One-to-one and group messaging - [ ] **Vault**: Secure file storage and sharing diff --git a/Security.md b/Security.md index aa3a90b..81335f4 100644 --- a/Security.md +++ b/Security.md @@ -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 - 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 diff --git a/src/components/PhoraButton.ts b/src/components/Arbor/Button.ts similarity index 94% rename from src/components/PhoraButton.ts rename to src/components/Arbor/Button.ts index aa4cba9..596f301 100644 --- a/src/components/PhoraButton.ts +++ b/src/components/Arbor/Button.ts @@ -3,8 +3,8 @@ import { customElement, property } from 'lit/decorators.js'; import '@components/EveLink'; -@customElement('arx-phora-button') -export class PhoraButton extends LitElement { +@customElement('arx-arbor-button') +export class ArborButton extends LitElement { @property({ type: String }) href = ''; @property({ type: String }) target = ''; @property({ type: String }) rel = ''; @@ -18,13 +18,16 @@ export class PhoraButton extends LitElement { static override styles = [ css` arx-eve-link::part(link) { + color: white; + text-decoration: none; + } + + arx-eve-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; diff --git a/src/components/PhoraForumCategory.ts b/src/components/Arbor/ForumCategory.ts similarity index 82% rename from src/components/PhoraForumCategory.ts rename to src/components/Arbor/ForumCategory.ts index aed7324..f9dd5e3 100644 --- a/src/components/PhoraForumCategory.ts +++ b/src/components/Arbor/ForumCategory.ts @@ -1,8 +1,8 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -@customElement('arx-phora-forum-category') -export class PhoraForumCategory extends LitElement { +@customElement('arx-arbor-forum-category') +export class ArborForumCategory extends LitElement { @property({ type: String }) override title = ''; @property({ type: String }) override id = ''; @@ -33,7 +33,9 @@ export class PhoraForumCategory extends LitElement { <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> + <arx-arbor-button href="/arbor/new-topic/${this.id}"> + New Topic + </arx-arbor-button> </div> <slot> <div style="padding: 1rem 1.5rem">No topics...</div> diff --git a/src/components/Arbor/ForumPost.ts b/src/components/Arbor/ForumPost.ts new file mode 100644 index 0000000..aeab7c6 --- /dev/null +++ b/src/components/Arbor/ForumPost.ts @@ -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> + `; + } +} diff --git a/src/components/PhoraForumTopic.ts b/src/components/Arbor/ForumTopic.ts similarity index 85% rename from src/components/PhoraForumTopic.ts rename to src/components/Arbor/ForumTopic.ts index 2cc1624..fd51093 100644 --- a/src/components/PhoraForumTopic.ts +++ b/src/components/Arbor/ForumTopic.ts @@ -2,9 +2,10 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import '@components/EveLink'; +import formatDateTime from '@/utils/formatDateTime'; -@customElement('arx-phora-forum-topic') -export class PhoraForumTopic extends LitElement { +@customElement('arx-arbor-forum-topic') +export class ArborForumTopic extends LitElement { static override styles = [ css` .topic { @@ -113,19 +114,6 @@ export class PhoraForumTopic extends LitElement { 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; @@ -168,7 +156,6 @@ export class PhoraForumTopic extends LitElement { @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; @@ -188,7 +175,7 @@ export class PhoraForumTopic extends LitElement { <div class="topic-details"> <arx-eve-link class="${this.status}" - href="/phora/topics/${this.id}" + href="/arbor/topics/${this.id}" > ${this.title} </arx-eve-link> @@ -196,14 +183,11 @@ export class PhoraForumTopic extends LitElement { </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 class="last-post-info"> + ${formatDateTime(this.lastPostTime)} + </div> </div> </div> </div> diff --git a/src/components/Breadcrumbs.ts b/src/components/Breadcrumbs.ts index 4937792..4423a11 100644 --- a/src/components/Breadcrumbs.ts +++ b/src/components/Breadcrumbs.ts @@ -1,6 +1,7 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import './BreadcrumbsItem'; +import { map } from 'lit/directives/map.js'; @customElement('arx-breadcrumbs') export class Breadcrumbs extends LitElement { @@ -8,11 +9,29 @@ export class Breadcrumbs extends LitElement { static override styles = [ 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 { max-width: 1200px; - margin: 1rem auto; - padding-inline: 1rem; - font-size: 0.9rem; + margin: 1.25rem auto; + background-color: var(--breadcrumb-bg); + 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 { @@ -20,8 +39,17 @@ export class Breadcrumbs extends LitElement { padding: 0; margin: 0; display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; + overflow-x: auto; + scrollbar-width: none; + } + + @media (max-width: 640px) { + nav { + padding: 0.5rem 1rem; + margin: 0.75rem auto; + } } `, ]; @@ -30,14 +58,16 @@ export class Breadcrumbs extends LitElement { return html` <nav aria-label="Breadcrumb"> <ol> - ${this.items.map( + ${map( + this.items, (item, index) => html` - <arx-breadcrumbs-item - .text=${item.text} - .href=${item.href} - .index=${index} - ></arx-breadcrumbs-item> - `, + <arx-breadcrumbs-item + .text=${item.text} + .href=${item.href} + .index=${index} + .isLast=${index === this.items.length - 1} + ></arx-breadcrumbs-item> + `, )} </ol> </nav> diff --git a/src/components/BreadcrumbsItem.ts b/src/components/BreadcrumbsItem.ts index 5a6dd5a..4f88882 100644 --- a/src/components/BreadcrumbsItem.ts +++ b/src/components/BreadcrumbsItem.ts @@ -2,49 +2,131 @@ import { LitElement, css, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import '@components/EveLink'; +import { when } from 'lit/directives/when.js'; @customElement('arx-breadcrumbs-item') export class BreadcrumbsItem extends LitElement { @property() text = ''; @property() href?: string; @property() index = 0; + @property({ type: Boolean }) isLast = false; static override styles = [ css` li { - display: inline-block; - margin-right: 0.5rem; + display: inline-flex; + align-items: center; + white-space: nowrap; + animation: fadeIn 0.3s ease-out forwards; + animation-delay: calc(var(--index, 0) * 0.05s); + opacity: 0; } .separator { - margin-inline: 0.5rem; - color: var(--secondary); + display: flex; + align-items: center; + margin-inline: 0.75rem; + color: var(--breadcrumb-separator); 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 { - color: var(--accent); + color: var(--breadcrumb-accent); text-decoration: none; - transition: text-decoration 0.2s; } - .secondary { - color: var(--secondary); + .link:hover .breadcrumb-content { + 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() { 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 style="--index: ${this.index}"> + ${when( + this.index > 0, + () => html` + <span class="separator" aria-hidden="true"> + <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" /> + </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> `; } diff --git a/src/components/ForumPost.ts b/src/components/ForumPost.ts deleted file mode 100644 index ab58311..0000000 --- a/src/components/ForumPost.ts +++ /dev/null @@ -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> - `; - } -} diff --git a/src/components/Prompt.ts b/src/components/Prompt.ts new file mode 100644 index 0000000..44b0bb3 --- /dev/null +++ b/src/components/Prompt.ts @@ -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; + } +} diff --git a/src/routes/Arbor/Home.ts b/src/routes/Arbor/Home.ts new file mode 100644 index 0000000..f939751 --- /dev/null +++ b/src/routes/Arbor/Home.ts @@ -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> + `, + )} + `; + } +} diff --git a/src/routes/Phora/NewPost.ts b/src/routes/Arbor/NewPost.ts similarity index 75% rename from src/routes/Phora/NewPost.ts rename to src/routes/Arbor/NewPost.ts index bba6e52..acd278e 100644 --- a/src/routes/Phora/NewPost.ts +++ b/src/routes/Arbor/NewPost.ts @@ -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> `; diff --git a/src/routes/Phora/NewTopic.ts b/src/routes/Arbor/NewTopic.ts similarity index 90% rename from src/routes/Phora/NewTopic.ts rename to src/routes/Arbor/NewTopic.ts index 630c7c9..f508e82 100644 --- a/src/routes/Phora/NewTopic.ts +++ b/src/routes/Arbor/NewTopic.ts @@ -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> `; } diff --git a/src/routes/Arbor/TopicView.ts b/src/routes/Arbor/TopicView.ts new file mode 100644 index 0000000..94e8fc3 --- /dev/null +++ b/src/routes/Arbor/TopicView.ts @@ -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> + `; + } +} diff --git a/src/routes/Home.ts b/src/routes/Home.ts index ad29897..baa3563 100644 --- a/src/routes/Home.ts +++ b/src/routes/Home.ts @@ -39,8 +39,8 @@ export class Home extends LitElement { }, { id: 3, - href: 'phora', - name: 'Phora', + href: 'arbor', + name: 'Arbor', color: '#FF3B30', icon: 'bxs:conversation', }, diff --git a/src/routes/Phora/Home.ts b/src/routes/Phora/Home.ts deleted file mode 100644 index de16989..0000000 --- a/src/routes/Phora/Home.ts +++ /dev/null @@ -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> - `, - )} - `; - } -} diff --git a/src/routes/Phora/NewCategory.ts b/src/routes/Phora/NewCategory.ts deleted file mode 100644 index f95bfef..0000000 --- a/src/routes/Phora/NewCategory.ts +++ /dev/null @@ -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> - `; - } -} diff --git a/src/routes/Phora/TopicView.ts b/src/routes/Phora/TopicView.ts deleted file mode 100644 index 9b2052a..0000000 --- a/src/routes/Phora/TopicView.ts +++ /dev/null @@ -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> - `; - } -} diff --git a/src/routes/router.ts b/src/routes/router.ts index 7bc3fb7..7b3fb0b 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -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',