Browse Source

Featured writers

imwald
Nuša Pukšič 1 month ago
parent
commit
9808c5dbae
  1. 83
      assets/styles/04-pages/discover.css
  2. 158
      src/Controller/Administration/RoleController.php
  3. 12
      src/Entity/User.php
  4. 1
      src/Enum/RolesEnum.php
  5. 121
      src/Repository/UserEntityRepository.php
  6. 63
      src/Twig/Components/Atoms/FeaturedWriters.php
  7. 118
      templates/admin/roles.html.twig
  8. 47
      templates/components/Atoms/FeaturedWriters.html.twig
  9. 1
      templates/pages/discover.html.twig
  10. 1
      templates/pages/newsstand.html.twig

83
assets/styles/04-pages/discover.css

@ -102,6 +102,89 @@
} }
} }
/* Featured Writers Section */
.featured-writers {
margin-bottom: var(--spacing-2);
}
.featured-writers-list {
margin: 0;
padding: 0;
}
.featured-writer-item {
margin-bottom: 0.75rem;
}
.featured-writer-item:last-child {
margin-bottom: 0;
}
.featured-writer-link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 6px;
text-decoration: none;
color: var(--text-primary, #1a1a1a);
transition: background-color 0.2s;
}
.featured-writer-link:hover {
background-color: var(--hover-bg, rgba(0, 0, 0, 0.05));
text-decoration: none;
}
.featured-writer-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.featured-writer-avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--placeholder-bg, #e0e0e0);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.featured-writer-avatar-placeholder .icon {
width: 20px;
height: 20px;
color: var(--text-secondary, #666);
}
.featured-writer-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.featured-writer-name {
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary, #1a1a1a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.featured-writer-nip05 {
font-size: 0.75rem;
color: var(--text-secondary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.discover-section .section-header { .discover-section .section-header {

158
src/Controller/Administration/RoleController.php

@ -4,8 +4,11 @@ declare(strict_types=1);
namespace App\Controller\Administration; namespace App\Controller\Administration;
use App\Entity\User;
use App\Form\RoleType; use App\Form\RoleType;
use App\Repository\UserEntityRepository; use App\Repository\UserEntityRepository;
use App\Service\RedisCacheService;
use App\Util\NostrKeyUtil;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
@ -16,12 +19,17 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt
class RoleController extends AbstractController class RoleController extends AbstractController
{ {
#[Route('/admin/role', name: 'admin_roles')] #[Route('/admin/role', name: 'admin_roles')]
public function index(): Response public function index(UserEntityRepository $userRepository, RedisCacheService $redisCacheService): Response
{ {
$form = $this->createForm(RoleType::class); $form = $this->createForm(RoleType::class);
// Get featured writers for display
$featuredWriters = $userRepository->findFeaturedWriters();
$featuredWritersData = $this->enrichUsersWithMetadata($featuredWriters, $redisCacheService);
return $this->render('admin/roles.html.twig', [ return $this->render('admin/roles.html.twig', [
'form' => $form->createView(), 'form' => $form->createView(),
'featuredWriters' => $featuredWritersData,
]); ]);
} }
@ -29,7 +37,7 @@ class RoleController extends AbstractController
* Add a role to current user as submitted in a form * Add a role to current user as submitted in a form
*/ */
#[Route('/admin/role/add', name: 'admin_roles_add')] #[Route('/admin/role/add', name: 'admin_roles_add')]
public function addRole(Request $request, UserEntityRepository $userRepository, EntityManagerInterface $em, TokenStorageInterface $tokenStorage): Response public function addRole(Request $request, UserEntityRepository $userRepository, EntityManagerInterface $em, TokenStorageInterface $tokenStorage, RedisCacheService $redisCacheService): Response
{ {
// get role from request and add to current user's roles and save to db // get role from request and add to current user's roles and save to db
$npub = $this->getUser()->getUserIdentifier(); $npub = $this->getUser()->getUserIdentifier();
@ -38,12 +46,21 @@ class RoleController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) { if (!$form->isSubmitted() || !$form->isValid()) {
$featuredWriters = $userRepository->findFeaturedWriters();
$featuredWritersData = $this->enrichUsersWithMetadata($featuredWriters, $redisCacheService);
return $this->render('admin/roles.html.twig', [ return $this->render('admin/roles.html.twig', [
'form' => $form->createView(), 'form' => $form->createView(),
'featuredWriters' => $featuredWritersData,
]); ]);
} }
$role = $form->get('role')->getData(); $role = $form->get('role')->getData();
if (!$role || !str_starts_with($role, 'ROLE_')) {
$this->addFlash('error', 'Invalid role format');
return $this->redirectToRoute('admin_roles');
}
$user = $userRepository->findOneBy(['npub' => $npub]); $user = $userRepository->findOneBy(['npub' => $npub]);
$user->addRole($role); $user->addRole($role);
$em->persist($user); $em->persist($user);
@ -60,8 +77,145 @@ class RoleController extends AbstractController
// add a flash message // add a flash message
$this->addFlash('success', 'Role added to user'); $this->addFlash('success', 'Role added to user');
$featuredWriters = $userRepository->findFeaturedWriters();
$featuredWritersData = $this->enrichUsersWithMetadata($featuredWriters, $redisCacheService);
return $this->render('admin/roles.html.twig', [ return $this->render('admin/roles.html.twig', [
'form' => $form->createView(), 'form' => $form->createView(),
'featuredWriters' => $featuredWritersData,
]); ]);
} }
/**
* Remove a role from current user
*/
#[Route('/admin/role/remove', name: 'admin_roles_remove', methods: ['POST'])]
public function removeOwnRole(Request $request, UserEntityRepository $userRepository, EntityManagerInterface $em, TokenStorageInterface $tokenStorage): Response
{
$npub = $this->getUser()->getUserIdentifier();
$role = $request->request->get('role');
if (!$role) {
$this->addFlash('error', 'Invalid role');
return $this->redirectToRoute('admin_roles');
}
// Prevent removing ROLE_ADMIN from yourself
if ($role === 'ROLE_ADMIN') {
$this->addFlash('error', 'Cannot remove ROLE_ADMIN from yourself');
return $this->redirectToRoute('admin_roles');
}
$user = $userRepository->findOneBy(['npub' => $npub]);
if (!$user) {
$this->addFlash('error', 'User not found');
return $this->redirectToRoute('admin_roles');
}
$user->removeRole($role);
$em->flush();
// Refresh the user token after update
$token = $tokenStorage->getToken();
if ($token) {
$token->setUser($user);
$tokenStorage->setToken($token);
}
$this->addFlash('success', 'Role removed');
return $this->redirectToRoute('admin_roles');
}
#[Route('/admin/featured-writers/add', name: 'admin_featured_writers_add', methods: ['POST'])]
public function addFeaturedWriter(Request $request, UserEntityRepository $userRepository, EntityManagerInterface $em): Response
{
$npub = $request->request->get('npub');
if (!$npub || !str_starts_with($npub, 'npub1')) {
$this->addFlash('error', 'Invalid npub format');
return $this->redirectToRoute('admin_roles');
}
$user = $userRepository->findOneBy(['npub' => $npub]);
if (!$user) {
// Create user if not exists
$user = new User();
$user->setNpub($npub);
$user->setRoles([User::ROLE_FEATURED_WRITER]);
$em->persist($user);
$this->addFlash('success', 'Created new user and added as featured writer');
} else {
if ($user->isFeaturedWriter()) {
$this->addFlash('warning', 'User is already a featured writer');
return $this->redirectToRoute('admin_roles');
}
$user->addRole(User::ROLE_FEATURED_WRITER);
$this->addFlash('success', 'User added as featured writer');
}
$em->flush();
return $this->redirectToRoute('admin_roles');
}
#[Route('/admin/featured-writers/remove/{id}', name: 'admin_featured_writers_remove', methods: ['POST'])]
public function removeFeaturedWriter(int $id, UserEntityRepository $userRepository, EntityManagerInterface $em): Response
{
$user = $userRepository->find($id);
if (!$user) {
$this->addFlash('error', 'User not found');
return $this->redirectToRoute('admin_roles');
}
$user->removeRole(User::ROLE_FEATURED_WRITER);
$em->flush();
$this->addFlash('success', 'User removed from featured writers');
return $this->redirectToRoute('admin_roles');
}
/**
* Enrich user array with Nostr metadata
* @param User[] $users
* @return array
*/
private function enrichUsersWithMetadata(array $users, RedisCacheService $redisCacheService): array
{
if (empty($users)) {
return [];
}
$hexPubkeys = [];
$npubToHex = [];
foreach ($users as $user) {
$npub = $user->getNpub();
if (NostrKeyUtil::isNpub($npub)) {
$hex = NostrKeyUtil::npubToHex($npub);
$hexPubkeys[] = $hex;
$npubToHex[$npub] = $hex;
}
}
$metadataMap = [];
if (!empty($hexPubkeys)) {
$metadataMap = $redisCacheService->getMultipleMetadata($hexPubkeys);
}
$result = [];
foreach ($users as $user) {
$npub = $user->getNpub();
$hex = $npubToHex[$npub] ?? null;
$result[] = [
'user' => $user,
'npub' => $npub,
'metadata' => $hex ? ($metadataMap[$hex] ?? null) : null,
];
}
return $result;
}
} }

12
src/Entity/User.php

@ -2,6 +2,7 @@
namespace App\Entity; namespace App\Entity;
use App\Enum\RolesEnum;
use App\Repository\UserEntityRepository; use App\Repository\UserEntityRepository;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@ -53,6 +54,17 @@ class User implements UserInterface, EquatableInterface
return $this; return $this;
} }
public function removeRole(string $role): self
{
$this->roles = array_filter($this->roles, fn($r) => $r !== $role);
return $this;
}
public function isFeaturedWriter(): bool
{
return in_array(RolesEnum::FEATURED_WRITER, $this->roles, true);
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;

1
src/Enum/RolesEnum.php

@ -7,4 +7,5 @@ enum RolesEnum: string
case USER = 'ROLE_USER'; case USER = 'ROLE_USER';
case ADMIN = 'ROLE_ADMIN'; case ADMIN = 'ROLE_ADMIN';
case EDITOR = 'ROLE_EDITOR'; case EDITOR = 'ROLE_EDITOR';
case FEATURED_WRITER = 'ROLE_FEATURED_WRITER';
} }

