8 changed files with 268 additions and 0 deletions
@ -0,0 +1,102 @@ |
|||||||
|
import { Controller } from '@hotwired/stimulus'; |
||||||
|
|
||||||
|
export default class extends Controller { |
||||||
|
static targets = ['publishButton', 'status']; |
||||||
|
static values = { |
||||||
|
publishUrl: String, |
||||||
|
csrfToken: String, |
||||||
|
eventData: Object |
||||||
|
}; |
||||||
|
|
||||||
|
connect() { |
||||||
|
console.log('Tabular data publish controller connected'); |
||||||
|
} |
||||||
|
|
||||||
|
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('Requesting signature from Nostr extension...'); |
||||||
|
|
||||||
|
try { |
||||||
|
// Prepare the event data
|
||||||
|
const eventData = this.eventDataValue; |
||||||
|
delete eventData.sig; // Remove sig if present
|
||||||
|
delete eventData.id; // Remove id if present
|
||||||
|
|
||||||
|
// Sign the event with Nostr extension
|
||||||
|
const signedEvent = await window.nostr.signEvent(eventData); |
||||||
|
|
||||||
|
this.showStatus('Publishing tabular data...'); |
||||||
|
|
||||||
|
// Send to backend
|
||||||
|
await this.sendToBackend(signedEvent); |
||||||
|
|
||||||
|
this.showSuccess('Tabular data published successfully!'); |
||||||
|
|
||||||
|
// Redirect to the event page
|
||||||
|
setTimeout(() => { |
||||||
|
window.location.href = `/e/${signedEvent.id}`; |
||||||
|
}, 2000); |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Publishing error:', error); |
||||||
|
this.showError(`Publishing failed: ${error.message}`); |
||||||
|
} finally { |
||||||
|
this.publishButtonTarget.disabled = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async sendToBackend(signedEvent) { |
||||||
|
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 |
||||||
|
}) |
||||||
|
}); |
||||||
|
|
||||||
|
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 = `<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,96 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Controller; |
||||||
|
|
||||||
|
use App\Enum\KindsEnum; |
||||||
|
use App\Form\TabularDataType; |
||||||
|
use App\Service\NostrClient; |
||||||
|
use swentel\nostr\Event\Event; |
||||||
|
use swentel\nostr\Sign\Sign; |
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; |
||||||
|
use Symfony\Component\HttpFoundation\Request; |
||||||
|
use Symfony\Component\HttpFoundation\Response; |
||||||
|
use Symfony\Component\Routing\Attribute\Route; |
||||||
|
|
||||||
|
class TabularDataController extends AbstractController |
||||||
|
{ |
||||||
|
#[Route('/tabular-data', name: 'tabular_data_publish')] |
||||||
|
public function publish(Request $request, NostrClient $nostrClient): Response |
||||||
|
{ |
||||||
|
$user = $this->getUser(); |
||||||
|
if (!$user) { |
||||||
|
return $this->redirectToRoute('login'); |
||||||
|
} |
||||||
|
|
||||||
|
$form = $this->createForm(TabularDataType::class); |
||||||
|
$form->handleRequest($request); |
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) { |
||||||
|
$data = $form->getData(); |
||||||
|
|
||||||
|
// Create the event |
||||||
|
$event = new Event(); |
||||||
|
$event->setKind(KindsEnum::TABULAR_DATA->value); |
||||||
|
$event->setContent($data['csvContent']); |
||||||
|
|
||||||
|
// Add tags |
||||||
|
$tags = [ |
||||||
|
['title', $data['title']], |
||||||
|
['m', 'text/csv'], |
||||||
|
['M', 'text/csv; charset=utf-8'], |
||||||
|
]; |
||||||
|
|
||||||
|
if (!empty($data['license'])) { |
||||||
|
$tags[] = ['license', $data['license']]; |
||||||
|
} |
||||||
|
|
||||||
|
if (!empty($data['units'])) { |
||||||
|
// Parse units, e.g., "col=2,EH/s" -> ['unit', 'col=2', 'EH/s'] |
||||||
|
$unitParts = explode(',', $data['units'], 2); |
||||||
|
if (count($unitParts) == 2) { |
||||||
|
$tags[] = ['unit', trim($unitParts[0]), trim($unitParts[1])]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
foreach ($tags as $tag) { |
||||||
|
$event->addTag($tag); |
||||||
|
} |
||||||
|
|
||||||
|
// For now, just render the event JSON |
||||||
|
return $this->render('tabular_data/preview.html.twig', [ |
||||||
|
'event' => $event, |
||||||
|
'data' => $data, |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
return $this->render('tabular_data/publish.html.twig', [ |
||||||
|
'form' => $form->createView(), |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
#[Route('/tabular-data/publish', name: 'tabular_data_publish_event', methods: ['POST'])] |
||||||
|
public function publishEvent(Request $request, NostrClient $nostrClient): Response |
||||||
|
{ |
||||||
|
$data = json_decode($request->getContent(), true); |
||||||
|
if (!$data || !isset($data['event'])) { |
||||||
|
return $this->json(['error' => 'Invalid data'], 400); |
||||||
|
} |
||||||
|
|
||||||
|
$signedEvent = $data['event']; |
||||||
|
|
||||||
|
// Validate the event |
||||||
|
if ($signedEvent['kind'] !== KindsEnum::TABULAR_DATA->value) { |
||||||
|
return $this->json(['error' => 'Invalid event kind'], 400); |
||||||
|
} |
||||||
|
|
||||||
|
// Publish the event |
||||||
|
try { |
||||||
|
$nostrClient->publishEvent($signedEvent, ['wss://relay.damus.io', 'wss://nos.lol']); |
||||||
|
return $this->json(['success' => true, 'eventId' => $signedEvent['id']]); |
||||||
|
} catch (\Exception $e) { |
||||||
|
return $this->json(['error' => 'Publishing failed: ' . $e->getMessage()], 500); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Form; |
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextareaType; |
||||||
|
use Symfony\Component\Form\Extension\Core\Type\TextType; |
||||||
|
use Symfony\Component\Form\FormBuilderInterface; |
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver; |
||||||
|
|
||||||
|
class TabularDataType extends AbstractType |
||||||
|
{ |
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void |
||||||
|
{ |
||||||
|
$builder |
||||||
|
->add('title', TextType::class, [ |
||||||
|
'label' => 'Title', |
||||||
|
'required' => true, |
||||||
|
]) |
||||||
|
->add('csvContent', TextareaType::class, [ |
||||||
|
'label' => 'CSV Content', |
||||||
|
'required' => true, |
||||||
|
'attr' => ['rows' => 10, 'placeholder' => 'date,hashrate\n2025-10-01,795\n2025-10-02,802'], |
||||||
|
]) |
||||||
|
->add('license', TextType::class, [ |
||||||
|
'label' => 'License (optional)', |
||||||
|
'required' => false, |
||||||
|
]) |
||||||
|
->add('units', TextType::class, [ |
||||||
|
'label' => 'Units (optional, e.g., col=2,EH/s)', |
||||||
|
'required' => false, |
||||||
|
]); |
||||||
|
} |
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void |
||||||
|
{ |
||||||
|
$resolver->setDefaults([ |
||||||
|
// Configure your form options here |
||||||
|
]); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
{% extends 'layout.html.twig' %} |
||||||
|
|
||||||
|
{% block title %}Preview Tabular Data Event{% endblock %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<div class="w-container" |
||||||
|
data-controller="tabular-publish" |
||||||
|
data-tabular-publish-publish-url-value="{{ path('tabular_data_publish_event') }}" |
||||||
|
data-tabular-publish-csrf-token-value="{{ csrf_token('tabular_publish') }}" |
||||||
|
data-tabular-publish-event-data-value="{{ event|json_encode }}"> |
||||||
|
<div class="card"> |
||||||
|
<div class="card-header"> |
||||||
|
<h1 class="card-title">Event Preview</h1> |
||||||
|
</div> |
||||||
|
<div class="card-body"> |
||||||
|
<p>Here is the generated event. Click "Sign and Publish" to sign with your Nostr key and publish to relays.</p> |
||||||
|
<pre>{{ event|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre> |
||||||
|
<div data-tabular-publish-target="status"></div> |
||||||
|
<button data-tabular-publish-target="publishButton" data-action="tabular-publish#publish" class="btn btn-primary">Sign and Publish</button> |
||||||
|
<a href="{{ path('tabular_data_publish') }}" class="btn btn-secondary">Back</a> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue