Enhance app with multi-CCN support and improved UX

Features:

 Add support for multiple CCNs
🔍 Implement sidebar hiding functionality
🎨 Revamp navigation system for better flow
🖌️ Replace icons with custom-designed assets and improved naming
🚀 Streamline initial setup process
📝 Refine terminology (e.g., "forum thread" → "topic")
🛠️ Enhance forum usability and interaction
This commit is contained in:
Danny Morabito 2025-04-11 22:26:00 +02:00
parent bf3c950da0
commit 9893945f55
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
34 changed files with 792 additions and 308 deletions

View file

@ -5,14 +5,14 @@ import { customElement, state } from 'lit/decorators.js';
import { map } from 'lit/directives/map.js';
import { when } from 'lit/directives/when.js';
import '@components/Breadcrumbs';
import '@components/Arbor/ForumTopic';
import '@components/Arbor/ForumCategory';
import '@components/Arbor/ForumThread';
import '@components/Breadcrumbs';
import '@components/General/Prompt';
import type { EvePrompt } from '@components/General/Prompt';
interface ForumTopic {
interface ForumThread {
id: string;
title: string;
author: string;
@ -23,7 +23,7 @@ interface ForumTopic {
interface ForumCategory {
id: string;
name: string;
topics: ForumTopic[];
threads: ForumThread[];
}
@customElement('arx-arbor-home')
@ -33,7 +33,7 @@ export class ArborForum extends LitElement {
private isSaving = false;
private topicsQuery: NDKSubscription | undefined;
private threadsQuery: NDKSubscription | undefined;
@state()
private forum: NDKEvent | null = null;
@ -42,6 +42,10 @@ export class ArborForum extends LitElement {
:host {
display: block;
}
arx-button {
margin-bottom: calc(var(--spacing-sm) / 2);
}
`;
override async connectedCallback() {
@ -92,11 +96,11 @@ export class ArborForum extends LitElement {
{
id: dtag,
name: newCategory,
topics: [],
threads: [],
},
];
this.loadTopics();
this.loadThreads();
} catch (error) {
console.error('Failed to create category:', error);
alert('Failed to create category');
@ -128,17 +132,17 @@ export class ArborForum extends LitElement {
{
id: categoryId,
name: tag[2],
topics: [],
threads: [],
},
];
}
}
this.loadTopics();
this.loadThreads();
}
private async loadTopics() {
if (this.topicsQuery) this.topicsQuery.stop();
this.topicsQuery = ndk
private async loadThreads() {
if (this.threadsQuery) this.threadsQuery.stop();
this.threadsQuery = ndk
.subscribe({
kinds: [892 as NDKKind],
'#d': this.categories.map((category) => `60891:${category.id}`),
@ -148,9 +152,9 @@ export class ArborForum extends LitElement {
(category) => category.id === event.tags.find((tag) => tag[0] === 'd')?.[1].split(':')[1],
);
if (categoryId === -1) return;
if (this.categories[categoryId].topics.find((topic) => topic.id === event.id)) return;
this.categories[categoryId].topics = [
...this.categories[categoryId].topics,
if (this.categories[categoryId].threads.find((thread) => thread.id === event.id)) return;
this.categories[categoryId].threads = [
...this.categories[categoryId].threads,
{
id: event.id,
title: event.tags.find((tag) => tag[0] === 'name')?.[1] || '',
@ -187,18 +191,18 @@ export class ArborForum extends LitElement {
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> `)}
${when(category.threads.length === 0, () => html` <div style="padding: 1rem 1.5rem">No threads...</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}
category.threads,
(thread) => html`
<arx-arbor-forum-thread
id=${thread.id}
title=${thread.title}
description=${thread.description}
lastPostBy=${thread.author}
lastPostTime=${thread.created_at}
>
</arx-arbor-forum-topic>
</arx-arbor-forum-thread>
`,
)}
</arx-arbor-forum-category>

View file

@ -9,13 +9,13 @@ import '@components/General/Textarea';
@customElement('arx-arbor-post-creator')
export class ArborPostCreator extends LitElement {
@property({ type: String })
topicId = '';
threadId = '';
@state()
private postContent = '';
@state()
private topic: NDKEvent | null = null;
private thread: NDKEvent | null = null;
@state()
private isCreating = false;
@ -34,7 +34,7 @@ export class ArborPostCreator extends LitElement {
gap: 1rem;
}
.topic-id {
.thread-id {
color: #666;
font-family: monospace;
}
@ -58,24 +58,24 @@ export class ArborPostCreator extends LitElement {
override connectedCallback() {
super.connectedCallback();
this.loadTopic();
this.loadThread();
}
private async loadTopic() {
private async loadThread() {
try {
await getSigner();
this.topic = await ndk.fetchEvent(this.topicId);
if (!this.topic) {
throw new Error('Could not load topic');
this.thread = await ndk.fetchEvent(this.threadId);
if (!this.thread) {
throw new Error('Could not load thread');
}
} catch (error) {
console.error('Failed to load topic:', error);
console.error('Failed to load thread:', error);
}
}
private async doCreatePost() {
if (this.isCreating) return;
if (!this.topic) return;
if (!this.thread) return;
if (this.postContent.length < 10) {
this.error = 'Post content must be at least 10 characters long';
@ -88,9 +88,9 @@ export class ArborPostCreator extends LitElement {
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],
['A', this.thread.tags.find((tag) => tag[0] === 'd')?.[1] || ''],
['E', this.threadId],
['p', this.thread.pubkey],
];
event.content = this.postContent;
@ -117,7 +117,7 @@ export class ArborPostCreator extends LitElement {
override render() {
return html`
<div class="container">
<div class="topic-id">Topic ID: ${this.topicId}</div>
<div class="thread-id">Thread ID: ${this.threadId}</div>
<arx-textarea
placeholder="Post. You can use Markdown here."

View file

@ -8,16 +8,16 @@ import '@components/General/Button';
import '@components/General/Input';
import '@components/General/Textarea';
@customElement('arx-arbor-topic-creator')
export class ArborTopicCreator extends LitElement {
@customElement('arx-arbor-thread-creator')
export class ArborThreadCreator extends LitElement {
@property({ type: String })
categoryId = '';
@state()
private newTopic = '';
private newThread = '';
@state()
private topicContent = '';
private threadContent = '';
@state()
private isCreating = false;
@ -39,16 +39,16 @@ export class ArborTopicCreator extends LitElement {
}
`;
private async doCreateTopic() {
private async doCreateThread() {
if (this.isCreating) return;
if (this.newTopic.length < 3) {
alert('Topic title must be at least 3 characters long');
if (this.newThread.length < 3) {
alert('Thread title must be at least 3 characters long');
return;
}
if (this.topicContent.length < 10) {
alert('Topic content must be at least 10 characters long');
if (this.threadContent.length < 10) {
alert('Thread content must be at least 10 characters long');
return;
}
@ -59,40 +59,27 @@ export class ArborTopicCreator extends LitElement {
const event = new NDKEvent(ndk);
event.kind = 892;
event.tags = [
['name', this.newTopic],
['name', this.newThread],
['d', `60891:${this.categoryId}`],
];
event.content = this.topicContent;
event.content = this.threadContent;
await event.sign();
await event.publish();
this.dispatchEvent(
new CustomEvent('topic-created', {
bubbles: true,
composed: true,
detail: {
topicId: event.id,
categoryId: this.categoryId,
},
}),
);
this.newTopic = '';
this.topicContent = '';
location.hash = `/arbor/${event.id}`;
} catch (error) {
console.error('Failed to create topic:', error);
alert('Failed to create topic');
console.error('Failed to create thread:', error);
alert('Failed to create thread');
} finally {
this.isCreating = false;
}
}
private handleTopicInput(e: ArxInputChangeEvent) {
this.newTopic = e.detail.value;
private handleThreadInput(e: ArxInputChangeEvent) {
this.newThread = e.detail.value;
}
private handleContentInput(e: ArxInputChangeEvent) {
this.topicContent = e.detail.value;
this.threadContent = e.detail.value;
}
override render() {
@ -101,15 +88,15 @@ export class ArborTopicCreator extends LitElement {
<arx-input
type="text"
placeholder="New Topic"
.value=${this.newTopic}
@change=${this.handleTopicInput}
placeholder="New Thread"
.value=${this.newThread}
@change=${this.handleThreadInput}
?disabled=${this.isCreating}
></arx-input>
<arx-textarea
placeholder="Topic. You can use Markdown here."
.value=${this.topicContent}
placeholder="Thread. You can use Markdown here."
.value=${this.threadContent}
@change=${this.handleContentInput}
?disabled=${this.isCreating}
></arx-textarea>
@ -123,7 +110,7 @@ export class ArborTopicCreator extends LitElement {
<arx-button
label=${this.isCreating ? 'Creating...' : 'Create'}
@click=${this.doCreateTopic}
@click=${this.doCreateThread}
?disabled=${this.isCreating}
>
</arx-button>

View file

@ -3,8 +3,8 @@ 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/Breadcrumbs';
import '@components/General/Button';
import { map } from 'lit/directives/map.js';
@ -17,10 +17,10 @@ interface ForumPost {
content: string;
}
@customElement('arx-arbor-topic-view')
export class ArborTopicView extends LitElement {
@customElement('arx-arbor-thread-view')
export class ArborThreadView extends LitElement {
@property({ type: String })
topicId = '';
threadId = '';
@state()
override title = '';
@ -36,7 +36,7 @@ export class ArborTopicView extends LitElement {
margin: 0 auto;
}
.topic-container {
.thread-container {
background: var(--color-base-100);
border-radius: var(--radius-box);
border: var(--border) solid var(--color-base-300);
@ -144,7 +144,7 @@ export class ArborTopicView extends LitElement {
override async connectedCallback() {
super.connectedCallback();
await this.loadTopic();
await this.loadThread();
}
override disconnectedCallback() {
@ -154,13 +154,13 @@ export class ArborTopicView extends LitElement {
}
}
private async loadTopic() {
private async loadThread() {
try {
await getSigner();
const event = await ndk.fetchEvent(this.topicId);
const event = await ndk.fetchEvent(this.threadId);
if (!event) {
throw new Error('Could not load topic');
throw new Error('Could not load thread');
}
this.title = event.tags.find((tag) => tag[0] === 'name')?.[1] || '';
@ -178,7 +178,7 @@ export class ArborTopicView extends LitElement {
this.subscription = ndk
.subscribe({
kinds: [893 as NDKKind],
'#E': [this.topicId],
'#E': [this.threadId],
})
.on('event', (event) => {
this.posts = [
@ -192,8 +192,8 @@ export class ArborTopicView extends LitElement {
];
});
} catch (error) {
console.error('Failed to load topic:', error);
alert('Could not load topic');
console.error('Failed to load thread:', error);
alert('Could not load thread');
}
}
@ -203,9 +203,9 @@ export class ArborTopicView extends LitElement {
return html`
<arx-breadcrumbs .items=${breadcrumbItems}></arx-breadcrumbs>
<div class="topic-container">
<div class="thread-container">
<div class="header">
<span>${this.title || 'Loading topic...'}</span>
<span>${this.title || 'Loading thread...'}</span>
</div>
<div class="posts-container">
@ -218,7 +218,7 @@ export class ArborTopicView extends LitElement {
(post) => html`
<arx-forum-post
id=${post.id}
.topicId=${this.topicId}
.threadId=${this.threadId}
.npub=${post.npub}
.date=${post.date}
.content=${post.content}
@ -229,7 +229,7 @@ export class ArborTopicView extends LitElement {
</div>
<div class="actions">
<arx-button label="New Post" href="/arbor/new-post/${this.topicId}">
<arx-button label="New Post" href="/arbor/new-post/${this.threadId}">
New Post
</arx-button>
</div>

View file

@ -26,56 +26,57 @@ export class Home extends LitElement {
href: 'letters',
name: 'Letters',
color: '#FF3E96',
icon: 'fa-solid:leaf',
icon: 'arx:letters',
},
{
id: 1,
href: 'messages',
name: 'Murmur',
href: 'howl',
name: 'Howl',
color: '#00CD66',
icon: 'fa-solid:seedling',
icon: 'arx:howl',
},
{
id: 2,
href: 'beacon',
name: 'Beacon',
href: 'calendar',
name: 'Calendar',
color: '#FF8C00',
icon: 'fa-solid:sun',
icon: 'arx:calendar',
},
{
id: 3,
href: 'arbor',
name: 'Arbor',
color: '#FF4040',
icon: 'fa-solid:tree',
icon: 'arx:arbor',
},
{
id: 5,
href: 'grove',
name: 'Grove',
href: 'market',
name: 'Market',
color: '#9370DB',
icon: 'fa-solid:store-alt',
icon: 'arx:market',
},
{
id: 6,
href: 'wallet',
name: 'Wallet',
color: '#1E90FF',
icon: 'fa-solid:spa',
icon: 'arx:wallet',
},
{
id: 7,
href: 'oracle',
name: 'Oracle',
color: '#FFD700',
icon: 'bxs:landscape',
href: 'pool',
name: 'Pool',
color: '#7aD700',
icon: 'arx:pool',
},
{
id: 8,
href: 'settings',
name: 'Settings',
color: '#7B68EE',
icon: 'fa-solid:tools',
icon: 'arx:settings',
},
},
];

View file

@ -3,8 +3,8 @@ import '@components/Sidebar';
import '@routes/404Page';
import '@routes/Arbor/Home';
import '@routes/Arbor/NewPost';
import '@routes/Arbor/NewTopic';
import '@routes/Arbor/TopicView';
import '@routes/Arbor/NewThread';
import '@routes/Arbor/ThreadView';
import '@routes/Calendar';
import '@routes/Home';
import '@routes/Profile';
@ -16,6 +16,7 @@ import type { NDKUserProfile } from '@nostr-dev-kit/ndk';
import { spread } from '@open-wc/lit-helpers';
import { LitElement, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { keyed } from 'lit/directives/keyed.js';
import { type Ref, createRef, ref } from 'lit/directives/ref.js';
import { when } from 'lit/directives/when.js';
@ -42,7 +43,7 @@ export default class EveRouter extends LitElement {
component: literal`arx-eve-home`,
},
{
pattern: 'beacon',
pattern: 'calendar',
params: {},
component: literal`arx-calendar-route`,
},
@ -57,20 +58,25 @@ export default class EveRouter extends LitElement {
component: literal`arx-arbor-home`,
},
{
pattern: 'arbor/new-topic/:categoryId',
pattern: 'arbor/new-thread/:categoryId',
params: {},
component: literal`arx-arbor-topic-creator`,
component: literal`arx-arbor-thread-creator`,
},
{
pattern: 'arbor/topics/:topicId',
pattern: 'arbor/threads/:threadId',
params: {},
component: literal`arx-arbor-topic-view`,
component: literal`arx-arbor-thread-view`,
},
{
pattern: 'arbor/new-post/:topicId',
pattern: 'arbor/new-post/:threadId',
params: {},
component: literal`arx-arbor-post-creator`,
},
{
pattern: 'howl',
params: {},
component: literal`arx-howl-route`,
},
{
pattern: 'settings',
params: {},
@ -112,6 +118,9 @@ export default class EveRouter extends LitElement {
@state()
private userNpub = '';
@state()
private sidebarVisible = true;
static override styles = css`
:host {
position: fixed;
@ -121,8 +130,13 @@ export default class EveRouter extends LitElement {
height: 100%;
display: grid;
grid-template-rows: auto 1fr;
grid-template-columns: 100px 1fr;
grid-template-columns: auto 1fr;
overflow: hidden;
transition: grid-template-columns 0.3s ease;
}
:host([sidebar-hidden]) {
grid-template-columns: 0 1fr;
}
::-webkit-scrollbar {
@ -173,6 +187,21 @@ export default class EveRouter extends LitElement {
arx-sidebar {
grid-column: 1;
grid-row: 1 / span 2;
transition: width 0.3s ease, opacity 0.3s ease, padding 0.3s ease, transform 0.3s ease;
width: 100px;
opacity: 1;
overflow: hidden;
background: var(--color-base-100);
border-right: 1px solid var(--color-base-300);
}
arx-sidebar.hidden {
width: 0;
opacity: 0;
padding: 0;
pointer-events: none;
transform: translateX(-20px);
border-right: none;
}
.window-content {
@ -187,6 +216,7 @@ export default class EveRouter extends LitElement {
transition: var(--transition);
backface-visibility: hidden;
filter: blur(0px);
grid-row: 1;
}
.window-content::after {
@ -218,6 +248,44 @@ export default class EveRouter extends LitElement {
grid-column: 2;
grid-row: 1;
}
.sidebar-toggle {
position: fixed;
top: 50%;
left: 100px;
transform: translateY(-50%);
z-index: 1000;
background: color-mix(in srgb, var(--color-primary) 70%, transparent);
backdrop-filter: blur(5px);
color: var(--color-base);
border: none;
width: 20px;
height: 50px;
border-top-right-radius: var(--radius-btn);
border-bottom-right-radius: var(--radius-btn);
border-top-left-radius: 0;
border-bottom-left-radius: 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all var(--transition);
box-shadow: 2px 0 5px rgba(0,0,0,0.1);
}
:host([sidebar-hidden]) .sidebar-toggle {
left: 0;
border-top-left-radius: var(--radius-btn);
border-bottom-left-radius: var(--radius-btn);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.sidebar-toggle:hover {
/* background: var(--color-primary-focus); */
background: color-mix(in srgb, var(--color-primary) 90%, transparent);
}
`;
constructor() {
@ -233,6 +301,7 @@ export default class EveRouter extends LitElement {
if (this.ccnSetup) {
this.loadUserProfile();
}
this.updateSidebarAttribute();
}
override disconnectedCallback(): void {
@ -403,11 +472,36 @@ export default class EveRouter extends LitElement {
}
}
private toggleSidebar() {
this.sidebarVisible = !this.sidebarVisible;
this.updateSidebarAttribute();
}
private updateSidebarAttribute() {
if (this.sidebarVisible) {
this.removeAttribute('sidebar-hidden');
} else {
this.setAttribute('sidebar-hidden', '');
}
}
override render() {
if (!this.ccnSetup) return this.renderSetup();
return html`
<button class="sidebar-toggle" @click=${this.toggleSidebar} title=${when(
this.sidebarVisible,
() => 'Hide Sidebar',
() => 'Show Sidebar',
)}>
${when(
this.sidebarVisible,
() => html`<iconify-icon icon="mdi:chevron-left"></iconify-icon>`,
() => html`<iconify-icon icon="mdi:chevron-right"></iconify-icon>`,
)}
</button>
<arx-sidebar
class=${classMap({ hidden: !this.sidebarVisible })}
.currentPath=${this.currentPath}
.userNpub=${this.userNpub}
.userProfile=${this.userProfile}
@ -422,8 +516,8 @@ export default class EveRouter extends LitElement {
@go-forward=${this.goForward}
title="Eve"
></arx-header>
<div class="window ${this.currentRoute.pattern === 'home' ? 'hide-overflow' : ''}">
<div ${ref(this.windowContentRef)} class="window-content ${this.isTransitioning ? 'transitioning' : ''}">
<div class=${classMap({ window: true, 'hide-overflow': this.currentRoute.pattern === 'home' })}>
<div ${ref(this.windowContentRef)} class=${classMap({ 'window-content': true, transitioning: this.isTransitioning })}>
${when(
this.isTransitioning,
() => html`<arx-loading-view></arx-loading-view>`,