Browse Source

Move stimulus controllers around, ES feature flag

imwald
Nuša Pukšič 1 month ago
parent
commit
77a9fad2ed
  1. 2
      .env.dist
  2. 0
      assets/controllers/analytics/unique_visitors_per_day_chart_controller.js
  3. 0
      assets/controllers/analytics/visits_per_day_chart_controller.js
  4. 0
      assets/controllers/content/advanced_metadata_controller.js
  5. 0
      assets/controllers/content/author_articles_controller.js
  6. 0
      assets/controllers/content/comments_mercure_controller.js
  7. 0
      assets/controllers/content/discover-scroll_controller.js
  8. 2
      assets/controllers/content/reading_list_dropdown_controller.js
  9. 0
      assets/controllers/media/image-loader_controller.js
  10. 0
      assets/controllers/media/media-loader_controller.js
  11. 5
      assets/controllers/nostr/amber_connect_controller.js
  12. 0
      assets/controllers/nostr/manual_nip46_session.js
  13. 0
      assets/controllers/nostr/nostr-utils.ts
  14. 0
      assets/controllers/nostr/nostr_comment_controller.js
  15. 0
      assets/controllers/nostr/nostr_index_sign_controller.js
  16. 0
      assets/controllers/nostr/nostr_preview_controller.js
  17. 0
      assets/controllers/nostr/nostr_publish_controller.js
  18. 48
      assets/controllers/nostr/nostr_single_sign_controller.js
  19. 127
      assets/controllers/nostr/signer_manager.js
  20. 0
      assets/controllers/publishing/image_upload_controller.js
  21. 0
      assets/controllers/publishing/nzine_magazine_publish_controller.js
  22. 0
      assets/controllers/publishing/quill_controller.js
  23. 0
      assets/controllers/publishing/tabular_publish_controller.js
  24. 0
      assets/controllers/publishing/workflow_progress_controller.js
  25. 0
      assets/controllers/search/search_broadcast_controller.js
  26. 0
      assets/controllers/search/search_visibility_controller.js
  27. 0
      assets/controllers/search/topic-filter_controller.js
  28. 85
      assets/controllers/signer_manager.js
  29. 0
      assets/controllers/ui/form-collection_controller.js
  30. 0
      assets/controllers/ui/gallery_controller.js
  31. 0
      assets/controllers/ui/highlights_toggle_controller.js
  32. 0
      assets/controllers/ui/menu_controller.js
  33. 0
      assets/controllers/ui/progress_bar_controller.js
  34. 0
      assets/controllers/ui/sidebar_toggle_controller.js
  35. 0
      assets/controllers/utility/copy_to_clipboard_controller.js
  36. 0
      assets/controllers/utility/install-prompt_controller.js
  37. 0
      assets/controllers/utility/login_controller.js
  38. 0
      assets/controllers/utility/service-worker_controller.js
  39. 0
      assets/controllers/utility/share_dropdown_controller.js
  40. 20
      config/services.yaml
  41. 1
      src/Controller/Administration/ArticleManagementController.php
  42. 3
      src/Controller/ArticleController.php
  43. 13
      src/Controller/AuthorController.php
  44. 14
      src/Controller/DefaultController.php
  45. 1
      src/Controller/ForumController.php
  46. 172
      src/Repository/ArticleRepository.php
  47. 19
      src/Service/NostrClient.php
  48. 28
      src/Service/Search/ArticleSearchFactory.php
  49. 55
      src/Service/Search/ArticleSearchInterface.php
  50. 76
      src/Service/Search/DatabaseArticleSearch.php
  51. 160
      src/Service/Search/ElasticsearchArticleSearch.php
  52. 5
      src/Twig/Components/Organisms/FeaturedList.php
  53. 43
      src/Twig/Components/SearchComponent.php
  54. 6
      templates/admin/analytics.html.twig
  55. 8
      templates/admin/articles.html.twig
  56. 16
      templates/admin/cache.html.twig
  57. 8
      templates/base.html.twig
  58. 10
      templates/components/Header.html.twig
  59. 12
      templates/components/Molecules/NostrPreview.html.twig
  60. 16
      templates/components/Organisms/CommentForm.html.twig
  61. 20
      templates/components/ReadingListDropdown.html.twig
  62. 18
      templates/components/ReadingListWorkflowStatus.html.twig
  63. 4
      templates/components/SearchComponent.html.twig
  64. 6
      templates/components/UserMenu.html.twig
  65. 10
      templates/feedback/form.html.twig
  66. 4
      templates/layout.html.twig
  67. 6
      templates/login/amber.html.twig
  68. 4
      templates/login/index.html.twig
  69. 8
      templates/nzine/list.html.twig
  70. 16
      templates/pages/article.html.twig
  71. 46
      templates/pages/editor.html.twig
  72. 12
      templates/partial/_gallery.html.twig
  73. 8
      templates/profile/author-media.html.twig
  74. 2
      templates/profile/author.html.twig
  75. 8
      templates/reading_list/index.html.twig
  76. 12
      templates/tabular_data/preview.html.twig

2
.env.dist

@ -43,6 +43,8 @@ MERCURE_PUBLIC_URL="https://${SERVER_NAME}/.well-known/mercure" @@ -43,6 +43,8 @@ MERCURE_PUBLIC_URL="https://${SERVER_NAME}/.well-known/mercure"
MERCURE_JWT_SECRET="!NotSoSecretMercureHubJWTSecretKey!"
###< symfony/mercure-bundle ###
###> elastic ###
# Set to 'true' to enable Elasticsearch, 'false' to use database queries
ELASTICSEARCH_ENABLED=false
ELASTICSEARCH_HOST=localhost
ELASTICSEARCH_PORT=9200
ELASTICSEARCH_USERNAME=elastic

0
assets/controllers/unique_visitors_per_day_chart_controller.js → assets/controllers/analytics/unique_visitors_per_day_chart_controller.js

0
assets/controllers/visits_per_day_chart_controller.js → assets/controllers/analytics/visits_per_day_chart_controller.js

0
assets/controllers/advanced_metadata_controller.js → assets/controllers/content/advanced_metadata_controller.js

0
assets/controllers/author_articles_controller.js → assets/controllers/content/author_articles_controller.js

0
assets/controllers/comments_mercure_controller.js → assets/controllers/content/comments_mercure_controller.js

0
assets/controllers/discover-scroll_controller.js → assets/controllers/content/discover-scroll_controller.js