121
src/Repository/UserEntityRepository.php

@ -3,7 +3,9 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User; use App\Entity\User;
use App\Util\NostrKeyUtil;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@ -13,6 +15,7 @@ class UserEntityRepository extends ServiceEntityRepository
{ {
parent::__construct($registry, User::class); parent::__construct($registry, User::class);
} }
public function findOrCreateByUniqueField(User $user): User public function findOrCreateByUniqueField(User $user): User
{ {
$entity = $this->findOneBy(['npub' => $user->getNpub()]); $entity = $this->findOneBy(['npub' => $user->getNpub()]);
@ -25,4 +28,122 @@ class UserEntityRepository extends ServiceEntityRepository
return $user; return $user;
} }
/**
* Find all users with the ROLE_FEATURED_WRITER role
* @return User[]
* @throws Exception
*/
public function findFeaturedWriters(): array
{
$conn = $this->entityManager->getConnection();
$sql = 'SELECT id FROM app_user WHERE roles::text LIKE :role';
$result = $conn->executeQuery($sql, ['role' => '%' . User::ROLE_FEATURED_WRITER . '%']);
$ids = $result->fetchFirstColumn();
if (empty($ids)) {
return [];
}
return $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $ids)
->getQuery()
->getResult();
}
/**
* Find top featured writers ordered by their most recent article
* @param int $limit Maximum number of writers to return
* @return User[]
* @throws Exception
*/
public function findTopFeaturedWriters(int $limit = 3): array
{
$conn = $this->entityManager->getConnection();
// Get featured writer IDs and their npubs
$sql = 'SELECT id, npub FROM app_user WHERE roles::text LIKE :role';
$result = $conn->executeQuery($sql, ['role' => '%' . User::ROLE_FEATURED_WRITER . '%']);
$featuredUsers = $result->fetchAllAssociative();
if (empty($featuredUsers)) {
return [];
}
// Convert npubs to hex pubkeys for article lookup
$userPubkeys = [];
foreach ($featuredUsers as $user) {
$npub = $user['npub'];
if (NostrKeyUtil::isNpub($npub)) {
$hex = NostrKeyUtil::npubToHex($npub);
$userPubkeys[$user['id']] = $hex;
}
}
if (empty($userPubkeys)) {
return [];
}
// Get most recent article date for each featured writer
$pubkeyPlaceholders = implode(',', array_map(fn($i) => ':pk' . $i, array_keys(array_values($userPubkeys))));
$sql = "SELECT pubkey, MAX(COALESCE(published_at, created_at)) as latest_article
FROM article
WHERE pubkey IN ($pubkeyPlaceholders)
GROUP BY pubkey";
$params = [];
foreach (array_values($userPubkeys) as $i => $pk) {
$params['pk' . $i] = $pk;
}
$articleResult = $conn->executeQuery($sql, $params);
$latestArticles = [];
foreach ($articleResult->fetchAllAssociative() as $row) {
$latestArticles[$row['pubkey']] = $row['latest_article'];
}
// Map back to user IDs and sort by latest article
$userLatest = [];
foreach ($userPubkeys as $userId => $pubkey) {
$userLatest[$userId] = $latestArticles[$pubkey] ?? null;
}
// Sort by latest article descending (users with articles first, then by date)
uasort($userLatest, function ($a, $b) {
if ($a === null && $b === null) return 0;
if ($a === null) return 1;
if ($b === null) return -1;
return strcmp($b, $a); // Descending order
});
// Get top N user IDs
$topUserIds = array_slice(array_keys($userLatest), 0, $limit);
if (empty($topUserIds)) {
return [];
}
// Fetch users in the sorted order
$users = $this->createQueryBuilder('u')
->where('u.id IN (:ids)')
->setParameter('ids', $topUserIds)
->getQuery()
->getResult();
// Re-order users to match the sorted order
$usersById = [];
foreach ($users as $user) {
$usersById[$user->getId()] = $user;
}
$sortedUsers = [];
foreach ($topUserIds as $id) {
if (isset($usersById[$id])) {
$sortedUsers[] = $usersById[$id];
}
}
return $sortedUsers;
}
} }

