24 changed files with 800 additions and 116 deletions
@ -0,0 +1,125 @@ |
|||||||
|
/* Discover Page Styles */ |
||||||
|
|
||||||
|
/* Discover Search Form */ |
||||||
|
.discover-search-form { |
||||||
|
max-width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form .search { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
align-items: center; |
||||||
|
width: 100%; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form input[type="search"] { |
||||||
|
flex: 1; |
||||||
|
padding: 0.75rem 1rem; |
||||||
|
transition: border-color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form input[type="search"]:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--primary-color, #007bff); |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form button[type="submit"] { |
||||||
|
padding: 0.75rem 1.5rem; |
||||||
|
white-space: nowrap; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form button[type="submit"] .icon { |
||||||
|
width: 1.25rem; |
||||||
|
height: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-section .section-heading { |
||||||
|
font-size: 1.75rem; |
||||||
|
font-weight: 700; |
||||||
|
margin-bottom: 0.25rem; |
||||||
|
color: var(--text-primary, #1a1a1a); |
||||||
|
} |
||||||
|
|
||||||
|
.discover-section .section-subheading { |
||||||
|
font-size: 0.95rem; |
||||||
|
color: var(--text-secondary, #666); |
||||||
|
margin-bottom: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-section .section-header { |
||||||
|
border-bottom: 2px solid var(--border-color, #e0e0e0); |
||||||
|
padding-bottom: 1rem; |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Discover Sidebar */ |
||||||
|
.discover-sidebar { |
||||||
|
position: sticky; |
||||||
|
top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-sidebar .sidebar-section { |
||||||
|
background: var(--card-bg, #f8f9fa); |
||||||
|
padding: 1.5rem; |
||||||
|
border-radius: 8px; |
||||||
|
border: 1px solid var(--border-color, #e0e0e0); |
||||||
|
} |
||||||
|
|
||||||
|
.discover-sidebar h3 { |
||||||
|
font-size: 1.1rem; |
||||||
|
font-weight: 700; |
||||||
|
margin-bottom: 1rem; |
||||||
|
color: var(--text-primary, #1a1a1a); |
||||||
|
} |
||||||
|
|
||||||
|
.discover-sidebar ul li { |
||||||
|
margin-bottom: 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-sidebar ul li a { |
||||||
|
color: var(--text-primary, #1a1a1a); |
||||||
|
text-decoration: none; |
||||||
|
transition: color 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-sidebar ul li a:hover { |
||||||
|
color: var(--primary-color, #007bff); |
||||||
|
} |
||||||
|
|
||||||
|
/* Highlights Preview Grid */ |
||||||
|
#highlights-preview .highlights-grid { |
||||||
|
display: grid; |
||||||
|
gap: 1.5rem; |
||||||
|
grid-template-columns: 1fr; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 768px) { |
||||||
|
#highlights-preview .highlights-grid { |
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* Responsive adjustments */ |
||||||
|
@media (max-width: 768px) { |
||||||
|
.discover-section .section-header { |
||||||
|
flex-direction: column; |
||||||
|
align-items: flex-start !important; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-section .section-header .btn { |
||||||
|
margin-top: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form .search { |
||||||
|
flex-direction: column; |
||||||
|
} |
||||||
|
|
||||||
|
.discover-search-form button[type="submit"] { |
||||||
|
width: 100%; |
||||||
|
justify-content: center; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,119 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace App\Session; |
||||||
|
|
||||||
|
use Psr\Log\LoggerInterface; |
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\Handler\AbstractSessionHandler; |
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\Handler\NativeFileSessionHandler; |
||||||
|
use Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler; |
||||||
|
|
||||||
|
/** |
||||||
|
* A session handler that tries Redis first, but gracefully falls back to files |
||||||
|
* if Redis is unavailable at runtime. |
||||||
|
*/ |
||||||
|
class GracefulSessionHandler extends AbstractSessionHandler |
||||||
|
{ |
||||||
|
private RedisSessionHandler $redisHandler; |
||||||
|
private NativeFileSessionHandler $fileHandler; |
||||||
|
private LoggerInterface $logger; |
||||||
|
|
||||||
|
private bool $useRedis = true; |
||||||
|
private bool $handlerOpened = false; |
||||||
|
|
||||||
|
public function __construct(RedisSessionHandler $redisHandler, NativeFileSessionHandler $fileHandler, LoggerInterface $logger) |
||||||
|
{ |
||||||
|
$this->redisHandler = $redisHandler; |
||||||
|
$this->fileHandler = $fileHandler; |
||||||
|
$this->logger = $logger; |
||||||
|
} |
||||||
|
|
||||||
|
protected function doRead(string $sessionId): string |
||||||
|
{ |
||||||
|
if ($this->useRedis) { |
||||||
|
try { |
||||||
|
$this->handlerOpened = true; |
||||||
|
return $this->redisHandler->read($sessionId); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->useRedis = false; |
||||||
|
$this->logger->warning('Redis session read failed; falling back to files', ['e' => $e->getMessage()]); |
||||||
|
} |
||||||
|
} |
||||||
|
$this->handlerOpened = true; |
||||||
|
return $this->fileHandler->read($sessionId); |
||||||
|
} |
||||||
|
|
||||||
|
public function updateTimestamp(string $sessionId, string $data): bool |
||||||
|
{ |
||||||
|
if ($this->useRedis) { |
||||||
|
try { |
||||||
|
return $this->redisHandler->updateTimestamp($sessionId, $data); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->useRedis = false; |
||||||
|
$this->logger->warning('Redis session touch failed; falling back to files', ['e' => $e->getMessage()]); |
||||||
|
} |
||||||
|
} |
||||||
|
// NativeFileSessionHandler doesn't support updateTimestamp, just return true |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
protected function doWrite(string $sessionId, string $data): bool |
||||||
|
{ |
||||||
|
if ($this->useRedis) { |
||||||
|
try { |
||||||
|
return $this->redisHandler->write($sessionId, $data); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->useRedis = false; |
||||||
|
$this->logger->warning('Redis session write failed; falling back to files', ['e' => $e->getMessage()]); |
||||||
|
} |
||||||
|
} |
||||||
|
return $this->fileHandler->write($sessionId, $data); |
||||||
|
} |
||||||
|
|
||||||
|
protected function doDestroy(string $sessionId): bool |
||||||
|
{ |
||||||
|
if ($this->useRedis) { |
||||||
|
try { |
||||||
|
return $this->redisHandler->destroy($sessionId); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->useRedis = false; |
||||||
|
$this->logger->warning('Redis session destroy failed; falling back to files', ['e' => $e->getMessage()]); |
||||||
|
} |
||||||
|
} |
||||||
|
return $this->fileHandler->destroy($sessionId); |
||||||
|
} |
||||||
|
|
||||||
|
public function close(): bool |
||||||
|
{ |
||||||
|
if (!$this->handlerOpened) { |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if ($this->useRedis) { |
||||||
|
return $this->redisHandler->close(); |
||||||
|
} else { |
||||||
|
return $this->fileHandler->close(); |
||||||
|
} |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->logger->warning('Session close error', ['e' => $e->getMessage()]); |
||||||
|
return false; |
||||||
|
} finally { |
||||||
|
$this->handlerOpened = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public function gc(int $max_lifetime): int|false |
||||||
|
{ |
||||||
|
if ($this->useRedis) { |
||||||
|
try { |
||||||
|
return $this->redisHandler->gc($max_lifetime); |
||||||
|
} catch (\Throwable $e) { |
||||||
|
$this->useRedis = false; |
||||||
|
$this->logger->warning('Redis session gc failed; falling back to files', ['e' => $e->getMessage()]); |
||||||
|
} |
||||||
|
} |
||||||
|
return $this->fileHandler->gc($max_lifetime); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,32 @@ |
|||||||
|
{% extends 'layout.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
<twig:Atoms:PageHeading heading="{{ category.name }}"/> |
||||||
|
|
||||||
|
<div class="subcategories-grid"> |
||||||
|
{% set cat = topics[categoryKey] ?? null %} |
||||||
|
{% if cat %} |
||||||
|
{% for subKey, sub in cat.subcategories %} |
||||||
|
<div class="sub-card"> |
||||||
|
<h3> |
||||||
|
<a href="{{ path('forum_topic', {'key': categoryKey ~ '-' ~ subKey}) }}">{{ sub.name }}</a> |
||||||
|
</h3> |
||||||
|
<div class="d-flex flex-row"> |
||||||
|
<div class="tags m-0"> |
||||||
|
{% for tag in sub.tags %} |
||||||
|
<a class="tag" href="{{ path('forum_tag', {'tag': tag}) }}">{{ tag }}</a> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
<div class="count">{{ sub.count|default(0) }}</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{% endfor %} |
||||||
|
{% else %} |
||||||
|
<div class="alert alert-info">No subcategories available for this topic.</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block aside %} |
||||||
|
<twig:Atoms:ForumAside /> |
||||||
|
{% endblock %} |
||||||
@ -0,0 +1,57 @@ |
|||||||
|
{% extends 'layout.html.twig' %} |
||||||
|
|
||||||
|
{% block body %} |
||||||
|
{# Search Section #} |
||||||
|
<section class="w-container"> |
||||||
|
<div class="discover-section"> |
||||||
|
<form action="{{ path('app_search_index') }}" method="get" class="discover-search-form"> |
||||||
|
<label class="search"> |
||||||
|
<input type="search" |
||||||
|
name="q" |
||||||
|
placeholder="Search for articles..." |
||||||
|
class="form-control" |
||||||
|
required |
||||||
|
/> |
||||||
|
<button type="submit" class="btn btn-primary"> |
||||||
|
<twig:ux:icon name="iconoir:search" class="icon" /> Search |
||||||
|
</button> |
||||||
|
</label> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
{# Main Topics (visible on small screens when sidebar is hidden) #} |
||||||
|
<section class="w-container d-md-none mb-3"> |
||||||
|
<div class="discover-section topics-strip"> |
||||||
|
{% if mainTopicsMap is defined and mainTopicsMap is not empty %} |
||||||
|
<div class="topics-buttons d-flex flex-row flex-wrap gap-2 justify-content-center"> |
||||||
|
<a href="{{ path('forum') }}" |
||||||
|
class="btn btn-outline-primary btn-sm" |
||||||
|
>All</a> |
||||||
|
{% for key, name in mainTopicsMap %} |
||||||
|
<a href="{{ path('forum_main_topic', { topic: key }) }}" |
||||||
|
class="btn btn-outline-primary btn-sm"> |
||||||
|
{{ name }} |
||||||
|
</a> |
||||||
|
{% endfor %} |
||||||
|
</div> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
|
||||||
|
<section class="w-container"> |
||||||
|
<div class="discover-section"> |
||||||
|
{% if articles is empty %} |
||||||
|
<div class="alert alert-info"> |
||||||
|
No articles found. |
||||||
|
</div> |
||||||
|
{% else %} |
||||||
|
<twig:Organisms:CardList :list="articles" :authorsMetadata="authorsMetadata" class="article-list" /> |
||||||
|
{% endif %} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
{% endblock %} |
||||||
|
|
||||||
|
{% block aside %} |
||||||
|
<twig:Atoms:ForumAside /> |
||||||
|
{% endblock %} |
||||||
Loading…
Reference in new issue