diff --git a/assets/router/index.js b/assets/router/index.js index 526b664..231642c 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -1,12 +1,16 @@ import { createRouter, createWebHistory } from 'vue-router'; import DashboardView from '../vue/views/DashboardView.vue' import SubscribersView from '../vue/views/SubscribersView.vue' +import ListsView from '../vue/views/ListsView.vue' +import ListSubscribersView from '../vue/views/ListSubscribersView.vue' export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', name: 'dashboard', component: DashboardView, meta: { title: 'Dashboard' } }, { path: '/subscribers', name: 'subscribers', component: SubscribersView, meta: { title: 'Subscribers' } }, + { path: '/lists', name: 'lists', component: ListsView, meta: { title: 'Lists' } }, + { path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } }, { path: '/:pathMatch(.*)*', redirect: '/' }, ], }); diff --git a/assets/vue/api.js b/assets/vue/api.js index b61c721..95c3cbc 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -1,4 +1,4 @@ -import { Client, SubscribersClient } from '@tatevikgr/rest-api-client'; +import {Client, ListClient, SubscribersClient, SubscriptionClient, SubscriberAttributesClient} from '@tatevikgr/rest-api-client'; const appElement = document.getElementById('vue-app'); const apiToken = appElement?.dataset.apiToken; @@ -15,4 +15,8 @@ if (apiToken) { } export const subscribersClient = new SubscribersClient(client); +export const listClient = new ListClient(client); +export const subscriptionClient = new SubscriptionClient(client); +export const subscriberAttributesClient = new SubscriberAttributesClient(client); + export default client; diff --git a/assets/vue/components/lists/AddSubscribersModal.vue b/assets/vue/components/lists/AddSubscribersModal.vue new file mode 100644 index 0000000..b54f3f8 --- /dev/null +++ b/assets/vue/components/lists/AddSubscribersModal.vue @@ -0,0 +1,165 @@ + + + diff --git a/assets/vue/components/lists/CreateListModal.vue b/assets/vue/components/lists/CreateListModal.vue new file mode 100644 index 0000000..dcd58ca --- /dev/null +++ b/assets/vue/components/lists/CreateListModal.vue @@ -0,0 +1,177 @@ + + + diff --git a/assets/vue/components/lists/EditListModal.vue b/assets/vue/components/lists/EditListModal.vue new file mode 100644 index 0000000..41d5981 --- /dev/null +++ b/assets/vue/components/lists/EditListModal.vue @@ -0,0 +1,230 @@ + + + diff --git a/assets/vue/components/lists/ListDirectory.vue b/assets/vue/components/lists/ListDirectory.vue new file mode 100644 index 0000000..5c94021 --- /dev/null +++ b/assets/vue/components/lists/ListDirectory.vue @@ -0,0 +1,377 @@ + + + diff --git a/assets/vue/components/lists/ListSubscribersExportPanel.vue b/assets/vue/components/lists/ListSubscribersExportPanel.vue new file mode 100644 index 0000000..bcc03ee --- /dev/null +++ b/assets/vue/components/lists/ListSubscribersExportPanel.vue @@ -0,0 +1,284 @@ + + + diff --git a/assets/vue/components/subscribers/SubscriberDirectory.vue b/assets/vue/components/subscribers/SubscriberDirectory.vue index d7bce38..5b6539f 100644 --- a/assets/vue/components/subscribers/SubscriberDirectory.vue +++ b/assets/vue/components/subscribers/SubscriberDirectory.vue @@ -83,6 +83,8 @@ + + diff --git a/assets/vue/components/subscribers/SubscribersTable.vue b/assets/vue/components/subscribers/SubscribersTable.vue deleted file mode 100644 index 790d854..0000000 --- a/assets/vue/components/subscribers/SubscribersTable.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - diff --git a/assets/vue/views/ListSubscribersView.vue b/assets/vue/views/ListSubscribersView.vue new file mode 100644 index 0000000..f51b621 --- /dev/null +++ b/assets/vue/views/ListSubscribersView.vue @@ -0,0 +1,562 @@ + + + diff --git a/assets/vue/views/ListsView.vue b/assets/vue/views/ListsView.vue new file mode 100644 index 0000000..dd66bc4 --- /dev/null +++ b/assets/vue/views/ListsView.vue @@ -0,0 +1,12 @@ + + + diff --git a/package.json b/package.json index 7b5b04a..d609a35 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "webpack-notifier": "^1.15.0" }, "dependencies": { - "@tatevikgr/rest-api-client": "^1.0.2", + "@tatevikgr/rest-api-client": "^1.3.1", "apexcharts": "^5.10.4", "vue": "^3.5.16", "vue-router": "4", diff --git a/src/Controller/ListsController.php b/src/Controller/ListsController.php new file mode 100644 index 0000000..f52d49d --- /dev/null +++ b/src/Controller/ListsController.php @@ -0,0 +1,47 @@ +headers->get('Accept', ''); + $wantsJson = $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); + if (! $wantsJson) { + return $this->render('spa.html.twig', [ + 'page' => 'Lists', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } + $initialData = $this->listClient->getLists(); + + return $this->json($initialData); + } + + #[Route('/{listId}/subscribers', name: 'list_subscribers', methods: ['GET'])] + public function view(Request $request, int $listId): JsonResponse|Response + { + return $this->render('spa.html.twig', [ + 'page' => 'List Subscribers', + 'api_token' => $request->getSession()->get('auth_token'), + 'api_base_url' => $this->getParameter('api_base_url'), + ]); + } +} diff --git a/src/Controller/SubscribersController.php b/src/Controller/SubscribersController.php index 9eaccb8..171e97c 100644 --- a/src/Controller/SubscribersController.php +++ b/src/Controller/SubscribersController.php @@ -4,21 +4,25 @@ namespace PhpList\WebFrontend\Controller; -use DateTimeImmutable; use PhpList\RestApiClient\Endpoint\SubscribersClient; -use PhpList\RestApiClient\Entity\Subscriber; use PhpList\RestApiClient\Request\Subscriber\SubscribersFilterRequest; +use PhpList\WebFrontend\Service\SubscriberCollectionNormalizer; +use PhpList\WebFrontend\Service\SubscriberExportRequestFactory; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; #[Route('/subscribers', name: 'subscriber_')] class SubscribersController extends AbstractController { - public function __construct(private readonly SubscribersClient $subscribersClient) - { + public function __construct( + private readonly SubscribersClient $subscribersClient, + private readonly SubscriberCollectionNormalizer $subscriberCollectionNormalizer, + private readonly SubscriberExportRequestFactory $subscriberExportRequestFactory + ) { } /** @@ -28,7 +32,9 @@ public function __construct(private readonly SubscribersClient $subscribersClien #[Route('/', name: 'list', methods: ['GET'])] public function index(Request $request): JsonResponse|Response { - if (! $request->isXmlHttpRequest() && $request->headers->get('Accept') !== 'application/json') { + $accept = (string) $request->headers->get('Accept', ''); + $wantsJson = $request->isXmlHttpRequest() || str_contains($accept, 'application/json'); + if (! $wantsJson) { return $this->render('spa.html.twig', [ 'page' => 'Subscribers', 'api_token' => $request->getSession()->get('auth_token'), @@ -36,8 +42,7 @@ public function index(Request $request): JsonResponse|Response ]); } - $afterId = (int) $request->query->get('after_id'); - $limit = max(1, (int) $request->query->get('limit', 10)); + $afterId = $request->query->has('after_id') ? $request->query->getInt('after_id') : null; $filter = new SubscribersFilterRequest( isConfirmed: $request->query->has('confirmed') ? true : @@ -48,7 +53,7 @@ public function index(Request $request): JsonResponse|Response findValue: $request->query->get('findValue'), ); - $collection = $this->subscribersClient->getSubscribers($filter, $afterId, $limit); + $collection = $this->subscribersClient->getSubscribers($filter, $afterId, 10); $history = $request->getSession()->get('subscribers_history', []); if (!in_array($afterId, $history, true)) { @@ -64,91 +69,53 @@ public function index(Request $request): JsonResponse|Response $prevId = $history[$index - 1]; } - $initialData = [ - 'items' => array_map(static function (Subscriber $subscriber) { - return [ - 'id' => $subscriber->id, - 'email' => $subscriber->email, - 'confirmed' => $subscriber->confirmed, - 'blacklisted' => $subscriber->blacklisted, - 'createdAt' => (new DateTimeImmutable($subscriber->createdAt))->format('Y-m-d H:i:s'), - 'uniqueId' => $subscriber->uniqueId, - 'listCount' => count($subscriber->subscribedLists), - ]; - }, $collection->items ?? []), - 'pagination' => [ - 'limit' => $collection->pagination->limit, - 'afterId' => $collection->pagination->nextCursor, - 'hasMore' => $collection->pagination->hasMore , - 'total' => $collection->pagination->total, - 'prevId' => $prevId, - 'isFirstPage' => $afterId === 0, - ], - ]; - - return $this->json($initialData); + return $this->json($this->subscriberCollectionNormalizer->normalize($collection, $prevId, $afterId)); } + /** + * @SuppressWarnings("CyclomaticComplexity") + */ #[Route('/export', name: 'export', methods: ['GET'])] public function export(Request $request): Response { - $filter = new SubscribersFilterRequest( - isConfirmed: $request->query->has('confirmed') ? true : - ($request->query->has('unconfirmed') ? false : null), - isBlacklisted: $request->query->has('blacklisted') ? true : - ($request->query->has('non-blacklisted') ? false : null), - findColumn: $request->query->get('findColumn'), - findValue: $request->query->get('findValue'), - ); + $exportRequest = $this->subscriberExportRequestFactory->fromQuery($request->query); - $collection = $this->subscribersClient->getSubscribers($filter, 0, $request->query->getInt('limit')); - $exportData = $collection->items; - if (empty($exportData)) { - return new Response('No subscribers to export.', Response::HTTP_NOT_FOUND); - } - $handle = fopen('php://temp', 'r+'); - - $headers = [ - 'id', - 'email', - 'createdAt', - 'confirmed', - 'blacklisted', - 'bounceCount', - 'uniqueId', - 'htmlEmail', - 'disabled', - 'lists', - ]; - fputcsv($handle, $headers); - - foreach ($exportData as $data) { - $row = [ - 'id' => $data->id, - 'email' => $data->email, - 'createdAt' => (new DateTimeImmutable($data->createdAt))->format('Y-m-d H:i:s'), - 'confirmed' => $data->confirmed, - 'blacklisted' => $data->blacklisted, - 'bounceCount' => $data->bounceCount, - 'uniqueId' => $data->uniqueId, - 'htmlEmail' => $data->htmlEmail, - 'disabled' => $data->disabled, - 'lists' => implode('|', array_map(fn($list) => $list['name'], $data->subscribedLists)), - ]; - - fputcsv($handle, $row); + $upstreamResponse = $this->subscribersClient->exportSubscribers($exportRequest); + $statusCode = $upstreamResponse->getStatusCode(); + $isSuccessfulExport = $statusCode >= 200 && $statusCode < 300; + + $contentType = $upstreamResponse->getHeaderLine('Content-Type'); + if ($isSuccessfulExport && $contentType === '') { + $contentType = 'text/csv; charset=UTF-8'; } - rewind($handle); - $csvContent = stream_get_contents($handle); - fclose($handle); + $contentDisposition = $upstreamResponse->getHeaderLine('Content-Disposition'); + if ($isSuccessfulExport && $contentDisposition === '') { + $contentDisposition = sprintf( + 'attachment; filename="subscribers_export_%s.csv"', + date('Y-m-d_H-i-s') + ); + } - $response = new Response($csvContent); - $response->headers->set('Content-Type', 'text/csv'); - $response->headers->set( - 'Content-Disposition', - 'attachment; filename="subscribers_export_' . date('Y-m-d_H-i-s') . '.csv"' + $body = $upstreamResponse->getBody(); + $response = new StreamedResponse( + static function () use ($body): void { + if ($body->isSeekable()) { + $body->rewind(); + } + while (! $body->eof()) { + echo $body->read(8192); + } + }, + $statusCode ); + if ($contentType !== '') { + $response->headers->set('Content-Type', $contentType); + } + + if ($contentDisposition !== '') { + $response->headers->set('Content-Disposition', $contentDisposition); + } return $response; } diff --git a/src/Service/SubscriberCollectionNormalizer.php b/src/Service/SubscriberCollectionNormalizer.php new file mode 100644 index 0000000..9be312e --- /dev/null +++ b/src/Service/SubscriberCollectionNormalizer.php @@ -0,0 +1,37 @@ + array_map(static function (Subscriber $subscriber) { + return [ + 'id' => $subscriber->id, + 'email' => $subscriber->email, + 'confirmed' => $subscriber->confirmed, + 'blacklisted' => $subscriber->blacklisted, + 'createdAt' => (new DateTimeImmutable($subscriber->createdAt))->format('Y-m-d H:i:s'), + 'uniqueId' => $subscriber->uniqueId, + 'listCount' => count($subscriber->subscribedLists), + ]; + }, $collection->items ?? []), + 'pagination' => [ + 'limit' => $collection->pagination->limit, + 'afterId' => $collection->pagination->nextCursor, + 'hasMore' => $collection->pagination->hasMore, + 'total' => $collection->pagination->total, + 'prevId' => $prevId, + 'isFirstPage' => $afterId === null, + ], + ]; + } +} diff --git a/src/Service/SubscriberExportRequestFactory.php b/src/Service/SubscriberExportRequestFactory.php new file mode 100644 index 0000000..3a32509 --- /dev/null +++ b/src/Service/SubscriberExportRequestFactory.php @@ -0,0 +1,26 @@ +get('date_type', 'any'), + listId: $query->has('list_id') ? $query->getInt('list_id') : null, + dateFrom: $query->get('date_from') ?: null, + dateTo: $query->get('date_to') ?: null, + columns: array_values(array_filter($query->all('columns'))), + isConfirmed: $query->has('confirmed') ? true : + ($query->has('unconfirmed') ? false : null), + isBlacklisted: $query->has('blacklisted') ? true : + ($query->has('non-blacklisted') ? false : null), + ); + } +} diff --git a/yarn.lock b/yarn.lock index c3bd267..8f2be7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1089,10 +1089,10 @@ postcss "^8.5.6" tailwindcss "4.2.1" -"@tatevikgr/rest-api-client@^1.0.2": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-1.0.2.tgz#d78c28a35a037fb3bfdf3ebe6d3e46163498be89" - integrity sha512-b/pu0LEt/p3TTDmsDIAWU95rb60weF7wGK1iuYOd+BNPCS9sineADQQ90z83C+vviU+DoEGSUqa3Uhr/jJwJLw== +"@tatevikgr/rest-api-client@^1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@tatevikgr/rest-api-client/-/rest-api-client-1.3.1.tgz#787416e20efb6ea404f57ad38fcc95d0264b08ef" + integrity sha512-qU4muzitBPT1fUkfuzKIa36D1v4PSNeBtVb3gPJ9QKgxuDz4Qp3JeVFfUDxIUUHF0qlBIPf2bD12TcI4rkdjGw== dependencies: axios "^1.6.0"