63
src/Twig/Components/Atoms/FeaturedWriters.php

@ -0,0 +1,63 @@
<?php
namespace App\Twig\Components\Atoms;
use App\Repository\UserEntityRepository;
use App\Service\RedisCacheService;
use App\Util\NostrKeyUtil;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
#[AsTwigComponent]
final class FeaturedWriters
{
public array $writers = [];
public function __construct(
private readonly RedisCacheService $redisCacheService,
private readonly UserEntityRepository $userRepository
) {}
public function mount(): void
{
// Get top 3 featured writers ordered by most recent article
$featuredUsers = $this->userRepository->findTopFeaturedWriters(3);
if (empty($featuredUsers)) {
return;
}
// Convert npubs to hex pubkeys for metadata lookup
$hexPubkeys = [];
$npubToHex = [];
foreach ($featuredUsers as $user) {
$npub = $user->getNpub();
if (NostrKeyUtil::isNpub($npub)) {
$hex = NostrKeyUtil::npubToHex($npub);
$hexPubkeys[] = $hex;
$npubToHex[$npub] = $hex;
}
}
if (empty($hexPubkeys)) {
return;
}
// Batch fetch metadata for all featured writers
$metadataMap = $this->redisCacheService->getMultipleMetadata($hexPubkeys);
// Build writers array with npub and metadata
foreach ($featuredUsers as $user) {
$npub = $user->getNpub();
$hex = $npubToHex[$npub] ?? null;
if ($hex && isset($metadataMap[$hex])) {
$this->writers[] = [
'npub' => $npub,
'pubkey' => $hex,
'metadata' => $metadataMap[$hex],
'user' => $user,
];
}
}
}
}

