76 changed files with 844 additions and 295 deletions
@ -1,5 +1,5 @@ |
|||||||
import { Controller } from '@hotwired/stimulus'; |
import { Controller } from '@hotwired/stimulus'; |
||||||
import { getSigner } from './signer_manager.js'; |
import { getSigner } from '../nostr/signer_manager.js'; |
||||||
|
|
||||||
export default class extends Controller { |
export default class extends Controller { |
||||||
static targets = ['dropdown', 'status', 'menu']; |
static targets = ['dropdown', 'status', 'menu']; |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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,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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
|
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -1,13 +1,13 @@ |
|||||||
<header class="header" data-controller="menu" {{ attributes }}> |
<header class="header" data-controller="ui--menu" {{ attributes }}> |
||||||
<div class="container"> |
<div class="container"> |
||||||
<div class="mobile-toggles" data-controller="sidebar-toggle"> |
<div class="mobile-toggles" data-controller="ui--sidebar-toggle"> |
||||||
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->sidebar-toggle#toggle">☰</button> |
<button class="toggle" aria-controls="leftNav" aria-expanded="false" data-action="click->ui--sidebar-toggle#toggle">☰</button> |
||||||
</div> |
</div> |
||||||
<div class="header__logo"> |
<div class="header__logo"> |
||||||
<h1 class="brand"><a href="/">Decent Newsroom</a></h1> |
<h1 class="brand"><a href="/">Decent Newsroom</a></h1> |
||||||
</div> |
</div> |
||||||
</div> |
</div> |
||||||
<div data-controller="progress-bar"> |
<div data-controller="ui--progress-bar"> |
||||||
<div id="progress-bar" data-progress-bar-target="bar"></div> |
<div id="progress-bar" data-ui--progress-bar-target="bar"></div> |
||||||
</div> |
</div> |
||||||
</header> |
</header> |
||||||
|
|||||||
Loading…
Reference in new issue