diff --git a/assets/styles/04-pages/discover.css b/assets/styles/04-pages/discover.css index 1978692..4b41ff3 100644 --- a/assets/styles/04-pages/discover.css +++ b/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 */ @media (max-width: 768px) { .discover-section .section-header { diff --git a/src/Controller/Administration/RoleController.php b/src/Controller/Administration/RoleController.php index d6d3840..469c163 100644 --- a/src/Controller/Administration/RoleController.php +++ b/src/Controller/Administration/RoleController.php @@ -4,8 +4,11 @@ declare(strict_types=1); namespace App\Controller\Administration; +use App\Entity\User; use App\Form\RoleType; use App\Repository\UserEntityRepository; +use App\Service\RedisCacheService; +use App\Util\NostrKeyUtil; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -16,12 +19,17 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt class RoleController extends AbstractController { #[Route('/admin/role', name: 'admin_roles')] - public function index(): Response + public function index(UserEntityRepository $userRepository, RedisCacheService $redisCacheService): Response { $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', [ 'form' => $form->createView(), + 'featuredWriters' => $featuredWritersData, ]); } @@ -29,7 +37,7 @@ class RoleController extends AbstractController * Add a role to current user as submitted in a form */ #[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 $npub = $this->getUser()->getUserIdentifier(); @@ -38,12 +46,21 @@ class RoleController extends AbstractController $form->handleRequest($request); if (!$form->isSubmitted() || !$form->isValid()) { + $featuredWriters = $userRepository->findFeaturedWriters(); + $featuredWritersData = $this->enrichUsersWithMetadata($featuredWriters, $redisCacheService); return $this->render('admin/roles.html.twig', [ 'form' => $form->createView(), + 'featuredWriters' => $featuredWritersData, ]); } $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->addRole($role); $em->persist($user); @@ -60,8 +77,145 @@ class RoleController extends AbstractController // add a flash message $this->addFlash('success', 'Role added to user'); + $featuredWriters = $userRepository->findFeaturedWriters(); + $featuredWritersData = $this->enrichUsersWithMetadata($featuredWriters, $redisCacheService); + return $this->render('admin/roles.html.twig', [ '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; + } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 5b4c6ce..d208fa4 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -2,6 +2,7 @@ namespace App\Entity; +use App\Enum\RolesEnum; use App\Repository\UserEntityRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -53,6 +54,17 @@ class User implements UserInterface, EquatableInterface 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 { return $this->id; diff --git a/src/Enum/RolesEnum.php b/src/Enum/RolesEnum.php index 78f9176..20fd995 100644 --- a/src/Enum/RolesEnum.php +++ b/src/Enum/RolesEnum.php @@ -7,4 +7,5 @@ enum RolesEnum: string case USER = 'ROLE_USER'; case ADMIN = 'ROLE_ADMIN'; case EDITOR = 'ROLE_EDITOR'; + case FEATURED_WRITER = 'ROLE_FEATURED_WRITER'; } diff --git a/src/Repository/UserEntityRepository.php b/src/Repository/UserEntityRepository.php index 0cdac14..45a01f0 100644 --- a/src/Repository/UserEntityRepository.php +++ b/src/Repository/UserEntityRepository.php @@ -3,7 +3,9 @@ namespace App\Repository; use App\Entity\User; +use App\Util\NostrKeyUtil; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\DBAL\Exception; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; @@ -13,6 +15,7 @@ class UserEntityRepository extends ServiceEntityRepository { parent::__construct($registry, User::class); } + public function findOrCreateByUniqueField(User $user): User { $entity = $this->findOneBy(['npub' => $user->getNpub()]); @@ -25,4 +28,122 @@ class UserEntityRepository extends ServiceEntityRepository 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; + } } diff --git a/src/Twig/Components/Atoms/FeaturedWriters.php b/src/Twig/Components/Atoms/FeaturedWriters.php new file mode 100644 index 0000000..619a976 --- /dev/null +++ b/src/Twig/Components/Atoms/FeaturedWriters.php @@ -0,0 +1,63 @@ +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, + ]; + } + } + } +} + diff --git a/templates/admin/roles.html.twig b/templates/admin/roles.html.twig index fd67b02..be17758 100644 --- a/templates/admin/roles.html.twig +++ b/templates/admin/roles.html.twig @@ -9,18 +9,130 @@ {{ message }} {% endfor %} + {% for message in app.flashes('error') %} +
+ {{ message }} +
+ {% endfor %} + {% for message in app.flashes('warning') %} +
+ {{ message }} +
+ {% endfor %} {% if app.user %} -
- {{ app.user.userIdentifier }}
- {% for role in app.user.roles %} - {{ role }}
- {% endfor %} +
+

Your Roles

+

{{ app.user.userIdentifier }}

+
{% endif %} {# Form for adding a new role #} - {{ form_start(form) }} - {{ form_widget(form) }} +
+

Add Role to Yourself

+ {{ form_start(form) }} + {{ form_widget(form) }} + {{ form_end(form) }} +
+ + {# Featured Writers Management #} + {% endblock %} diff --git a/templates/components/Atoms/FeaturedWriters.html.twig b/templates/components/Atoms/FeaturedWriters.html.twig new file mode 100644 index 0000000..4356e16 --- /dev/null +++ b/templates/components/Atoms/FeaturedWriters.html.twig @@ -0,0 +1,47 @@ +{% if writers is not empty %} + +{% endif %} + diff --git a/templates/pages/discover.html.twig b/templates/pages/discover.html.twig index a867b78..bc3f351 100644 --- a/templates/pages/discover.html.twig +++ b/templates/pages/discover.html.twig @@ -53,5 +53,6 @@ {% endblock %} {% block aside %} + {% endblock %} diff --git a/templates/pages/newsstand.html.twig b/templates/pages/newsstand.html.twig index 7de38bc..37ebf48 100644 --- a/templates/pages/newsstand.html.twig +++ b/templates/pages/newsstand.html.twig @@ -20,6 +20,7 @@ {% endblock %} {% block aside %} + {#
Lists
#} {# #} {% endblock %}