2
assets/controllers/reading_list_dropdown_controller.js → assets/controllers/content/reading_list_dropdown_controller.js

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Controller } from '@hotwired/stimulus';
import { getSigner } from './signer_manager.js';
import { getSigner } from '../nostr/signer_manager.js';
export default class extends Controller {
static targets = ['dropdown', 'status', 'menu'];

0
assets/controllers/image-loader_controller.js → assets/controllers/media/image-loader_controller.js

0
assets/controllers/media-loader_controller.js → assets/controllers/media/media-loader_controller.js

5
assets/controllers/amber_connect_controller.js → assets/controllers/nostr/amber_connect_controller.js

@ -73,7 +73,9 @@ export default class extends Controller { @@ -73,7 +73,9 @@ export default class extends Controller {
async _createSigner() {
this._pool = new SimplePool();
this._setStatus('Waiting for remote signer…');
// fromURI resolves only after remote bunker connects & authorizes (handshake done inside nostr-tools)
// INITIAL CONNECTION: fromURI() waits for Amber to accept connection (NIP-46 connect handshake)
// After this succeeds, the session (privkey, uri, relays, secret) is persisted to localStorage
// Subsequent calls to BunkerSigner.fromURI() with same credentials should work without waiting for approval
this._signer = await BunkerSigner.fromURI(this._localSecretKey, this._uri, { pool: this._pool });
}
@ -101,6 +103,7 @@ export default class extends Controller { @@ -101,6 +103,7 @@ export default class extends Controller {
});
if (resp.ok) {
// Persist remote signer session for reuse after reload
// Note: Reconnection with Amber may require user approval each time
setRemoteSignerSession({
privkey: this._localSecretKey,
uri: this._uri,

0
assets/controllers/nostr/manual_nip46_session.js

0
assets/controllers/nostr-utils.ts → assets/controllers/nostr/nostr-utils.ts

0
assets/controllers/nostr_comment_controller.js → assets/controllers/nostr/nostr_comment_controller.js

0
assets/controllers/nostr_index_sign_controller.js → assets/controllers/nostr/nostr_index_sign_controller.js

0
assets/controllers/nostr_preview_controller.js → assets/controllers/nostr/nostr_preview_controller.js

0
assets/controllers/nostr_publish_controller.js → assets/controllers/nostr/nostr_publish_controller.js

48
assets/controllers/nostr_single_sign_controller.js → assets/controllers/nostr/nostr_single_sign_controller.js

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Controller } from '@hotwired/stimulus';
import { getSigner } from './signer_manager.js';
import { getSigner, getRemoteSignerSession } from './signer_manager.js';
export default class extends Controller {
static targets = ['status', 'publishButton', 'computedPreview'];
@ -19,10 +19,21 @@ export default class extends Controller { @@ -19,10 +19,21 @@ export default class extends Controller {
try {
const skeleton = JSON.parse(this.eventValue || '{}');
let pubkey = '<pubkey>';
try {
const signer = await getSigner();
pubkey = await signer.getPublicKey();
} catch (_) {}
// Only try to get pubkey if extension is available
// Don't attempt remote signer connection during preview (it would timeout)
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
try {
pubkey = await window.nostr.getPublicKey();
} catch (_) {}
} else {
// If remote signer session exists, show placeholder
const session = getRemoteSignerSession();
if (session) {
pubkey = '<will be obtained from remote signer>';
}
}
const preview = JSON.parse(JSON.stringify(skeleton));
preview.pubkey = pubkey;
// Update content from textarea if present
@ -40,16 +51,33 @@ export default class extends Controller { @@ -40,16 +51,33 @@ export default class extends Controller {
event.preventDefault();
console.log('[nostr_single_sign] Sign and publish triggered');
const session = getRemoteSignerSession();
console.log('[nostr_single_sign] Remote signer session:', session);
let signer;
try {
this.showStatus('Connecting to signer...');
console.log('[nostr_single_sign] Calling getSigner()...');
// getSigner() handles caching and reuses existing connection if available
signer = await getSigner();
console.log('[nostr_single_sign] Signer obtained successfully');
// Verify connection works
const testPubkey = await signer.getPublicKey();
console.log('[nostr_single_sign] Signer verified, pubkey:', testPubkey);
} catch (e) {
console.error('[nostr_single_sign] Failed to get signer:', e);
this.showError(`No Nostr signer available: ${e.message}. Please connect Amber or install a Nostr signer extension.`);
const session = getRemoteSignerSession();
if (session && e.message.includes('unavailable')) {
this.showError('Amber connection lost. Please use a Nostr browser extension (like nos2x or Alby) to sign, or reconnect Amber from the login page.');
} else {
this.showError(`No Nostr signer available: ${e.message}. Please connect Amber or install a Nostr signer extension.`);
}
return;
}
if (!this.publishUrlValue || !this.csrfTokenValue) {
console.error('[nostr_single_sign] Missing config', { publishUrl: this.publishUrlValue, csrf: !!this.csrfTokenValue });
this.showError('Missing config');
@ -58,12 +86,12 @@ export default class extends Controller { @@ -58,12 +86,12 @@ export default class extends Controller {
this.publishButtonTarget.disabled = true;
try {
this.showStatus('Getting public key...');
this.showStatus('Preparing event...');
const pubkey = await signer.getPublicKey();
console.log('[nostr_single_sign] Public key obtained:', pubkey);
const skeleton = JSON.parse(this.eventValue || '{}');
// Update content from textarea before signing
// Update content from textarea if present
const textarea = this.element.querySelector('textarea');
if (textarea) {
skeleton.content = textarea.value;
@ -72,10 +100,10 @@ export default class extends Controller { @@ -72,10 +100,10 @@ export default class extends Controller {
this.ensureContent(skeleton);
skeleton.pubkey = pubkey;
this.showStatus('Signing event…');
this.showStatus('Sending event to signer for signature...');
console.log('[nostr_single_sign] Signing event:', skeleton);
const signed = await signer.signEvent(skeleton);
console.log('[nostr_single_sign] Event signed successfully');
console.log('[nostr_single_sign] Event signed successfully:', signed);
this.showStatus('Publishing…');
await this.publishSigned(signed);

127
assets/controllers/nostr/signer_manager.js

@ -0,0 +1,127 @@ @@ -0,0 +1,127 @@
// Shared signer manager for Nostr signers (remote and extension)
import { SimplePool } from 'nostr-tools';
import { BunkerSigner } from 'nostr-tools/nip46';
const REMOTE_SIGNER_KEY = 'amber_remote_signer';
let remoteSigner = null;
let remoteSignerPromise = null;
let remoteSignerPool = null;
export async function getSigner(_retrying = 0) {
// If remote signer session is active, use it
const session = getRemoteSignerSession();
console.log('[signer_manager] getSigner called, session exists:', !!session);
if (session) {
if (remoteSigner) {
console.log('[signer_manager] Returning cached remote signer');
return remoteSigner;
}
if (remoteSignerPromise) {
console.log('[signer_manager] Returning existing connection promise');
return remoteSignerPromise;
}
console.log('[signer_manager] Recreating BunkerSigner from stored session (no connect needed)...');
// According to nostr-tools docs: BunkerSigner.fromURI() returns immediately
// After initial connect() during login, we can reuse the signer without reconnecting
remoteSignerPromise = createRemoteSignerFromSession(session)
.then(signer => {
remoteSigner = signer;
console.log('[signer_manager] Remote signer successfully recreated and cached');
return signer;
})
.catch((error) => {
console.error('[signer_manager] Remote signer creation failed:', error);
remoteSignerPromise = null;
// Clear stale session
console.log('[signer_manager] Clearing stale remote signer session');
clearRemoteSignerSession();
// Fallback to browser extension if available
if (window.nostr && typeof window.nostr.signEvent === 'function') {
console.log('[signer_manager] Falling back to browser extension');
return window.nostr;
}
throw new Error('Remote signer unavailable. Please reconnect Amber or use a browser extension.');
});
return remoteSignerPromise;
}
// Fallback to browser extension ONLY if no remote session
console.log('[signer_manager] No remote session, checking for browser extension');
if (window.nostr && typeof window.nostr.signEvent === 'function') {
console.log('[signer_manager] Using browser extension');
return window.nostr;
}
throw new Error('No signer available');
}
export function setRemoteSignerSession(session) {
localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session));
}
export function clearRemoteSignerSession() {
localStorage.removeItem(REMOTE_SIGNER_KEY);
remoteSigner = null;
remoteSignerPromise = null;
if (remoteSignerPool) {
try { remoteSignerPool.close?.([]); } catch (_) {}
remoteSignerPool = null;
}
}
export function getRemoteSignerSession() {
const raw = localStorage.getItem(REMOTE_SIGNER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// Create BunkerSigner from stored session
// According to nostr-tools: fromURI() returns immediately, no waiting for handshake
// The connect() was already done during initial login, so we can use the signer right away
async function createRemoteSignerFromSession(session) {
console.log('[signer_manager] ===== Recreating BunkerSigner from session =====');
console.log('[signer_manager] Session URI:', session.uri);
console.log('[signer_manager] Session relays:', session.relays);
// Reuse existing pool if available, otherwise create new one
if (!remoteSignerPool) {
console.log('[signer_manager] Creating new SimplePool for relays:', session.relays);
remoteSignerPool = new SimplePool();
} else {
console.log('[signer_manager] Reusing existing SimplePool');
}
try {
console.log('[signer_manager] Creating BunkerSigner from stored session...');
// fromURI returns a Promise - await it to get the signer
const signer = await BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool });
console.log('[signer_manager] ✅ BunkerSigner created! Testing with getPublicKey...');
// Test the signer to make sure it works
try {
const pubkey = await signer.getPublicKey();
console.log('[signer_manager] ✅ Signer verified! Pubkey:', pubkey);
return signer;
} catch (testError) {
console.error('[signer_manager] ❌ Signer test failed:', testError);
throw new Error('Signer created but failed verification: ' + testError.message);
}
} catch (error) {
console.error('[signer_manager] ❌ Failed to create signer:', error);
// Clean up on error
if (remoteSignerPool) {
try {
console.log('[signer_manager] Closing pool after error');
remoteSignerPool.close?.([]);
} catch (_) {}
remoteSignerPool = null;
}
remoteSigner = null;
remoteSignerPromise = null;
throw error;
}
}

0
assets/controllers/image_upload_controller.js → assets/controllers/publishing/image_upload_controller.js

0
assets/controllers/nzine_magazine_publish_controller.js → assets/controllers/publishing/nzine_magazine_publish_controller.js

0
assets/controllers/quill_controller.js → assets/controllers/publishing/quill_controller.js

0
assets/controllers/tabular_publish_controller.js → assets/controllers/publishing/tabular_publish_controller.js

0
assets/controllers/workflow_progress_controller.js → assets/controllers/publishing/workflow_progress_controller.js

0
assets/controllers/search_broadcast_controller.js → assets/controllers/search/search_broadcast_controller.js

0
assets/controllers/search_visibility_controller.js → assets/controllers/search/search_visibility_controller.js

0
assets/controllers/topic-filter_controller.js → assets/controllers/search/topic-filter_controller.js

85
assets/controllers/signer_manager.js

@ -1,85 +0,0 @@ @@ -1,85 +0,0 @@
// Shared signer manager for Nostr signers (remote and extension)
import { SimplePool } from 'nostr-tools';
import { BunkerSigner } from 'nostr-tools/nip46';
const REMOTE_SIGNER_KEY = 'amber_remote_signer';
let remoteSigner = null;
let remoteSignerPromise = null;
let remoteSignerPool = null;
export async function getSigner() {
// If remote signer session is active, use it
const session = getRemoteSignerSession();
if (session) {
if (remoteSigner) return remoteSigner;
if (remoteSignerPromise) return remoteSignerPromise;
remoteSignerPromise = createRemoteSigner(session)
.then(signer => {
remoteSigner = signer;
return signer;
})
.catch(error => {
// Reset promise on failure so next call can retry
remoteSignerPromise = null;
throw error;
});
return remoteSignerPromise;
}
// Fallback to browser extension
if (window.nostr && typeof window.nostr.signEvent === 'function') {
return window.nostr;
}
throw new Error('No signer available');
}
export function setRemoteSignerSession(session) {
localStorage.setItem(REMOTE_SIGNER_KEY, JSON.stringify(session));
}
export function clearRemoteSignerSession() {
localStorage.removeItem(REMOTE_SIGNER_KEY);
remoteSigner = null;
remoteSignerPromise = null;
if (remoteSignerPool) {
try { remoteSignerPool.close?.([]); } catch (_) {}
remoteSignerPool = null;
}
}
export function getRemoteSignerSession() {
const raw = localStorage.getItem(REMOTE_SIGNER_KEY);
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async function createRemoteSigner(session) {
remoteSignerPool = new SimplePool();
// Add timeout to prevent hanging indefinitely
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Remote signer connection timeout')), 10000);
});
try {
return await Promise.race([
BunkerSigner.fromURI(session.privkey, session.uri, { pool: remoteSignerPool }),
timeoutPromise
]);
} catch (error) {
// Clean up on error
if (remoteSignerPool) {
try { remoteSignerPool.close?.([]); } catch (_) {}
remoteSignerPool = null;
}
remoteSigner = null;
remoteSignerPromise = null;
throw error;
}
}

0
assets/controllers/form-collection_controller.js → assets/controllers/ui/form-collection_controller.js

0
assets/controllers/gallery_controller.js → assets/controllers/ui/gallery_controller.js

0
assets/controllers/highlights_toggle_controller.js → assets/controllers/ui/highlights_toggle_controller.js

0
assets/controllers/menu_controller.js → assets/controllers/ui/menu_controller.js

0
assets/controllers/progress_bar_controller.js → assets/controllers/ui/progress_bar_controller.js

0
assets/controllers/sidebar_toggle_controller.js → assets/controllers/ui/sidebar_toggle_controller.js

0
assets/controllers/copy_to_clipboard_controller.js → assets/controllers/utility/copy_to_clipboard_controller.js

0
assets/controllers/install-prompt_controller.js → assets/controllers/utility/install-prompt_controller.js

0
assets/controllers/login_controller.js → assets/controllers/utility/login_controller.js

0
assets/controllers/service-worker_controller.js → assets/controllers/utility/service-worker_controller.js

0
assets/controllers/share_dropdown_controller.js → assets/controllers/utility/share_dropdown_controller.js

20
config/services.yaml

@ -7,6 +7,7 @@ parameters: @@ -7,6 +7,7 @@ parameters:
encryption_key: '%env(APP_ENCRYPTION_KEY)%'
mercure_public_hub_url: '%env(MERCURE_PUBLIC_URL)%'
nostr_default_relay: '%env(default::NOSTR_DEFAULT_RELAY)%'
elasticsearch_enabled: '%env(ELASTICSEARCH_ENABLED)%'
services:
# default configuration for services in *this* file
@ -79,3 +80,22 @@ services: @@ -79,3 +80,22 @@ services:
App\Util\NostrPhp\TweakedRequest: ~
swentel\nostr\Request\Request: '@App\Util\NostrPhp\TweakedRequest'
# Search services - Elasticsearch implementation
App\Service\Search\ElasticsearchArticleSearch:
arguments:
$finder: '@fos_elastica.finder.articles'
$enabled: '%elasticsearch_enabled%'
# Search services - Database implementation
App\Service\Search\DatabaseArticleSearch: ~
# Search service factory
App\Service\Search\ArticleSearchFactory:
arguments:
$elasticsearchEnabled: '%elasticsearch_enabled%'
# Main search service - uses Elasticsearch if enabled, otherwise database
App\Service\Search\ArticleSearchInterface:
factory: ['@App\Service\Search\ArticleSearchFactory', 'create']

1
src/Controller/Administration/ArticleManagementController.php

@ -5,6 +5,7 @@ declare(strict_types=1); @@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Administration;
use App\Service\RedisCacheService;
use App\Service\Search\ArticleSearchInterface;
use Elastica\Query;
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

3
src/Controller/ArticleController.php

@ -13,12 +13,9 @@ use App\Service\Nostr\NostrEventParser; @@ -13,12 +13,9 @@ use App\Service\Nostr\NostrEventParser;
use App\Service\RedisCacheService;
use App\Util\CommonMark\Converter;
use Doctrine\ORM\EntityManagerInterface;
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface;
use League\CommonMark\Exception\CommonMarkException;
use nostriphant\NIP19\Bech32;
use nostriphant\NIP19\Data\NAddr;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Key\Key;

13
src/Controller/AuthorController.php

@ -10,6 +10,8 @@ use App\Enum\KindsEnum; @@ -10,6 +10,8 @@ use App\Enum\KindsEnum;
use App\Message\FetchAuthorArticlesMessage;
use App\Service\NostrClient;
use App\Service\RedisCacheService;
use App\Service\RedisViewStore;
use App\ReadModel\RedisView\RedisViewFactory;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface;
use Elastica\Query\BoolQuery;
@ -254,15 +256,8 @@ class AuthorController extends AbstractController @@ -254,15 +256,8 @@ class AuthorController extends AbstractController
}
$fromCache = true;
} else {
// Cache miss - query from Elasticsearch
$boolQuery = new BoolQuery();
$boolQuery->addMust(new Term(['pubkey' => $pubkey]));
$query = new \Elastica\Query($boolQuery);
$query->setSort(['createdAt' => ['order' => 'desc']]);
$collapse = new Collapse();
$collapse->setFieldname('slug');
$query->setCollapse($collapse);
$articles = $finder->find($query);
// Cache miss - query using search service
$articles = $articleSearch->findByPubkey($pubkey, 100, 0);
// Build and cache Redis views for next time
if (!empty($articles)) {

14
src/Controller/DefaultController.php

@ -24,8 +24,6 @@ use Psr\Cache\CacheItemPoolInterface; @@ -24,8 +24,6 @@ use Psr\Cache\CacheItemPoolInterface;
use Psr\Cache\InvalidArgumentException;
use swentel\nostr\Key\Key;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Tests\Compiler\K;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@ -335,21 +333,15 @@ class DefaultController extends AbstractController @@ -335,21 +333,15 @@ class DefaultController extends AbstractController
}
if (!empty($coordinates)) {
// Extract slugs for elasticsearch query
// Extract slugs for query
$slugs = array_map(function($coordinate) {
$parts = explode(':', $coordinate, 3);
return end($parts);
}, $coordinates);
$slugs = array_filter($slugs); // Remove empty values
// First filter to only include articles with the slugs we want
$termsQuery = new Terms('slug', array_values($slugs));
// Create a Query object to set the size parameter
$query = new Query($termsQuery);
$query->setSize(200); // Set size to exceed the number of articles we expect
$articles = $finder->find($query);
// Use the search service to find articles by slugs
$articles = $articleSearch->findBySlugs(array_values($slugs), 200);
// Create a map of slug => item to remove duplicates
$slugMap = [];

1
src/Controller/ForumController.php

@ -6,6 +6,7 @@ namespace App\Controller; @@ -6,6 +6,7 @@ namespace App\Controller;
use App\Entity\User;
use App\Service\NostrClient;
use App\Service\Search\ArticleSearchInterface;
use App\Util\ForumTopics;
use App\Util\NostrKeyUtil;
use Elastica\Aggregation\Filters as FiltersAgg;

172
src/Repository/ArticleRepository.php

@ -15,4 +15,176 @@ class ArticleRepository extends ServiceEntityRepository @@ -15,4 +15,176 @@ class ArticleRepository extends ServiceEntityRepository
parent::__construct($registry, Article::class);
}
/**
* Search articles by query string using database full-text search
*
* @param string $query
* @param int $limit
* @param int $offset
* @return Article[]
*/
public function searchByQuery(string $query, int $limit = 12, int $offset = 0): array
{
$qb = $this->createQueryBuilder('a');
// Use LIKE for basic text search (works with PostgreSQL and MySQL)
// For better performance, consider using PostgreSQL full-text search or MySQL FULLTEXT indexes
$searchTerm = '%' . $query . '%';
$qb->where(
$qb->expr()->orX(
$qb->expr()->like('a.title', ':search'),
$qb->expr()->like('a.content', ':search'),
$qb->expr()->like('a.summary', ':search')
)
)
->andWhere($qb->expr()->notLike('a.slug', ':slugPattern'))
->setParameter('search', $searchTerm)
->setParameter('slugPattern', '%/%')
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
/**
* Find articles by slugs
*
* @param array $slugs
* @param int $limit
* @return Article[]
*/
public function findBySlugs(array $slugs, int $limit = 200): array
{
if (empty($slugs)) {
return [];
}
$qb = $this->createQueryBuilder('a');
$qb->where($qb->expr()->in('a.slug', ':slugs'))
->setParameter('slugs', $slugs)
->orderBy('a.createdAt', 'DESC')
->setMaxResults($limit);
$results = $qb->getQuery()->getResult();
// Group by slug and keep the most recent version of each
$slugMap = [];
foreach ($results as $article) {
$slug = $article->getSlug();
if (!isset($slugMap[$slug]) || $article->getCreatedAt() > $slugMap[$slug]->getCreatedAt()) {
$slugMap[$slug] = $article;
}
}
return array_values($slugMap);
}
/**
* Find articles by topics
*
* @param array $topics
* @param int $limit
* @param int $offset
* @return Article[]
*/
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array
{
if (empty($topics)) {
return [];
}
$qb = $this->createQueryBuilder('a');
// Use JSON contains for topics (PostgreSQL)
// Note: This assumes topics is stored as a JSON field
$orX = $qb->expr()->orX();
foreach ($topics as $index => $topic) {
$orX->add("JSONB_CONTAINS(a.topics, :topic$index) = true");
$qb->setParameter("topic$index", json_encode([$topic]));
}
$qb->where($orX)
->andWhere($qb->expr()->notLike('a.slug', ':slugPattern'))
->setParameter('slugPattern', '%/%')
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
/**
* Find articles by pubkey (author)
*
* @param string $pubkey
* @param int $limit
* @param int $offset
* @return Article[]
*/
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array
{
$qb = $this->createQueryBuilder('a');
$qb->where('a.pubkey = :pubkey')
->andWhere($qb->expr()->notLike('a.slug', ':slugPattern'))
->setParameter('pubkey', $pubkey)
->setParameter('slugPattern', '%/%')
->orderBy('a.createdAt', 'DESC')
->setFirstResult($offset)
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
/**
* Search articles with PostgreSQL full-text search (ts_vector)
* This is an optimized version for PostgreSQL if you have full-text indexes set up
*
* @param string $query
* @param int $limit
* @param int $offset
* @return Article[]
*/
public function searchByQueryPostgreSQL(string $query, int $limit = 12, int $offset = 0): array
{
// This requires a tsvector column in your database
// You can uncomment and use this if you set up PostgreSQL full-text search
/*
$conn = $this->getEntityManager()->getConnection();
$sql = "
SELECT a.* FROM article a
WHERE
to_tsvector('english', COALESCE(a.title, '') || ' ' || COALESCE(a.content, '') || ' ' || COALESCE(a.summary, ''))
@@ plainto_tsquery('english', :query)
AND a.slug NOT LIKE '%/%'
ORDER BY
ts_rank(to_tsvector('english', COALESCE(a.title, '') || ' ' || COALESCE(a.content, '') || ' ' || COALESCE(a.summary, '')), plainto_tsquery('english', :query)) DESC,
a.created_at DESC
LIMIT :limit OFFSET :offset
";
$stmt = $conn->prepare($sql);
$result = $stmt->executeQuery([
'query' => $query,
'limit' => $limit,
'offset' => $offset
]);
$articles = [];
foreach ($result->fetchAllAssociative() as $row) {
$articles[] = $this->find($row['id']);
}
return $articles;
*/
// Fallback to simple search
return $this->searchByQuery($query, $limit, $offset);
}
}

19
src/Service/NostrClient.php

@ -234,6 +234,20 @@ class NostrClient @@ -234,6 +234,20 @@ class NostrClient
*/
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void
{
$this->logger->info('Getting long form from ' . $slug, [
'relay_list' => $relayList,
'author' => $author,
'kind' => $kind
]);
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays);
$this->logger->info('Author relays for long form fetch', [
'author' => $author,
'from_event' => $this->getNpubRelays($author),
'relays' => $topAuthorRelays
]);
if (empty($relayList)) {
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays);
@ -464,11 +478,14 @@ class NostrClient @@ -464,11 +478,14 @@ class NostrClient
$this->logger->warning('Cache error', ['error' => $e->getMessage()]);
}
$relays = new RelaySet();
$relays->createFromUrls(self::REPUTABLE_RELAYS);
// Get relays
$request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST->value],
filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet
relaySet: $relays
);
$response = $this->processResponse($request->send(), function($received) {
return $received;

28
src/Service/Search/ArticleSearchFactory.php

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
<?php
namespace App\Service\Search;
use Psr\Log\LoggerInterface;
class ArticleSearchFactory
{
public function __construct(
private readonly ElasticsearchArticleSearch $elasticsearchSearch,
private readonly DatabaseArticleSearch $databaseSearch,
private readonly LoggerInterface $logger,
private readonly bool $elasticsearchEnabled
) {
}
public function create(): ArticleSearchInterface
{
if ($this->elasticsearchEnabled && $this->elasticsearchSearch->isAvailable()) {
$this->logger->info('Using Elasticsearch for article search');
return $this->elasticsearchSearch;
}
$this->logger->info('Using database for article search');
return $this->databaseSearch;
}
}

55
src/Service/Search/ArticleSearchInterface.php

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
<?php
namespace App\Service\Search;
use App\Entity\Article;
interface ArticleSearchInterface
{
/**
* Search for articles matching the given query
*
* @param string $query The search query
* @param int $limit Maximum number of results
* @param int $offset Offset for pagination
* @return Article[]
*/
public function search(string $query, int $limit = 12, int $offset = 0): array;
/**
* Find articles by slugs
*
* @param array $slugs Array of article slugs
* @param int $limit Maximum number of results
* @return Article[]
*/
public function findBySlugs(array $slugs, int $limit = 200): array;
/**
* Find articles by topics
*
* @param array $topics Array of topics
* @param int $limit Maximum number of results
* @param int $offset Offset for pagination
* @return Article[]
*/
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array;
/**
* Find articles by pubkey (author)
*
* @param string $pubkey Author's public key
* @param int $limit Maximum number of results
* @param int $offset Offset for pagination
* @return Article[]
*/
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array;
/**
* Check if the search service is available
*
* @return bool
*/
public function isAvailable(): bool;
}

76
src/Service/Search/DatabaseArticleSearch.php

@ -0,0 +1,76 @@ @@ -0,0 +1,76 @@
<?php
namespace App\Service\Search;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use Psr\Log\LoggerInterface;
class DatabaseArticleSearch implements ArticleSearchInterface
{
public function __construct(
private readonly ArticleRepository $articleRepository,
private readonly LoggerInterface $logger
) {
}
public function search(string $query, int $limit = 12, int $offset = 0): array
{
try {
$results = $this->articleRepository->searchByQuery($query, $limit, $offset);
$this->logger->info('Database search results count: ' . count($results));
return $results;
} catch (\Exception $e) {
$this->logger->error('Database search error: ' . $e->getMessage());
return [];
}
}
public function findBySlugs(array $slugs, int $limit = 200): array
{
if (empty($slugs)) {
return [];
}
try {
return $this->articleRepository->findBySlugs($slugs, $limit);
} catch (\Exception $e) {
$this->logger->error('Database findBySlugs error: ' . $e->getMessage());
return [];
}
}
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array
{
if (empty($topics)) {
return [];
}
try {
return $this->articleRepository->findByTopics($topics, $limit, $offset);
} catch (\Exception $e) {
$this->logger->error('Database findByTopics error: ' . $e->getMessage());
return [];
}
}
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array
{
if (empty($pubkey)) {
return [];
}
try {
return $this->articleRepository->findByPubkey($pubkey, $limit, $offset);
} catch (\Exception $e) {
$this->logger->error('Database findByPubkey error: ' . $e->getMessage());
return [];
}
}
public function isAvailable(): bool
{
return true; // Database is always available
}
}

160
src/Service/Search/ElasticsearchArticleSearch.php

@ -0,0 +1,160 @@ @@ -0,0 +1,160 @@
<?php
namespace App\Service\Search;
use App\Entity\Article;
use Elastica\Query;
use Elastica\Query\BoolQuery;
use Elastica\Query\MultiMatch;
use Elastica\Query\Terms;
use FOS\ElasticaBundle\Finder\FinderInterface;
use Psr\Log\LoggerInterface;
class ElasticsearchArticleSearch implements ArticleSearchInterface
{
public function __construct(
private readonly FinderInterface $finder,
private readonly LoggerInterface $logger,
private readonly bool $enabled = true
) {
}
public function search(string $query, int $limit = 12, int $offset = 0): array
{
if (!$this->enabled) {
return [];
}
try {
$mainQuery = new Query();
$boolQuery = new BoolQuery();
// Add phrase match for exact matches (high boost)
$phraseMatch = new Query\MatchPhrase();
$phraseMatch->setField('search_combined', [
'query' => $query,
'boost' => 10
]);
$boolQuery->addShould($phraseMatch);
// Main multi-match query with optimized settings
$multiMatch = new MultiMatch();
$multiMatch->setQuery($query);
$multiMatch->setFields(['search_combined']);
$multiMatch->setFuzziness('AUTO');
$boolQuery->addMust($multiMatch);
// Exclude specific patterns
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
$mainQuery->setQuery($boolQuery);
// Simplified collapse - no inner_hits for better performance
$mainQuery->setParam('collapse', [
'field' => 'slug'
]);
// Lower minimum score for better recall
$mainQuery->setMinScore(0.25);
// Sort by score first, then date
$mainQuery->setSort([
'_score' => ['order' => 'desc'],
'createdAt' => ['order' => 'desc']
]);
$mainQuery->setFrom($offset);
$mainQuery->setSize($limit);
// Execute the search
$results = $this->finder->find($mainQuery);
$this->logger->info('Elasticsearch search results count: ' . count($results));
return $results;
} catch (\Exception $e) {
$this->logger->error('Elasticsearch search error: ' . $e->getMessage());
return [];
}
}
public function findBySlugs(array $slugs, int $limit = 200): array
{
if (!$this->enabled || empty($slugs)) {
return [];
}
try {
$termsQuery = new Terms('slug', array_values($slugs));
$query = new Query($termsQuery);
$query->setSize($limit);
return $this->finder->find($query);
} catch (\Exception $e) {
$this->logger->error('Elasticsearch findBySlugs error: ' . $e->getMessage());
return [];
}
}
public function findByTopics(array $topics, int $limit = 12, int $offset = 0): array
{
if (!$this->enabled || empty($topics)) {
return [];
}
try {
$boolQuery = new BoolQuery();
$termsQuery = new Terms('topics', $topics);
$boolQuery->addMust($termsQuery);
// Exclude specific patterns
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
$mainQuery = new Query($boolQuery);
$mainQuery->setSort([
'createdAt' => ['order' => 'desc']
]);
$mainQuery->setFrom($offset);
$mainQuery->setSize($limit);
return $this->finder->find($mainQuery);
} catch (\Exception $e) {
$this->logger->error('Elasticsearch findByTopics error: ' . $e->getMessage());
return [];
}
}
public function findByPubkey(string $pubkey, int $limit = 12, int $offset = 0): array
{
if (!$this->enabled || empty($pubkey)) {
return [];
}
try {
$boolQuery = new BoolQuery();
$termQuery = new Query\Term();
$termQuery->setTerm('pubkey', $pubkey);
$boolQuery->addMust($termQuery);
// Exclude specific patterns
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
$mainQuery = new Query($boolQuery);
$mainQuery->setSort([
'createdAt' => ['order' => 'desc']
]);
$mainQuery->setFrom($offset);
$mainQuery->setSize($limit);
return $this->finder->find($mainQuery);
} catch (\Exception $e) {
$this->logger->error('Elasticsearch findByPubkey error: ' . $e->getMessage());
return [];
}
}
public function isAvailable(): bool
{
return $this->enabled;
}
}

5
src/Twig/Components/Organisms/FeaturedList.php

@ -67,10 +67,7 @@ final class FeaturedList @@ -67,10 +67,7 @@ final class FeaturedList
}
}
$termsQuery = new Terms('slug', array_values($slugs));
$query = new Query($termsQuery);
$query->setSize(200); // Set size to exceed the number of articles we expect
$articles = $this->finder->find($query);
$articles = $this->articleSearch->findBySlugs(array_values($slugs), 200);
// Create a map of slug => item
$slugMap = [];

43
src/Twig/Components/SearchComponent.php

@ -215,51 +215,12 @@ final class SearchComponent @@ -215,51 +215,12 @@ final class SearchComponent
*/
private function performOptimizedSearch(string $query, ?int $maxResults = null): array
{
$mainQuery = new Query();
$boolQuery = new BoolQuery();
// Add phrase match for exact matches (high boost)
$phraseMatch = new Query\MatchPhrase();
$phraseMatch->setField('search_combined', [
'query' => $query,
'boost' => 10
]);
$boolQuery->addShould($phraseMatch);
// Main multi-match query with optimized settings
$multiMatch = new MultiMatch();
$multiMatch->setQuery($query);
$multiMatch->setFields(['search_combined']);
$multiMatch->setFuzziness('AUTO');
$boolQuery->addMust($multiMatch);
// Exclude specific patterns
$boolQuery->addMustNot(new Query\Wildcard('slug', '*/*'));
$mainQuery->setQuery($boolQuery);
// Simplified collapse - no inner_hits for better performance
$mainQuery->setParam('collapse', [
'field' => 'slug'
]);
// Lower minimum score for better recall
$mainQuery->setMinScore(0.25);
// Sort by score first, then date
$mainQuery->setSort([
'_score' => ['order' => 'desc'],
'createdAt' => ['order' => 'desc']
]);
// Pagination - use maxResults if provided, otherwise use default resultsPerPage
$effectiveResultsPerPage = $maxResults ?? $this->resultsPerPage;
$offset = ($this->page - 1) * $effectiveResultsPerPage;
$mainQuery->setFrom($offset);
$mainQuery->setSize($effectiveResultsPerPage);
// Execute the search
$results = $this->finder->find($mainQuery);
// Execute the search using the configured search service
$results = $this->articleSearch->search($query, $effectiveResultsPerPage, $offset);
$this->logger->info('Search results count: ' . count($results));
return $results;

6
templates/admin/analytics.html.twig

@ -106,9 +106,9 @@ @@ -106,9 +106,9 @@
<h2>Visits Per Day (Last 30 Days)</h2>
{% if dailyVisitCountsLast30Days|length > 0 %}
<canvas id="visitsPerDayChart"
data-controller="visits-per-day-chart"
data-visits-per-day-chart-labels-value='{{ dailyVisitCountsLast30Days|map(stat => stat.day|date('Y-m-d'))|json_encode()|e('html_attr') }}'
data-visits-per-day-chart-counts-value='{{ dailyVisitCountsLast30Days|map(stat => stat.count)|json_encode()|e('html_attr') }}'
data-controller="analytics--visits-per-day-chart"
data-analytics--visits-per-day-chart-labels-value='{{ dailyVisitCountsLast30Days|map(stat => stat.day|date('Y-m-d'))|json_encode()|e('html_attr') }}'
data-analytics--visits-per-day-chart-counts-value='{{ dailyVisitCountsLast30Days|map(stat => stat.count)|json_encode()|e('html_attr') }}'
height="80"></canvas>
<noscript>
<p><em>Enable JavaScript to see the visits per day chart.</em></p>

8
templates/admin/articles.html.twig

@ -16,11 +16,11 @@ @@ -16,11 +16,11 @@
<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>
<span data-controller="utility--copy-to-clipboard">
<span class="hidden" data-utility--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">Copy to Clipboard</button>
data-utility--copy-to-clipboard-target="copyButton"
data-action="click->utility--copy-to-clipboard#copyToClipboard">Copy to Clipboard</button>
</span>
<form method="post" action="{{ path('admin_article_add_to_index') }}">

16
templates/admin/cache.html.twig

@ -55,7 +55,7 @@ @@ -55,7 +55,7 @@
{% block layout %}
<main>
<div class="cache-management" data-controller="service-worker">
<div class="cache-management" data-controller="utility--service-worker">
<h1>Cache Management</h1>
<div class="cache-info">
@ -68,49 +68,49 @@ @@ -68,49 +68,49 @@
</ul>
</div>
<div class="cache-status" data-service-worker-target="status">
<div class="cache-status" data-utility--service-worker-target="status">
Loading cache status...
</div>
<div class="cache-actions">
<button
class="cache-action-btn"
data-action="click->service-worker#displayCacheInfoAction"
data-action="click->utility--service-worker#displayCacheInfoAction"
>
Show Cache Status
</button>
<button
class="cache-action-btn"
data-action="click->service-worker#preloadCriticalAssetsAction"
data-action="click->utility--service-worker#preloadCriticalAssetsAction"
>
Preload Critical Assets
</button>
<button
class="cache-action-btn"
data-action="click->service-worker#refreshCacheAction"
data-action="click->utility--service-worker#refreshCacheAction"
>
Refresh All Caches
</button>
<button
class="cache-action-btn"
data-action="click->service-worker#clearAssetsCacheAction"
data-action="click->utility--service-worker#clearAssetsCacheAction"
>
Clear Assets Cache
</button>
<button
class="cache-action-btn"
data-action="click->service-worker#clearStaticCacheAction"
data-action="click->utility--service-worker#clearStaticCacheAction"
>
Clear Static Cache
</button>
<button
class="cache-action-btn danger"
data-action="click->service-worker#clearCacheAction"
data-action="click->utility--service-worker#clearCacheAction"
>
Clear All Caches
</button>

8
templates/base.html.twig

@ -31,14 +31,14 @@ @@ -31,14 +31,14 @@
{% block layout %}{% endblock %}
<div data-controller="install-prompt">
<div data-controller="utility--install-prompt">
<div
class="install-prompt-box hidden"
data-install-prompt-target="promptBox"
data-utility--install-prompt-target="promptBox"
>
<p>Install this app on your device for quick access?</p>
<button data-action="click->install-prompt#install">Yes, install</button>
<button data-action="click->install-prompt#dismiss">No thanks</button>
<button data-action="click->utility--install-prompt#install">Yes, install</button>
<button data-action="click->utility--install-prompt#dismiss">No thanks</button>
</div>
</div>

10
templates/components/Header.html.twig

@ -1,13 +1,13 @@ @@ -1,13 +1,13 @@
<header class="header" data-controller="menu" {{ attributes }}>
<header class="header" data-controller="ui--menu" {{ attributes }}>
<div class="container">
<div class="mobile-toggles" data-controller="sidebar-toggle">
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->sidebar-toggle#toggle">☰</button>
<div class="mobile-toggles" data-controller="ui--sidebar-toggle">
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->ui--sidebar-toggle#toggle">☰</button>
</div>
<div class="header__logo">
<h1 class="brand"><a href="/">Decent Newsroom</a></h1>
</div>
</div>
<div data-controller="progress-bar">
<div id="progress-bar" data-progress-bar-target="bar"></div>
<div data-controller="ui--progress-bar">
<div id="progress-bar" data-ui--progress-bar-target="bar"></div>
</div>
</header>

12
templates/components/Molecules/NostrPreview.html.twig

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
<div class="nostr-preview"
data-controller="nostr-preview"
data-nostr-preview-identifier-value="{{ preview.identifier }}"
data-nostr-preview-type-value="{{ preview.type }}"
data-nostr-preview-decoded-value="{{ preview.data|json_encode }}"
data-nostr-preview-full-match-value="{{ preview.full_match }}">
<div data-nostr-preview-target="container">
data-controller="nostr--nostr-preview"
data-nostr--nostr-preview-identifier-value="{{ preview.identifier }}"
data-nostr--nostr-preview-type-value="{{ preview.type }}"
data-nostr--nostr-preview-decoded-value="{{ preview.data|json_encode }}"
data-nostr--nostr-preview-full-match-value="{{ preview.full_match }}">
<div data-nostr--nostr-preview-target="container">
<div class="text-center my-2">
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
<span class="ms-2">Loading preview...</span>

16
templates/components/Organisms/CommentForm.html.twig

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
<form data-controller="nostr-comment"
data-nostr-comment-publish-url-value="{{ publish_url }}"
data-nostr-comment-csrf-token-value="{{ csrf_token }}"
data-nostr-comment-root="{{ root_context|json_encode|e('html_attr') }}"
data-nostr-comment-parent="{{ parent_context|json_encode|e('html_attr') }}"
data-action="submit->nostr-comment#publish"
<form data-controller="nostr--nostr-comment"
data-nostr--nostr-comment-publish-url-value="{{ publish_url }}"
data-nostr--nostr-comment-csrf-token-value="{{ csrf_token }}"
data-nostr--nostr-comment-root="{{ root_context|json_encode|e('html_attr') }}"
data-nostr--nostr-comment-parent="{{ parent_context|json_encode|e('html_attr') }}"
data-action="submit->nostr--nostr-comment#publish"
class="nip22-comment-form">
<div data-nostr-comment-target="status"></div>
<div data-nostr--nostr-comment-target="status"></div>
<div class="mb-2">
<label for="comment_content_{{ form_id }}" class="form-label hidden">Comment</label>
<textarea id="comment_content_{{ form_id }}" name="comment[content]" class="form-control" rows="3" required placeholder="Write your comment"></textarea>
@ -13,6 +13,6 @@ @@ -13,6 +13,6 @@
<input type="hidden" name="comment[root]" value='{{ root_context|json_encode|e('html_attr') }}'>
<input type="hidden" name="comment[parent]" value='{{ parent_context|json_encode|e('html_attr') }}'>
<div class="actions">
<button type="submit" data-nostr-comment-target="publishButton" class="btn btn-primary mt-2">Publish Comment</button>
<button type="submit" data-nostr--nostr-comment-target="publishButton" class="btn btn-primary mt-2">Publish Comment</button>
</div>
</form>

20
templates/components/ReadingListDropdown.html.twig

@ -3,24 +3,24 @@ @@ -3,24 +3,24 @@
{% set csrfToken = csrf_token('nostr_publish') %}
<div {{ attributes }}
data-controller="reading-list-dropdown"
data-reading-list-dropdown-coordinate-value="{{ coordinate }}"
data-reading-list-dropdown-lists-value="{{ lists|json_encode|e('html_attr') }}"
data-reading-list-dropdown-publish-url-value="{{ publishUrl }}"
data-reading-list-dropdown-csrf-token-value="{{ csrfToken }}">
data-controller="content--reading-list-dropdown"
data-content--reading-list-dropdown-coordinate-value="{{ coordinate }}"
data-content--reading-list-dropdown-lists-value="{{ lists|json_encode|e('html_attr') }}"
data-content--reading-list-dropdown-publish-url-value="{{ publishUrl }}"
data-content--reading-list-dropdown-csrf-token-value="{{ csrfToken }}">
<div class="dropdown">
<button class="btn btn-outline-primary dropdown-toggle"
type="button"
id="readingListDropdown"
data-reading-list-dropdown-target="dropdown"
data-action="click->reading-list-dropdown#toggleDropdown"
data-content--reading-list-dropdown-target="dropdown"
data-action="click->content--reading-list-dropdown#toggleDropdown"
aria-expanded="false">
Add to Reading List
</button>
<ul class="dropdown-menu"
aria-labelledby="readingListDropdown"
data-reading-list-dropdown-target="menu">
data-content--reading-list-dropdown-target="menu">
{% if lists is empty %}
<li>
<span class="dropdown-item disabled">
@ -34,7 +34,7 @@ @@ -34,7 +34,7 @@
<li>
<a class="dropdown-item"
href="#"
data-action="click->reading-list-dropdown#addToList"
data-action="click->content--reading-list-dropdown#addToList"
data-slug="{{ list.slug }}"
data-title="{{ list.title }}">
<div class="d-flex flex-row justify-content-between">
@ -62,5 +62,5 @@ @@ -62,5 +62,5 @@
</ul>
</div>
<div data-reading-list-dropdown-target="status" style="display: none;"></div>
<div data-content--reading-list-dropdown-target="status" style="display: none;"></div>
</div>

18
templates/components/ReadingListWorkflowStatus.html.twig

@ -1,14 +1,14 @@ @@ -1,14 +1,14 @@
<div {{ attributes }}>
<div class="workflow-status-card"
data-controller="workflow-progress"
data-workflow-progress-percentage-value="{{ this.completionPercentage }}"
data-workflow-progress-status-value="{{ this.currentState }}"
data-workflow-progress-color-value="{{ this.badgeColor }}"
data-workflow-progress-animated-value="true">
data-controller="publishing--workflow-progress"
data-publishing--workflow-progress-percentage-value="{{ this.completionPercentage }}"
data-publishing--workflow-progress-status-value="{{ this.currentState }}"
data-publishing--workflow-progress-color-value="{{ this.badgeColor }}"
data-publishing--workflow-progress-animated-value="true">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Workflow Status</h6>
<span class="badge bg-{{ this.badgeColor }}" data-workflow-progress-target="badge">
<span class="badge bg-{{ this.badgeColor }}" data-publishing--workflow-progress-target="badge">
{{ this.statusMessage }}
</span>
</div>
@ -23,7 +23,7 @@ @@ -23,7 +23,7 @@
aria-valuemin="0"
aria-valuemax="100"
aria-label="{{ this.statusMessage }}: {{ this.completionPercentage }}% complete"
data-workflow-progress-target="bar"
data-publishing--workflow-progress-target="bar"
></div>
</div>
@ -31,13 +31,13 @@ @@ -31,13 +31,13 @@
<div class="workflow-state-info">
<p class="small text-muted mb-2">
<strong>Current State:</strong>
<span data-workflow-progress-target="statusText">
<span data-publishing--workflow-progress-target="statusText">
{{ this.currentState|replace({'_': ' '})|title }}
</span>
</p>
{% if this.nextSteps is not empty %}
<div class="next-steps" data-workflow-progress-target="nextSteps">
<div class="next-steps" data-publishing--workflow-progress-target="nextSteps">
<p class="small mb-1"><strong>Next Steps:</strong></p>
<ul class="small mb-0">
{% for step in this.nextSteps %}

4
templates/components/SearchComponent.html.twig

@ -9,8 +9,8 @@ @@ -9,8 +9,8 @@
placeholder="{{ 'text.search'|trans }}"
data-model="norender|query"
value="{{ this.query }}"
data-controller="search-broadcast"
data-action="input->search-broadcast#onInput"
data-controller="search--search-broadcast"
data-action="input->search--search-broadcast#onInput"
/>
<button type="submit"><twig:ux:icon name="iconoir:search" class="icon" /></button>
</label>

6
templates/components/UserMenu.html.twig

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
<div {{ attributes.defaults(stimulus_controller('login')) }}>
<div {{ attributes.defaults(stimulus_controller('utility--login')) }}>
{% if app.user %}
<div class="notice info">
<twig:Molecules:UserFromNpub ident="{{ app.user.npub }}" />
@ -35,8 +35,8 @@ @@ -35,8 +35,8 @@
</span>
<ul>
<li>
<div data-login-target="nostrError" class="nostr-error-message" style="display:none;color:#b00;margin-bottom:0.5em;"></div>
<twig:Atoms:Button {{ ...stimulus_action('login', 'loginAct') }} tag="a" variant="accent">Extension</twig:Atoms:Button>
<div data-utility--login-target="nostrError" class="nostr-error-message" style="display:none;color:#b00;margin-bottom:0.5em;"></div>
<twig:Atoms:Button {{ ...stimulus_action('utility--login', 'loginAct') }} tag="a" variant="accent">Extension</twig:Atoms:Button>
</li>
<li>
<twig:Atoms:Button href="{{ path('app_login_signer') }}" tag="a" variant="accent">Signer</twig:Atoms:Button>

10
templates/feedback/form.html.twig

@ -5,15 +5,15 @@ @@ -5,15 +5,15 @@
<div class="w-container mt-5">
<twig:Atoms:PageHeading heading="Feedback" tagline="Bug reports and feature requests"/>
<section>
<form data-controller="nostr-single-sign" data-nostr-single-sign-event-value='{{ {
<form data-controller="nostr--nostr-single-sign" data-nostr--nostr-single-sign-event-value='{{ {
"kind": 24,
"tags": [...recipients|map(r => ["p", r]), ['client', 'Decent Newsroom']],
"content": ""
}|json_encode(constant('JSON_UNESCAPED_SLASHES')) }}'
data-nostr-single-sign-publish-url-value="/api/nostr/publish"
data-nostr-single-sign-csrf-token-value="{{ csrf_token('feedback') }}"
data-action="submit->nostr-single-sign#signAndPublish">
<div data-nostr-single-sign-target="status"></div>
data-nostr--nostr-single-sign-publish-url-value="/api/nostr/publish"
data-nostr--nostr-single-sign-csrf-token-value="{{ csrf_token('feedback') }}"
data-action="submit->nostr--nostr-single-sign#signAndPublish">
<div data-nostr--nostr-single-sign-target="status"></div>
<div class="mb-3">
<p>To:
{% for r in recipients %}

4
templates/layout.html.twig

@ -1,11 +1,11 @@ @@ -1,11 +1,11 @@
{% extends 'base.html.twig' %}
{% block layout %}
<div class="layout" data-controller="sidebar-toggle">
<div class="layout" data-controller="ui--sidebar-toggle">
<div>
<nav id="leftNav">
<header>
<button class="close" data-action="click->sidebar-toggle#close" aria-label="Close left sidebar">✕</button>
<button class="close" data-action="click->ui--sidebar-toggle#close" aria-label="Close left sidebar">✕</button>
</header>
<ul class="user-nav">
<li>

6
templates/login/amber.html.twig

@ -7,14 +7,14 @@ @@ -7,14 +7,14 @@
<h1 class="h3 mb-4">Remote Signer</h1>
<p class="text-muted">Scan the QR below with a NIP-46 compatible bunker signer to pair a remote signing session. Keep this page open while pairing.</p>
<div class="card">
<div class="card-body" data-controller="amber-connect">
<div class="card-body" data-controller="nostr--amber-connect">
<div class="row">
<div class="col-md-6 text-center mb-3">
<div data-amber-connect-target="qr" class="mb-2"></div>
<div data-nostr--amber-connect-target="qr" class="mb-2"></div>
</div>
<div class="col-md-6">
<h2 class="h6">Status</h2>
<div data-amber-connect-target="status" class="small text-muted">Initializing…</div>
<div data-nostr--amber-connect-target="status" class="small text-muted">Initializing…</div>
<hr>
<p class="small text-muted mb-1">After pairing, any page that uses window.nostr will automatically use this remote signer session.</p>
<p class="small text-muted">If pairing stalls, refresh this page to generate a new ephemeral key.</p>

4
templates/login/index.html.twig

@ -9,9 +9,9 @@ @@ -9,9 +9,9 @@
<div class="alert alert-success">You are already authenticated.</div>
{% else %}
<div class="card">
<div class="card-body" data-controller="login">
<div class="card-body" data-controller="utility--login">
<p class="text-muted small mb-3">Use your Nostr credentials to authenticate.</p>
<button class="btn btn--primary" data-action="click->login#loginAct">Login with Extension</button>
<button class="btn btn--primary" data-action="click->utility--login#loginAct">Login with Extension</button>
<a class="btn btn--primary" href="{{ path('app_login_signer') }}">Login with a remote signer</a>
</div>
</div>

8
templates/nzine/list.html.twig

@ -35,11 +35,11 @@ @@ -35,11 +35,11 @@
<span class="btn btn-sm btn-outline-secondary disabled" title="Publish the NZine first">View</span>
{% endif %}
{% if nzine.npub %}
<span data-controller="copy-to-clipboard">
<span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ nzine.npub }}</span>
<span data-controller="utility--copy-to-clipboard">
<span class="hidden" data-utility--copy-to-clipboard-target="textToCopy">{{ nzine.npub }}</span>
<button class="btn btn-sm btn-secondary"
data-copy-to-clipboard-target="copyButton"
data-action="click->copy-to-clipboard#copyToClipboard"
data-utility--copy-to-clipboard-target="copyButton"
data-action="click->utility--copy-to-clipboard#copyToClipboard"
title="Copy npub">Copy npub</button>
</span>
{% endif %}

16
templates/pages/article.html.twig

@ -16,25 +16,25 @@ @@ -16,25 +16,25 @@
<twig:Organisms:MagazineHero :mag="mag" :magazine="magazine" />
{% endif %}
<article class="w-container" data-controller="highlights-toggle">
<article class="w-container" data-controller="ui--highlights-toggle">
<div class="article-actions">
<div data-controller="share-dropdown" class="dropdown share-dropdown" style="display:inline-block;position:relative;">
<button data-share-dropdown-target="button"
<div data-controller="utility--share-dropdown" class="dropdown share-dropdown" style="display:inline-block;position:relative;">
<button data-utility--share-dropdown-target="button"
class="btn btn-secondary"
id="shareBtn"
type="button"
aria-haspopup="true"
aria-expanded="false"
data-action="click->share-dropdown#toggle">
data-action="click->utility--share-dropdown#toggle">
Share
</button>
<div data-share-dropdown-target="menu"
<div data-utility--share-dropdown-target="menu"
class="dropdown-menu"
id="shareDropdown"
style="display:none;position:absolute;z-index:1000;min-width:200px;">
<button class="dropdown-item"
type="button"
data-action="click->share-dropdown#copy"
data-action="click->utility--share-dropdown#copy"
data-copy="{{ canonical|e('js') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="vertical-align:middle;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h6m0 0v6m0-6L10 19l-7-7" />
@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
</button>
<button class="dropdown-item"
type="button"
data-action="click->share-dropdown#copy"
data-action="click->utility--share-dropdown#copy"
data-copy="{{ article|naddrEncode }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" style="vertical-align:middle;">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12H9m12 0A9 9 0 11 3 12a9 9 0 0118 0z" />
@ -136,7 +136,9 @@ @@ -136,7 +136,9 @@
<hr class="divider" />
<div class="tags">
{% for tag in article.topics %}
{% if tag is not empty %}
<a href="{{ path('forum_tag', {'tag': tag|url_encode}) }}" class="tag">#{{ tag }}</a>
{% endif %}
{% endfor %}
</div>
{% endif %}

46
templates/pages/editor.html.twig

@ -3,11 +3,11 @@ @@ -3,11 +3,11 @@
{% form_theme form _self 'pages/_advanced_metadata.html.twig' %}
{% block quill_widget %}
<div {{ stimulus_controller('quill') }} class="quill" data-id="{{ id }}" >
<div {{ stimulus_controller('publishing--quill') }} class="quill" data-id="{{ id }}" >
<div id="editor">
{{ value|raw }}
</div>
<input type="hidden" name="editor[content_md]" data-quill-target="markdown">
<input type="hidden" name="editor[content_md]" data-publishing--quill-target="markdown">
<input type="hidden" {{ block('widget_attributes') }} value="{{ value }}" />
</div>
{% endblock %}
@ -20,10 +20,10 @@ @@ -20,10 +20,10 @@
<p>A Nostr identity is required to post articles.</p>
</div>
{% endif %}
<div {{ stimulus_controller('nostr-publish', {
<div {{ stimulus_controller('nostr--nostr-publish', {
publishUrl: path('api-article-publish'),
csrfToken: csrf_token('nostr_publish')
}) }} data-nostr-publish-target="form" data-slug="{{ article.slug|default('') }}">
}) }} data-nostr--nostr-publish-target="form" data-slug="{{ article.slug|default('') }}">
{{ form_start(form) }}
{{ form_row(form.slug) }}
@ -32,37 +32,37 @@ @@ -32,37 +32,37 @@
{{ form_row(form.content) }}
{{ form_row(form.image) }}
<div class="actions" data-controller="image-upload">
<div class="actions" data-controller="publishing--image-upload">
<button type="button"
class="btn btn-secondary"
data-action="click->image-upload#openDialog">
data-action="click->publishing--image-upload#openDialog">
Upload Image
</button>
<div data-image-upload-target="dialog" class="iu-dialog">
<div class="iu-backdrop" data-action="click->image-upload#closeDialog"></div>
<div data-publishing--image-upload-target="dialog" class="iu-dialog">
<div class="iu-backdrop" data-action="click->publishing--image-upload#closeDialog"></div>
<div class="iu-modal">
<div class="modal-header">
<h5>Upload Image</h5>
<button type="button" class="close" data-action="click->image-upload#closeDialog">&times;</button>
<button type="button" class="close" data-action="click->publishing--image-upload#closeDialog">&times;</button>
</div>
<div class="modal-body">
<div>
<label for="upload-provider">Upload to</label>
<select id="upload-provider" data-image-upload-target="provider">
<select id="upload-provider" data-publishing--image-upload-target="provider">
<option value="sovbit">files.sovbit.host</option>
<option value="nostrbuild">nostr.build</option>
<option value="nostrcheck">nostrcheck.me</option>
</select>
</div>
<div data-image-upload-target="dropArea" class="upload-area">
<div data-publishing--image-upload-target="dropArea" class="upload-area">
<span>Drag &amp; drop or click to select an image</span>
<input type="file" accept="image/*" data-image-upload-target="fileInput">
<input type="file" accept="image/*" data-publishing--image-upload-target="fileInput">
</div>
<div data-image-upload-target="progress" class="upload-progress"></div>
<div data-image-upload-target="error" class="upload-error"></div>
<div data-publishing--image-upload-target="progress" class="upload-progress"></div>
<div data-publishing--image-upload-target="error" class="upload-error"></div>
</div>
</div>
</div>
@ -87,31 +87,31 @@ @@ -87,31 +87,31 @@
<div class="mb-2">
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-nostr-publish-target="jsonToggle"
data-action="click->nostr-publish#toggleJsonPreview">
data-nostr--nostr-publish-target="jsonToggle"
data-action="click->nostr--nostr-publish#toggleJsonPreview">
Show raw event JSON
</button>
</div>
<div class="json-preview mb-3" data-nostr-publish-target="jsonContainer" hidden>
<div class="json-preview mb-3" data-nostr--nostr-publish-target="jsonContainer" hidden>
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="mb-0" for="raw-json">Raw Nostr event (editable)</label>
<div>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" data-action="click->nostr-publish#regenerateJsonPreview">Rebuild from form</button>
<span class="text-muted" data-nostr-publish-target="jsonDirtyHint" style="display:none">modified</span>
<button type="button" class="btn btn-outline-secondary btn-sm me-2" data-action="click->nostr--nostr-publish#regenerateJsonPreview">Rebuild from form</button>
<span class="text-muted" data-nostr--nostr-publish-target="jsonDirtyHint" style="display:none">modified</span>
</div>
</div>
<textarea id="raw-json" data-nostr-publish-target="jsonTextarea" data-action="input->nostr-publish#onJsonInput" class="form-control" rows="12" spellcheck="false" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"></textarea>
<textarea id="raw-json" data-nostr--nostr-publish-target="jsonTextarea" data-action="input->nostr--nostr-publish#onJsonInput" class="form-control" rows="12" spellcheck="false" style="font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;"></textarea>
<small class="text-muted d-block mt-1">Edits here override the form when publishing. Keep required fields: kind, created_at, tags, content, pubkey.</small>
</div>
<!-- Status messages -->
<div data-nostr-publish-target="status"></div>
<div data-nostr--nostr-publish-target="status"></div>
<button type="button"
class="btn btn-primary"
data-nostr-publish-target="publishButton"
data-action="click->nostr-publish#publish">
data-nostr--nostr-publish-target="publishButton"
data-action="click->nostr--nostr-publish#publish">
Publish
</button>
</div>

12
templates/partial/_gallery.html.twig

@ -65,7 +65,7 @@ @@ -65,7 +65,7 @@
{% endfor %}
{% if images|length > 0 %}
<div class="gallery-view" data-controller="gallery">
<div class="gallery-view" data-controller="ui--gallery">
<div class="main-image-wrapper">
{% set main = images[0] %}
<figure class="media">
@ -78,7 +78,7 @@ @@ -78,7 +78,7 @@
{% if main.dimensions %}data-dimensions="{{ main.dimensions }}"{% endif %}
{% if main.blurhash %}data-blurhash="{{ main.blurhash }}"{% endif %}
class="picture-image main-image"
data-gallery-target="mainImage"
data-ui--gallery-target="mainImage"
/>
{% for fallback in main.fallbacks %}
<source srcset="{{ fallback }}" />
@ -86,14 +86,14 @@ @@ -86,14 +86,14 @@
</picture>
{% if images|length > 1 %}
<div class="thumbnails" data-gallery-target="thumbnails">
<div class="thumbnails" data-ui--gallery-target="thumbnails">
{% for img in images %}
<img src="{{ img.url }}"
alt="{{ img.altText|default('Picture') }}"
class="thumbnail{% if loop.first %} selected{% endif %}"
data-gallery-target="thumbnail"
data-action="click->gallery#switch"
data-gallery-index="{{ loop.index0 }}"
data-ui--gallery-target="thumbnail"
data-action="click->ui--gallery#switch"
data-ui--gallery-index="{{ loop.index0 }}"
{% if img.dimensions %}data-dimensions="{{ img.dimensions }}"{% endif %}
{% if img.blurhash %}data-blurhash="{{ img.blurhash }}"{% endif %}
/>

8
templates/profile/author-media.html.twig

@ -13,10 +13,10 @@ @@ -13,10 +13,10 @@
<div class="w-container">
{% if pictureEvents|length > 0 %}
<div data-controller="media-loader"
data-media-loader-npub-value="{{ npub }}"
data-media-loader-total-value="{{ total }}">
<div class="masonry-grid" data-media-loader-target="grid">
<div data-controller="media--media-loader"
data-media--media-loader-npub-value="{{ npub }}"
data-media--media-loader-total-value="{{ total }}">
<div class="masonry-grid" data-media--media-loader-target="grid">
{% for event in pictureEvents %}
<div class="masonry-item">
{# Extract title #}

2
templates/profile/author.html.twig

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
<a href="{{ path('author-media', {'npub': npub}) }}" class="tab-link">Media</a>
</div>
<div class="w-container" data-controller="author-articles" data-author-articles-pubkey-value="{{ pubkey }}" data-author-articles-hub-url-value="{{ mercure_public_hub_url }}">
<div class="w-container" data-controller="content--author-articles" data-content--author-articles-pubkey-value="{{ pubkey }}" data-content--author-articles-hub-url-value="{{ mercure_public_hub_url }}">
{% if articles|length > 0 %}
<twig:Organisms:CardList :list="articles" class="article-list"></twig:Organisms:CardList>
{% else %}

8
templates/reading_list/index.html.twig

@ -26,11 +26,11 @@ @@ -26,11 +26,11 @@
<a class="btn btn-sm btn-primary" href="{{ path('read_wizard_articles', {'load': item.slug}) }}">Open Composer</a>
{% if item.slug %}
<a class="btn btn-sm btn-outline-primary" href="{{ path('reading-list', { slug: item.slug, npub: item.pubkey|toNpub }) }}">View</a>
<span data-controller="copy-to-clipboard">
<span class="hidden" data-copy-to-clipboard-target="textToCopy">{{ absolute_url(path('reading-list', { slug: item.slug, npub: item.pubkey|toNpub })) }}</span>
<span data-controller="utility--copy-to-clipboard">
<span class="hidden" data-utility--copy-to-clipboard-target="textToCopy">{{ absolute_url(path('reading-list', { slug: item.slug, npub: item.pubkey|toNpub })) }}</span>
<button class="btn btn-sm btn-secondary"
data-copy-to-clipboard-target="copyButton"
data-action="click->copy-to-clipboard#copyToClipboard">Copy link</button>
data-utility--copy-to-clipboard-target="copyButton"
data-action="click->utility--copy-to-clipboard#copyToClipboard">Copy link</button>
</span>
{% endif %}
</div>

12
templates/tabular_data/preview.html.twig

@ -4,10 +4,10 @@ @@ -4,10 +4,10 @@
{% 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 }}">
data-controller="publishing--tabular-publish"
data-publishing--tabular-publish-publish-url-value="{{ path('tabular_data_publish_event') }}"
data-publishing--tabular-publish-csrf-token-value="{{ csrf_token('tabular_publish') }}"
data-publishing--tabular-publish-event-data-value="{{ event|json_encode }}">
<div class="card">
<div class="card-header">
<h1 class="card-title">Event Preview</h1>
@ -15,8 +15,8 @@ @@ -15,8 +15,8 @@
<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>
<div data-publishing--tabular-publish-target="status"></div>
<button data-publishing--tabular-publish-target="publishButton" data-action="publishing--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>

Loading…
Cancel
Save