18 changed files with 909 additions and 104 deletions
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
export default class extends Controller { |
||||
static targets = ["copyButton", "textToCopy"]; |
||||
|
||||
copyToClipboard(event) { |
||||
event.preventDefault(); |
||||
const text = this.textToCopyTarget.textContent; |
||||
navigator.clipboard.writeText(text).then(() => { |
||||
this.copyButtonTarget.textContent = "Copied!"; |
||||
setTimeout(() => { |
||||
this.copyButtonTarget.textContent = "Copy to Clipboard"; |
||||
}, 2000); |
||||
}).catch(err => { |
||||
console.error('Failed to copy: ', err); |
||||
}); |
||||
} |
||||
} |
||||
@ -1,16 +0,0 @@
@@ -1,16 +0,0 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
/* |
||||
* This is an example Stimulus controller! |
||||
* |
||||
* Any element with a data-controller="hello" attribute will cause |
||||
* this controller to be executed. The name "hello" comes from the filename: |
||||
* hello_controller.js -> "hello" |
||||
* |
||||
* Delete this file or adapt it for your use! |
||||
*/ |
||||
export default class extends Controller { |
||||
connect() { |
||||
this.element.textContent = 'Hello Stimulus! Edit me in assets/controllers/hello_controller.js'; |
||||
} |
||||
} |
||||
@ -0,0 +1,246 @@
@@ -0,0 +1,246 @@
|
||||
import { Controller } from '@hotwired/stimulus'; |
||||
|
||||
export default class extends Controller { |
||||
static targets = ['form', 'publishButton', 'status']; |
||||
static values = { |
||||
publishUrl: String, |
||||
csrfToken: String |
||||
}; |
||||
|
||||
connect() { |
||||
console.log('Nostr publish controller connected'); |
||||
this.checkNostrSupport(); |
||||
} |
||||
|
||||
checkNostrSupport() { |
||||
if (!window.nostr) { |
||||
this.showError('Nostr extension not found. Please install a Nostr browser extension like nos2x or Alby.'); |
||||
this.publishButtonTarget.disabled = true; |
||||
} |
||||
} |
||||
|
||||
async publish(event) { |
||||
event.preventDefault(); |
||||
|
||||
if (!window.nostr) { |
||||
this.showError('Nostr extension not found'); |
||||
return; |
||||
} |
||||
|
||||
this.publishButtonTarget.disabled = true; |
||||
this.showStatus('Preparing article for signing...'); |
||||
|
||||
try { |
||||
// Collect form data
|
||||
const formData = this.collectFormData(); |
||||
|
||||
// Validate required fields
|
||||
if (!formData.title || !formData.content) { |
||||
throw new Error('Title and content are required'); |
||||
} |
||||
|
||||
// Create Nostr event
|
||||
const nostrEvent = await this.createNostrEvent(formData); |
||||
|
||||
this.showStatus('Requesting signature from Nostr extension...'); |
||||
|
||||
// Sign the event with Nostr extension
|
||||
const signedEvent = await window.nostr.signEvent(nostrEvent); |
||||
|
||||
this.showStatus('Publishing article...'); |
||||
|
||||
// Send to backend
|
||||
await this.sendToBackend(signedEvent, formData); |
||||
|
||||
this.showSuccess('Article published successfully!'); |
||||
|
||||
// Optionally redirect after successful publish
|
||||
setTimeout(() => { |
||||
window.location.href = `/article/d/${formData.slug}`; |
||||
}, 2000); |
||||
|
||||
} catch (error) { |
||||
console.error('Publishing error:', error); |
||||
this.showError(`Publishing failed: ${error.message}`); |
||||
} finally { |
||||
this.publishButtonTarget.disabled = false; |
||||
} |
||||
} |
||||
|
||||
collectFormData() { |
||||
// Find the actual form element within our target
|
||||
const form = this.formTarget.querySelector('form'); |
||||
if (!form) { |
||||
throw new Error('Form element not found'); |
||||
} |
||||
|
||||
const formData = new FormData(form); |
||||
|
||||
// Get content from Quill editor if available
|
||||
const quillEditor = document.querySelector('.ql-editor'); |
||||
let content = formData.get('editor[content]') || ''; |
||||
|
||||
// Convert HTML to markdown (basic conversion)
|
||||
content = this.htmlToMarkdown(content); |
||||
|
||||
const title = formData.get('editor[title]') || ''; |
||||
const summary = formData.get('editor[summary]') || ''; |
||||
const image = formData.get('editor[image]') || ''; |
||||
const topicsString = formData.get('editor[topics]') || ''; |
||||
|
||||
// Parse topics
|
||||
const topics = topicsString.split(',') |
||||
.map(topic => topic.trim()) |
||||
.filter(topic => topic.length > 0) |
||||
.map(topic => topic.startsWith('#') ? topic : `#${topic}`); |
||||
|
||||
// Generate slug from title
|
||||
const slug = this.generateSlug(title); |
||||
|
||||
return { |
||||
title, |
||||
summary, |
||||
content, |
||||
image, |
||||
topics, |
||||
slug |
||||
}; |
||||
} |
||||
|
||||
async createNostrEvent(formData) { |
||||
// Get user's public key
|
||||
const pubkey = await window.nostr.getPublicKey(); |
||||
|
||||
// Create tags array
|
||||
const tags = [ |
||||
['d', formData.slug], // NIP-33 replaceable event identifier
|
||||
['title', formData.title], |
||||
['published_at', Math.floor(Date.now() / 1000).toString()] |
||||
]; |
||||
|
||||
if (formData.summary) { |
||||
tags.push(['summary', formData.summary]); |
||||
} |
||||
|
||||
if (formData.image) { |
||||
tags.push(['image', formData.image]); |
||||
} |
||||
|
||||
// Add topic tags
|
||||
formData.topics.forEach(topic => { |
||||
tags.push(['t', topic.replace('#', '')]); |
||||
}); |
||||
|
||||
// Create the Nostr event (NIP-23 long-form content)
|
||||
const event = { |
||||
kind: 30023, // Long-form content kind
|
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: tags, |
||||
content: formData.content, |
||||
pubkey: pubkey |
||||
}; |
||||
|
||||
return event; |
||||
} |
||||
|
||||
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(); |
||||
} |
||||
|
||||
htmlToMarkdown(html) { |
||||
// Basic HTML to Markdown conversion
|
||||
// This is a simplified version - you might want to use a proper library
|
||||
let markdown = html; |
||||
|
||||
// Convert headers
|
||||
markdown = markdown.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n'); |
||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n'); |
||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n'); |
||||
|
||||
// Convert formatting
|
||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**'); |
||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**'); |
||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*'); |
||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*'); |
||||
markdown = markdown.replace(/<u[^>]*>(.*?)<\/u>/gi, '_$1_'); |
||||
|
||||
// Convert links
|
||||
markdown = markdown.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)'); |
||||
|
||||
// Convert lists
|
||||
markdown = markdown.replace(/<ul[^>]*>(.*?)<\/ul>/gis, '$1\n'); |
||||
markdown = markdown.replace(/<ol[^>]*>(.*?)<\/ol>/gis, '$1\n'); |
||||
markdown = markdown.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n'); |
||||
|
||||
// Convert paragraphs
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n'); |
||||
|
||||
// Convert line breaks
|
||||
markdown = markdown.replace(/<br[^>]*>/gi, '\n'); |
||||
|
||||
// Convert blockquotes
|
||||
markdown = markdown.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, '> $1\n\n'); |
||||
|
||||
// Convert code blocks
|
||||
markdown = markdown.replace(/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/gis, '```\n$1\n```\n\n'); |
||||
markdown = markdown.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`'); |
||||
|
||||
// Clean up HTML entities and remaining tags
|
||||
markdown = markdown.replace(/ /g, ' '); |
||||
markdown = markdown.replace(/&/g, '&'); |
||||
markdown = markdown.replace(/</g, '<'); |
||||
markdown = markdown.replace(/>/g, '>'); |
||||
markdown = markdown.replace(/"/g, '"'); |
||||
markdown = markdown.replace(/<[^>]*>/g, ''); // Remove any remaining HTML tags
|
||||
|
||||
// Clean up extra whitespace
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n').trim(); |
||||
|
||||
return markdown; |
||||
} |
||||
|
||||
generateSlug(title) { |
||||
return title |
||||
.toLowerCase() |
||||
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
} |
||||
|
||||
showStatus(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-info">${message}</div>`; |
||||
} |
||||
} |
||||
|
||||
showSuccess(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-success">${message}</div>`; |
||||
} |
||||
} |
||||
|
||||
showError(message) { |
||||
if (this.hasStatusTarget) { |
||||
this.statusTarget.innerHTML = `<div class="alert alert-danger">${message}</div>`; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Controller\Administration; |
||||
|
||||
use App\Service\RedisCacheService; |
||||
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; |
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire; |
||||
use Symfony\Component\HttpFoundation\Response; |
||||
use Symfony\Component\Routing\Attribute\Route; |
||||
use Symfony\Component\HttpFoundation\Request; |
||||
use Symfony\Component\HttpFoundation\RedirectResponse; |
||||
|
||||
class ArticleManagementController extends AbstractController |
||||
{ |
||||
// This controller will handle article management functionalities. |
||||
#[Route('/admin/articles', name: 'admin_articles')] |
||||
|
||||
public function listArticles( |
||||
#[Autowire(service: 'fos_elastica.finder.articles')] PaginatedFinderInterface $finder, |
||||
RedisCacheService $redisCacheService |
||||
): Response |
||||
{ |
||||
// Query: latest 50, deduplicated by slug, sorted by createdAt desc |
||||
$query = [ |
||||
'size' => 100, // fetch more to allow deduplication |
||||
'sort' => [ |
||||
['createdAt' => ['order' => 'desc']] |
||||
] |
||||
]; |
||||
$results = $finder->find($query); |
||||
$unique = []; |
||||
$articles = []; |
||||
foreach ($results as $article) { |
||||
$slug = $article->getSlug(); |
||||
if (!isset($unique[$slug])) { |
||||
$unique[$slug] = true; |
||||
$articles[] = $article; |
||||
if (count($articles) >= 50) break; |
||||
} |
||||
} |
||||
// Fetch main index and extract nested indexes |
||||
$mainIndex = $redisCacheService->getMagazineIndex('magazine-newsroom-magazine-by-newsroom'); |
||||
$indexes = []; |
||||
if ($mainIndex && $mainIndex->getTags() !== null) { |
||||
foreach ($mainIndex->getTags() as $tag) { |
||||
if ($tag[0] === 'a' && isset($tag[1])) { |
||||
$parts = explode(':', $tag[1], 3); |
||||
$indexes[$tag[1]] = end($parts); // Extract index key from tag |
||||
} |
||||
} |
||||
} |
||||
return $this->render('admin/articles.html.twig', [ |
||||
'articles' => $articles, |
||||
'indexes' => $indexes, |
||||
]); |
||||
} |
||||
|
||||
#[Route('/admin/articles/add-to-index', name: 'admin_article_add_to_index', methods: ['POST'])] |
||||
public function addToIndex( |
||||
Request $request, |
||||
RedisCacheService $redisCacheService |
||||
): RedirectResponse { |
||||
$slug = $request->request->get('slug'); |
||||
$indexKey = $request->request->get('index_key'); |
||||
if (!$slug || !$indexKey) { |
||||
$this->addFlash('danger', 'Missing article or index selection.'); |
||||
return $this->redirectToRoute('admin_articles'); |
||||
} |
||||
// Build the tag: ['a', 'article:'.$slug] |
||||
$articleTag = ['a', 'article:' . $slug]; |
||||
$success = $redisCacheService->addArticleToIndex($indexKey, $articleTag); |
||||
if ($success) { |
||||
$this->addFlash('success', 'Article added to index.'); |
||||
} else { |
||||
$this->addFlash('danger', 'Failed to add article to index.'); |
||||
} |
||||
return $this->redirectToRoute('admin_articles'); |
||||
} |
||||
} |
||||
@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
|
||||
{% extends 'base.html.twig' %} |
||||
|
||||
{% block body %} |
||||
<h1>Latest 50 Articles</h1> |
||||
<table> |
||||
<thead> |
||||
<tr> |
||||
<th>Title</th> |
||||
<th>Summary</th> |
||||
<th>Coordinate</th> |
||||
<th>Add to Index</th> |
||||
</tr> |
||||
</thead> |
||||
<tbody> |
||||
{% for article in articles %} |
||||
<tr> |
||||
<td><a href="{{ path('article-slug', {slug: article.slug|url_encode}) }}">{{ article.title }}</a></td> |
||||
<td>{{ article.summary|slice(0, 100) }}{% if article.summary|length > 100 %}...{% endif %}</td> |
||||
<td> |
||||
<span data-controller="copy-to-clipboard"> |
||||
<span class="hidden" data-copy-to-clipboard-target="textToCopy">30023:{{ article.pubkey }}:{{ article.slug }}</span> |
||||
<button type="button" |
||||
data-copy-to-clipboard-target="copyButton" |
||||
data-action="click->copy-to-clipboard#copyToClipboard" |
||||
style="margin-left: 0.5em;">Copy to Clipboard</button> |
||||
</span> |
||||
</td> |
||||
<td> |
||||
<form method="post" action="{{ path('admin_article_add_to_index') }}" style="display:inline;"> |
||||
<input type="hidden" name="slug" value="{{ article.slug }}"> |
||||
<label> |
||||
<select name="index_key" required> |
||||
<option value="">Select index</option> |
||||
{% for index in indexes %} |
||||
<option value="{{ index }}">{{ index }}</option> |
||||
{% endfor %} |
||||
</select> |
||||
</label> |
||||
<button type="submit">Add</button> |
||||
</form> |
||||
</td> |
||||
</tr> |
||||
{% else %} |
||||
<tr><td colspan="4">No articles found.</td></tr> |
||||
{% endfor %} |
||||
</tbody> |
||||
</table> |
||||
{% endblock %} |
||||
Loading…
Reference in new issue