From fc94dbcdcb7b32b4fd0892e1d9878aeaffad20fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nu=C5=A1a=20Puk=C5=A1i=C4=8D?= Date: Sat, 11 Oct 2025 21:53:35 +0200 Subject: [PATCH] Comments on articles --- .../controllers/nostr_comment_controller.js | 170 ++++++++++++++++++ assets/styles/02-layout/header.css | 4 + assets/styles/04-pages/forum.css | 23 ++- src/Controller/CommentController.php | 32 ++++ src/Twig/Components/Organisms/CommentForm.php | 30 ++++ .../Organisms/CommentForm.html.twig | 18 ++ templates/pages/article.html.twig | 18 ++ 7 files changed, 283 insertions(+), 12 deletions(-) create mode 100644 assets/controllers/nostr_comment_controller.js create mode 100644 src/Controller/CommentController.php create mode 100644 src/Twig/Components/Organisms/CommentForm.php create mode 100644 templates/components/Organisms/CommentForm.html.twig diff --git a/assets/controllers/nostr_comment_controller.js b/assets/controllers/nostr_comment_controller.js new file mode 100644 index 0000000..6b3d7b9 --- /dev/null +++ b/assets/controllers/nostr_comment_controller.js @@ -0,0 +1,170 @@ +import { Controller } from '@hotwired/stimulus'; + +// NIP-22 Comment Publishing Controller +// Usage: Attach to a form with data attributes for root/parent context +export default class extends Controller { + static targets = ['publishButton', 'status']; + static values = { + publishUrl: String, + csrfToken: String + }; + + connect() { + console.log('Nostr comment controller connected'); + try { + console.debug('[nostr-comment] publishUrl:', this.publishUrlValue || '(none)'); + console.debug('[nostr-comment] has csrfToken:', Boolean(this.csrfTokenValue)); + } catch (_) {} + } + + async publish(event) { + event.preventDefault(); + + if (!this.publishUrlValue) { + this.showError('Publish URL is not configured'); + return; + } + if (!this.csrfTokenValue) { + this.showError('Missing CSRF token'); + return; + } + if (!window.nostr) { + this.showError('Nostr extension not found'); + return; + } + + this.publishButtonTarget.disabled = true; + this.showStatus('Preparing comment for signing...'); + + try { + // Collect form data and context + const formData = this.collectFormData(); + + // Validate required fields + if (!formData.content) { + throw new Error('Comment content is required'); + } + if (!formData.root || !formData.parent) { + throw new Error('Missing root or parent context'); + } + if (!this.isPlaintext(formData.content)) { + throw new Error('Comment must be plaintext (no formatting)'); + } + + // Create NIP-22 event + const nostrEvent = await this.createNip22Event(formData); + + this.showStatus('Requesting signature from Nostr extension...'); + const signedEvent = await window.nostr.signEvent(nostrEvent); + + this.showStatus('Publishing comment...'); + await this.sendToBackend(signedEvent, formData); + + this.showSuccess('Comment published successfully!'); + // Optionally reload or clear form + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + console.error('Publishing error:', error); + this.showError(`Publishing failed: ${error.message}`); + } finally { + this.publishButtonTarget.disabled = false; + } + } + + collectFormData() { + // Use the form element directly (this.element is the
) + const form = this.element; + if (!form) { + throw new Error('Form element not found'); + } + const formData = new FormData(form); + // Comment content (plaintext only) + const content = (formData.get('comment[content]') || '').trim(); + // Root and parent context (JSON in hidden fields or data attributes) + let root, parent; + try { + root = JSON.parse(formData.get('comment[root]') || this.element.dataset.root || '{}'); + parent = JSON.parse(formData.get('comment[parent]') || this.element.dataset.parent || '{}'); + } catch (_) { + throw new Error('Invalid root/parent context'); + } + return { content, root, parent }; + } + + isPlaintext(text) { + // No HTML tags, no Markdown formatting + return !(/[<>\*\_\`\[\]#]/.test(text)); + } + + async createNip22Event({ content, root, parent }) { + // Get user's public key + const pubkey = await window.nostr.getPublicKey(); + const created_at = Math.floor(Date.now() / 1000); + // Build tags according to NIP-22 + const tags = []; + // Root tags (uppercase) + if (root.tag && root.value) { + const rootTag = [root.tag.toUpperCase(), root.value]; + if (root.relay) rootTag.push(root.relay); + if (root.pubkey) rootTag.push(root.pubkey); + tags.push(rootTag); + } + if (root.kind) tags.push(['K', String(root.kind)]); + if (root.pubkey) tags.push(['P', root.pubkey, root.relay || '']); + // Parent tags (lowercase) + if (parent.tag && parent.value) { + const parentTag = [parent.tag.toLowerCase(), parent.value]; + if (parent.relay) parentTag.push(parent.relay); + if (parent.pubkey) parentTag.push(parent.pubkey); + tags.push(parentTag); + } + if (parent.kind) tags.push(['k', String(parent.kind)]); + if (parent.pubkey) tags.push(['p', parent.pubkey, parent.relay || '']); + // NIP-22 event + return { + kind: 1111, + created_at, + tags, + content, + pubkey + }; + } + + async sendToBackend(signedEvent, formData) { + const response = await fetch(this.publishUrlValue, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRF-TOKEN': this.csrfTokenValue + }, + body: JSON.stringify({ + event: signedEvent, + formData: formData + }) + }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } + + showStatus(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + showSuccess(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } + showError(message) { + if (this.hasStatusTarget) { + this.statusTarget.innerHTML = `
${message}
`; + } + } +} diff --git a/assets/styles/02-layout/header.css b/assets/styles/02-layout/header.css index a85735c..83b3121 100644 --- a/assets/styles/02-layout/header.css +++ b/assets/styles/02-layout/header.css @@ -66,6 +66,10 @@ margin: 10px 0; } +.header__logo { + margin-left: var(--spacing-3); +} + .header__image { position: relative; width: 100%; diff --git a/assets/styles/04-pages/forum.css b/assets/styles/04-pages/forum.css index 21e35a2..4c167c2 100644 --- a/assets/styles/04-pages/forum.css +++ b/assets/styles/04-pages/forum.css @@ -60,22 +60,21 @@ margin: 1rem 0; } -@media (max-width: 960px) { - .subcategories-grid .tags { - justify-content: flex-start; - } +.subcategories-grid .tags { + justify-content: flex-start; +} - .subcategories-grid .tag { - padding: 0; - background-color: transparent; - color: var(--color-primary); - } +.subcategories-grid .tag { + padding: 0; + background-color: transparent; + color: var(--color-primary); +} - .subcategories-grid .tag:before { - content: '#'; - } +.subcategories-grid .tag:before { + content: '#'; } + .sub-card { border: 1px solid var(--color-primary); background: #fff; diff --git a/src/Controller/CommentController.php b/src/Controller/CommentController.php new file mode 100644 index 0000000..c6c796a --- /dev/null +++ b/src/Controller/CommentController.php @@ -0,0 +1,32 @@ +headers->get('X-CSRF-TOKEN'); + if (!$csrfToken || !$csrfTokenManager->isTokenValid(new \Symfony\Component\Security\Csrf\CsrfToken('comment_publish', $csrfToken))) { + return new JsonResponse(['message' => 'Invalid CSRF token'], 403); + } + + $data = json_decode($request->getContent(), true); + if (!$data || !isset($data['event'])) { + return new JsonResponse(['message' => 'Invalid request'], 400); + } + + // Here you would validate and process the NIP-22 event + // For now, just return success for integration testing + return new JsonResponse(['status' => 'ok']); + } +} + diff --git a/src/Twig/Components/Organisms/CommentForm.php b/src/Twig/Components/Organisms/CommentForm.php new file mode 100644 index 0000000..805e7ed --- /dev/null +++ b/src/Twig/Components/Organisms/CommentForm.php @@ -0,0 +1,30 @@ +publish_url = $publish_url; + $this->csrf_token = $csrf_token; + $this->root_context = $root_context; + $this->parent_context = $parent_context; + $this->form_id = $form_id ?? uniqid('nip22_comment_'); + } +} + + diff --git a/templates/components/Organisms/CommentForm.html.twig b/templates/components/Organisms/CommentForm.html.twig new file mode 100644 index 0000000..2ccabb4 --- /dev/null +++ b/templates/components/Organisms/CommentForm.html.twig @@ -0,0 +1,18 @@ + +
+
+ + +
+ + +
+ +
+
diff --git a/templates/pages/article.html.twig b/templates/pages/article.html.twig index c2e7f3d..b031f2b 100644 --- a/templates/pages/article.html.twig +++ b/templates/pages/article.html.twig @@ -82,6 +82,24 @@ + + {% endblock %}