18 changed files with 909 additions and 104 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
{% 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