118
templates/admin/roles.html.twig

@ -9,18 +9,130 @@
{{ message }} {{ message }}
</div> </div>
{% endfor %} {% endfor %}
{% for message in app.flashes('error') %}
<div class="alert alert-danger">
{{ message }}
</div>
{% endfor %}
{% for message in app.flashes('warning') %}
<div class="alert alert-warning">
{{ message }}
</div>
{% endfor %}
{% if app.user %} {% if app.user %}
<div> <div class="mb-4">
{{ app.user.userIdentifier }}<br> <h3>Your Roles</h3>
<p>{{ app.user.userIdentifier }}</p>
<ul>
{% for role in app.user.roles %} {% for role in app.user.roles %}
{{ role }}<br> <li>
{{ role }}
{% if role != 'ROLE_ADMIN' and role != 'ROLE_USER' %}
<form action="{{ path('admin_roles_remove') }}"
method="post"
style="display: inline;"
onsubmit="return confirm('Remove {{ role }} from yourself?');">
<input type="hidden" name="role" value="{{ role }}">
<button type="submit" class="btn btn-sm btn-outline-danger ms-2">Remove</button>
</form>
{% endif %}
{% if role is not same as('ROLE_ADMIN') and role is not same as('ROLE_USER') and not (role starts with 'ROLE_') %}
<span class="badge bg-warning text-dark ms-2">Invalid</span>
{% endif %}
</li>
{% endfor %} {% endfor %}
</ul>
</div> </div>
{% endif %} {% endif %}
{# Form for adding a new role #} {# Form for adding a new role #}
<div class="mb-5">
<h3>Add Role to Yourself</h3>
{{ form_start(form) }} {{ form_start(form) }}
{{ form_widget(form) }} {{ form_widget(form) }}
{{ form_end(form) }}
</div>
{# Featured Writers Management #}
<div class="featured-writers-admin">
<h2>Featured Writers</h2>
<p class="text-muted">Users with ROLE_FEATURED_WRITER appear in the sidebar across the site.</p>
<small class="text-muted">Enter an npub to add as featured writer. User will be created if they don't exist.</small>
{# Add new featured writer form #}
<form action="{{ path('admin_featured_writers_add') }}" method="post" class="mb-4">
<div>
<div>
<input type="text"
name="npub"
class="form-control"
placeholder="npub1..."
required
pattern="npub1.*"
title="Must be a valid npub starting with npub1">
</div>
<div>
<button type="submit" class="btn btn-primary">Add Featured Writer</button>
</div>
</div>
</form>
{# List of featured writers #}
{% if featuredWriters is defined and featuredWriters|length > 0 %}
<table class="table">
<thead>
<tr>
<th>Avatar</th>
<th>Name</th>
<th>Npub</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for writer in featuredWriters %}
<tr>
<td>
{% if writer.metadata.picture is defined and writer.metadata.picture %}
<img src="{{ writer.metadata.picture }}"
alt="Avatar"
style="width: 40px; height: 40px; border-radius: 50%; object-fit: cover;">
{% else %}
<span style="width: 40px; height: 40px; border-radius: 50%; background: #e0e0e0; display: inline-flex; align-items: center; justify-content: center;">
<twig:ux:icon name="iconoir:user" style="width: 20px; height: 20px;" />
</span>
{% endif %}
</td>
<td>
{% if writer.metadata %}
<a href="{{ path('author-profile', { npub: writer.npub }) }}">
<twig:Atoms:NameOrNpub :author="writer.metadata" :npub="writer.npub" />
</a>
{% else %}
{{ writer.npub|shortenNpub }}
{% endif %}
</td>
<td>
<code style="font-size: 0.8rem;">{{ writer.npub|shortenNpub }}</code>
</td>
<td>
<form action="{{ path('admin_featured_writers_remove', { id: writer.user.id }) }}"
method="post"
style="display: inline;"
onsubmit="return confirm('Remove this user from featured writers?');">
<button type="submit" class="btn btn-sm btn-outline-danger">Remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="alert alert-info">
No featured writers yet. Add one using the form above or with the command:<br>
<code>php bin/console user:elevate npub1... ROLE_FEATURED_WRITER</code>
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}

47
templates/components/Atoms/FeaturedWriters.html.twig

@ -0,0 +1,47 @@
{% if writers is not empty %}
<div class="featured-writers">
<div class="d-flex gap-3 center mt-3 mb-3 ln-section--reader">
<h2 class="mb-4">
<a href="{{ path('forum') }}">Writers</a>
</h2>
</div>
<ul class="featured-writers-list list-unstyled">
{% for writer in writers %}
<li class="featured-writer-item">
<a href="{{ path('author-profile', { npub: writer.npub }) }}" class="featured-writer-link">
{% if writer.metadata.picture is defined and writer.metadata.picture %}
<img src="{{ writer.metadata.picture }}"
alt="{{ writer.metadata.display_name ?? writer.metadata.name ?? 'Writer' }}"
class="featured-writer-avatar"
loading="lazy"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
/>
<span class="featured-writer-avatar-placeholder" style="display: none;">
<twig:ux:icon name="iconoir:user" class="icon" />
</span>
{% else %}
<span class="featured-writer-avatar-placeholder">
<twig:ux:icon name="iconoir:user" class="icon" />
</span>
{% endif %}
<span class="featured-writer-info">
<span class="featured-writer-name">
<twig:Atoms:NameOrNpub :author="writer.metadata" :npub="writer.npub" />
</span>
{% if writer.metadata.nip05 is defined and writer.metadata.nip05 %}
<span class="featured-writer-nip05">
{% if writer.metadata.nip05 is iterable %}
{{ writer.metadata.nip05|first }}
{% else %}
{{ writer.metadata.nip05 }}
{% endif %}
</span>
{% endif %}
</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

1
templates/pages/discover.html.twig

@ -53,5 +53,6 @@
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
<twig:Atoms:FeaturedWriters />
<twig:Atoms:ForumAside /> <twig:Atoms:ForumAside />
{% endblock %} {% endblock %}

1
templates/pages/newsstand.html.twig

@ -20,6 +20,7 @@
{% endblock %} {% endblock %}
{% block aside %} {% block aside %}
<twig:Atoms:FeaturedWriters />
{# <h6>Lists</h6>#} {# <h6>Lists</h6>#}
{# <twig:Organisms:ReadingListList />#} {# <twig:Organisms:ReadingListList />#}
{% endblock %} {% endblock %}

Loading…
Cancel
Save