Skip to content
Open

Dev #92

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
0e57640
Refactor: env (#91)
TatevikGr Jul 29, 2026
88c00fc
use dev branch for core
tatevikg1 Jul 29, 2026
cbf56ab
feat: enhance template error handling with field-specific validation …
tatevikg1 Jul 29, 2026
20b89db
Update openapi.json from web frontend workflow 2026-08-04T12:38:04Z
Aug 4, 2026
34972eb
feat: add cursor pointer to buttons for better user interaction
tatevikg1 Aug 4, 2026
c0437c6
Merge branch 'dev' of github.com:phpList/web-frontend into dev
tatevikg1 Aug 4, 2026
f2dd0dc
chore: update @symfony/webpack-encore and other dependencies in packa…
tatevikg1 Aug 4, 2026
48707a2
feat: add cursor pointer style to interactive buttons globally
tatevikg1 Aug 5, 2026
dab6039
feat: add dark mode
tatevikg1 Aug 5, 2026
b62c256
feat: add JSON manifest path and enable versioning in webpack configu…
tatevikg1 Aug 9, 2026
08380af
feat: set custom session name in framework configuration
tatevikg1 Aug 16, 2026
1b1b7b4
Update openapi.json from web frontend workflow 2026-08-17T08:24:03Z
Aug 17, 2026
0507986
feat: update dashboard statistics handling to use new API responses
tatevikg1 Aug 17, 2026
bb2b810
fix: response structure in dashboard controller tests
tatevikg1 Aug 19, 2026
7e4053b
Update openapi.json from web frontend workflow 2026-08-20T11:04:47Z
Aug 20, 2026
cae1e63
feat: implement pagination and filtering for campaigns in CampaignDir…
tatevikg1 Aug 20, 2026
db3a633
feat: add exception logging configuration for NotFoundHttpException i…
tatevikg1 Aug 25, 2026
d6f365a
feat: refactor dashboard to load statistics from API and pass summary…
tatevikg1 Aug 25, 2026
f633d01
feat: refactor dashboard data handling into a composable for improved…
tatevikg1 Aug 25, 2026
e185992
feat: return JSON response for expired session in AJAX requests
tatevikg1 Aug 25, 2026
2bfca6f
feat: refactor analytics data loading into a composable for improved …
tatevikg1 Aug 25, 2026
3e41f56
Update openapi.json from web frontend workflow 2026-09-09T15:21:57Z
Sep 9, 2026
6af8a03
feat: add Stuck Campaigns view and list components for managing (#104)
TatevikGr Sep 18, 2026
4d4f08c
feat: refactor dashboard to improve loading state handling and remove…
tatevikg1 Sep 21, 2026
153cd4a
feat: enhance dashboard components with independent loading and error…
tatevikg1 Sep 21, 2026
e2eab5f
feat: implement loading indicators for analytics and subscriber direc…
tatevikg1 Sep 21, 2026
6127634
Revert "feat: implement loading indicators for analytics and subscrib…
tatevikg1 Sep 21, 2026
e48ce4c
feat: increase API client timeout to 90 seconds
tatevikg1 Sep 22, 2026
026d10c
ref: comment out admin data fetching logic in AdminLayout
tatevikg1 Sep 22, 2026
9e1fb40
Update openapi.json from web frontend workflow 2026-09-23T18:30:41Z
Sep 23, 2026
ed9de5c
Update openapi.json from web frontend workflow 2026-09-24T12:52:13Z
Sep 24, 2026
0d0f700
Update openapi.json from web frontend workflow 2026-09-28T10:13:24Z
Sep 28, 2026
1559c00
Refactor vue components (#105)
TatevikGr Sep 29, 2026
800a3e9
feat: add build scripts for dist output
tatevikg1 Sep 29, 2026
8c3b134
Update openapi.json from web frontend workflow 2026-10-01T07:09:22Z
Oct 1, 2026
b12f6fe
feat: update rest-api-client to version 2.2.4 and adjust analytics da…
tatevikg1 Oct 1, 2026
7b3479d
feat: update domain confirmation display in AnalyticsView and upgrade…
tatevikg1 Oct 1, 2026
5464317
Merge branch 'dev' of github.com:phpList/web-frontend into dev
tatevikg1 Oct 1, 2026
d89804a
fix test
tatevikg1 Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@
/node_modules
/drivers/
.codex
.claude/
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ which also has more detailed installation instructions in the README.
php bin/console assets:install public --symlink --relative
```

### Frontend (Vue/CKEditor) assets

The compiled JS/CSS for this module's Vue components live in the git-tracked
[`dist/`](dist) directory, built with `yarn build:dist` (Symfony Webpack
Encore, output path `dist/`, public path `/build`). Consumers do not need
Node.js/Yarn or this package's JS dependencies (Vue, CKEditor, etc.) — just
copy `dist/` into the host application's `public/build/` directory as part of
the host's Composer install/update step.

`dist/` must be rebuilt (`yarn build:dist`) and committed whenever
`assets/` changes, before tagging a release.

This module serves its frontend files from `/`.


Expand Down
58 changes: 29 additions & 29 deletions assets/editor/EditorAssetPicker.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,100 +8,100 @@
aria-labelledby="editor-asset-picker-title"
@keydown.esc="$emit('close')"
>
<div class="w-full max-w-5xl overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl">
<div class="flex items-center justify-between gap-3 border-b border-slate-200 px-4 py-3">
<div class="w-full max-w-5xl overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-xl">
<div class="flex items-center justify-between gap-3 border-b border-slate-200 dark:border-slate-700 px-4 py-3">
<div class="min-w-0">
<h3 id="editor-asset-picker-title" class="text-sm font-semibold text-slate-900">
<h3 id="editor-asset-picker-title" class="text-sm font-semibold text-slate-900 dark:text-slate-100">
Choose an existing asset
</h3>
<p class="text-xs text-slate-500">Pick a file to insert it at the cursor position.</p>
<p class="text-xs text-slate-500 dark:text-slate-400">Pick a file to insert it at the cursor position.</p>
</div>

<button
type="button"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
class="rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700"
@click="$emit('close')"
>
Close
</button>
</div>

<div class="flex flex-col gap-3 border-b border-slate-200 px-4 py-3 sm:flex-row sm:items-center">
<div class="flex flex-col gap-3 border-b border-slate-200 dark:border-slate-700 px-4 py-3 sm:flex-row sm:items-center">
<input
ref="searchInput"
:value="query"
type="search"
class="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 outline-none focus:border-ext-wf1 focus:ring-2 focus:ring-ext-wf2"
class="w-full rounded-lg border border-slate-300 dark:border-slate-600 px-3 py-2 text-sm text-slate-900 dark:text-slate-100 dark:bg-slate-800 outline-none focus:border-ext-wf1 focus:ring-2 focus:ring-ext-wf2"
placeholder="Search by file name"
@input="$emit('update:query', $event.target.value)"
>

<button
type="button"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
class="rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-2 text-sm font-medium text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700"
@click="$emit('refresh')"
>
Refresh
</button>
</div>

<div class="max-h-[60vh] overflow-auto">
<div v-if="loading" class="px-4 py-8 text-sm text-slate-500">
<div v-if="loading" class="px-4 py-8 text-sm text-slate-500 dark:text-slate-400">
Loading assets...
</div>

<div v-else-if="error" class="px-4 py-8 text-sm text-red-600">
<div v-else-if="error" class="px-4 py-8 text-sm text-red-600 dark:text-red-400">
{{ error }}
</div>

<table v-else class="w-full table-fixed border-separate border-spacing-0">
<thead class="sticky top-0 bg-slate-50">
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
<th class="w-24 border-b border-slate-200 px-4 py-3">Preview</th>
<th class="border-b border-slate-200 px-4 py-3">Name</th>
<th class="w-32 border-b border-slate-200 px-4 py-3">Type</th>
<th class="w-28 border-b border-slate-200 px-4 py-3">Size</th>
<th class="w-40 border-b border-slate-200 px-4 py-3">Updated</th>
<th class="w-28 border-b border-slate-200 px-4 py-3"></th>
<thead class="sticky top-0 bg-slate-50 dark:bg-slate-800">
<tr class="text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
<th class="w-24 border-b border-slate-200 dark:border-slate-700 px-4 py-3">Preview</th>
<th class="border-b border-slate-200 dark:border-slate-700 px-4 py-3">Name</th>
<th class="w-32 border-b border-slate-200 dark:border-slate-700 px-4 py-3">Type</th>
<th class="w-28 border-b border-slate-200 dark:border-slate-700 px-4 py-3">Size</th>
<th class="w-40 border-b border-slate-200 dark:border-slate-700 px-4 py-3">Updated</th>
<th class="w-28 border-b border-slate-200 dark:border-slate-700 px-4 py-3"></th>
</tr>
</thead>
<tbody>
<tr v-if="!filteredItems.length">
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500">
<td colspan="6" class="px-4 py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No assets found.
</td>
</tr>

<tr
v-for="item in filteredItems"
:key="item.url"
class="border-b border-slate-100 text-sm text-slate-700 hover:bg-slate-50"
class="border-b border-slate-100 dark:border-slate-700 text-sm text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700"
>
<td class="px-4 py-3 align-top">
<img
v-if="item.isImage"
:src="item.url"
:alt="item.fileName.slice(0, 5) + '...'"
class="h-14 w-14 rounded border border-slate-200 object-cover"
class="h-14 w-14 rounded border border-slate-200 dark:border-slate-700 object-cover"
>
<div
v-else
class="flex h-14 w-14 items-center justify-center rounded border border-slate-200 bg-slate-50 text-xs font-semibold uppercase text-slate-500"
class="flex h-14 w-14 items-center justify-center rounded border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 text-xs font-semibold uppercase text-slate-500 dark:text-slate-400"
>
{{ extensionLabel(item.fileName) }}
</div>
</td>
<td class="px-4 py-3 align-top">
<p class="break-all font-medium text-slate-900">{{ item.fileName }}</p>
<p class="break-all text-xs text-slate-500">{{ item.url }}</p>
<p class="break-all font-medium text-slate-900 dark:text-slate-100">{{ item.fileName }}</p>
<p class="break-all text-xs text-slate-500 dark:text-slate-400">{{ item.url }}</p>
</td>
<td class="px-4 py-3 align-top text-xs uppercase text-slate-500">
<td class="px-4 py-3 align-top text-xs uppercase text-slate-500 dark:text-slate-400">
{{ item.mimeType }}
</td>
<td class="px-4 py-3 align-top text-sm text-slate-600">
<td class="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">
{{ formatBytes(item.size) }}
</td>
<td class="px-4 py-3 align-top text-sm text-slate-600">
<td class="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">
{{ formatDate(item.modifiedAt) }}
</td>
<td class="px-4 py-3 align-top text-right">
Expand All @@ -118,9 +118,9 @@
</table>
</div>

<div class="flex items-center justify-between gap-3 border-t border-slate-200 px-4 py-3 text-sm text-slate-600">
<div class="flex items-center justify-between gap-3 border-t border-slate-200 dark:border-slate-700 px-4 py-3 text-sm text-slate-600 dark:text-slate-300">
<span>{{ filteredItems.length }} asset{{ filteredItems.length === 1 ? '' : 's' }}</span>
<span class="text-xs text-slate-500">Choose a row to insert it immediately.</span>
<span class="text-xs text-slate-500 dark:text-slate-400">Choose a row to insert it immediately.</span>
</div>
</div>
</div>
Expand Down
46 changes: 17 additions & 29 deletions assets/router/index.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,25 @@
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'
import CampaignsView from '../vue/views/CampaignsView.vue'
import CampaignEditView from '../vue/views/CampaignEditView.vue'
import TemplatesView from '../vue/views/TemplatesView.vue'
import TemplateEditView from '../vue/views/TemplateEditView.vue'
import BouncesView from '../vue/views/BouncesView.vue'
import AnalyticsView from '../vue/views/AnalyticsView.vue'
import PublicPagesView from '../vue/views/PublicPagesView.vue'
import PublicPageEditView from '../vue/views/PublicPageEditView.vue'
import SettingsView from '../vue/views/SettingsView.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: '/campaigns', name: 'campaigns', component: CampaignsView, meta: { title: 'Campaigns' } },
{ path: '/templates', name: 'templates', component: TemplatesView, meta: { title: 'Templates' } },
{ path: '/templates/create', name: 'template-create', component: TemplateEditView, meta: { title: 'Create Template' } },
{ path: '/templates/:templateId/edit', name: 'template-edit', component: TemplateEditView, meta: { title: 'Edit Template' } },
{ path: '/campaigns/create', name: 'campaign-create', component: CampaignEditView, meta: { title: 'Create Campaign' } },
{ path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: CampaignEditView, meta: { title: 'Edit Campaign' } },
{ path: '/lists/:listId/subscribers', name: 'list-subscribers', component: ListSubscribersView, meta: { title: 'List Subscribers' } },
{ path: '/bounces', name: 'bounces', component: BouncesView, meta: { title: 'Bounces' } },
{ path: '/analytics', name: 'analytics', component: AnalyticsView, meta: { title: 'Analytics' } },
{ path: '/public', name: 'public-pages', component: PublicPagesView, meta: { title: 'Public Pages' } },
{ path: '/public/create', name: 'public-page-create', component: PublicPageEditView, meta: { title: 'Create Public Page' } },
{ path: '/public/:pageId/edit', name: 'public-page-edit', component: PublicPageEditView, meta: { title: 'Edit Public Page' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
{ path: '/', name: 'dashboard', component: () => import('../vue/views/DashboardView.vue'), meta: { title: 'Dashboard' } },
{ path: '/subscribers', name: 'subscribers', component: () => import('../vue/views/SubscribersView.vue'), meta: { title: 'Subscribers' } },
{ path: '/lists', name: 'lists', component: () => import('../vue/views/ListsView.vue'), meta: { title: 'Lists' } },
{ path: '/campaigns', name: 'campaigns', component: () => import('../vue/views/CampaignsView.vue'), meta: { title: 'Campaigns' } },
{ path: '/campaigns/stuck', name: 'stuck-campaigns', component: () => import('../vue/views/StuckCampaignsView.vue'), meta: { title: 'Stuck Campaigns' } },
{ path: '/templates', name: 'templates', component: () => import('../vue/views/TemplatesView.vue'), meta: { title: 'Templates' } },
{ path: '/templates/create', name: 'template-create', component: () => import('../vue/views/TemplateEditView.vue'), meta: { title: 'Create Template' } },
{ path: '/templates/:templateId/edit', name: 'template-edit', component: () => import('../vue/views/TemplateEditView.vue'), meta: { title: 'Edit Template' } },
{ path: '/campaigns/create', name: 'campaign-create', component: () => import('../vue/views/CampaignEditView.vue'), meta: { title: 'Create Campaign' } },
{ path: '/campaigns/:campaignId/edit', name: 'campaign-edit', component: () => import('../vue/views/CampaignEditView.vue'), meta: { title: 'Edit Campaign' } },
{ path: '/lists/:listId/subscribers', name: 'list-subscribers', component: () => import('../vue/views/ListSubscribersView.vue'), meta: { title: 'List Subscribers' } },
{ path: '/bounces', name: 'bounces', component: () => import('../vue/views/BouncesView.vue'), meta: { title: 'Bounces' } },
{ path: '/analytics', name: 'analytics', component: () => import('../vue/views/AnalyticsView.vue'), meta: { title: 'Analytics' } },
{ path: '/public', name: 'public-pages', component: () => import('../vue/views/PublicPagesView.vue'), meta: { title: 'Public Pages' } },
{ path: '/public/create', name: 'public-page-create', component: () => import('../vue/views/PublicPageEditView.vue'), meta: { title: 'Create Public Page' } },
{ path: '/public/:pageId/edit', name: 'public-page-edit', component: () => import('../vue/views/PublicPageEditView.vue'), meta: { title: 'Edit Public Page' } },
{ path: '/settings', name: 'settings', component: () => import('../vue/views/SettingsView.vue'), meta: { title: 'Settings' } },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
});
Expand Down
11 changes: 11 additions & 0 deletions assets/styles/app.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@import "tailwindcss";
@tailwind utilities;

@custom-variant dark (&:where(.dark, .dark *));

@theme {
--color-primary: #2563eb;
--color-secondary: #6b7280;
Expand All @@ -14,3 +16,12 @@

@source "../../templates/**/*.twig";
@source "../**/*.vue";

@layer base {
button:not(:disabled),
[type="button"]:not(:disabled),
[type="submit"]:not(:disabled),
[type="reset"]:not(:disabled) {
cursor: pointer;
}
}
10 changes: 9 additions & 1 deletion assets/vue/App.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-[#F8FAFC] flex text-slate-900 font-sans">
<div class="min-h-screen bg-[#F8FAFC] dark:bg-slate-900 flex text-slate-900 dark:text-slate-100 font-sans">
<AppSidebar />

<div class="flex flex-col flex-1 min-h-screen min-w-0">
Expand All @@ -9,5 +9,13 @@
</template>

<script setup>
import { onMounted } from 'vue'
import AppSidebar from './components/sidebar/AppSidebar.vue'
import { useDarkMode } from './composables/useDarkMode'

const { initDarkMode } = useDarkMode()

onMounted(() => {
initDarkMode()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</script>
4 changes: 3 additions & 1 deletion assets/vue/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,16 @@ const redirectToLogin = () => {

const appElement = document.getElementById('vue-app');
const apiToken = appElement?.dataset.apiToken;
const apiBaseUrl = appElement?.dataset.apiBaseUrl;
const apiElement = document.getElementById('api-config');
const apiBaseUrl = apiElement?.dataset.apiBaseUrl;

if (!apiBaseUrl) {
console.error('API Base URL is not configured.');
}

const client = new Client(apiBaseUrl || '', {
onAuthenticationError: redirectToLogin,
timeout: 90000,
});

if (apiToken) {
Expand Down
47 changes: 47 additions & 0 deletions assets/vue/components/base/ActionButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!-- assets/vue/components/base/ActionButton.vue -->
<template>
<button
type="button"
:class="buttonClass"
v-bind="$attrs"
>
<BaseIcon v-if="icon" :name="icon" class="w-3.5 h-3.5" />
<slot />
</button>
</template>

<script setup>
import { computed } from 'vue'
import BaseIcon from './BaseIcon.vue'

const props = defineProps({
variant: {
type: String,
default: 'neutral' // neutral | danger | success | info | warning
},
icon: {
type: String,
default: ''
},
block: {
type: Boolean,
default: false // centers content, for full-width mobile buttons
}
})

const variantClassMap = {
neutral: 'border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-700',
danger: 'border-red-200 dark:border-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-500/10',
success: 'border-emerald-200 dark:border-emerald-500/20 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-emerald-500/10',
info: 'border-blue-200 dark:border-blue-500/20 text-blue-700 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-500/10',
warning: 'border-amber-200 dark:border-amber-500/20 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-500/10'
}

const buttonClass = computed(() => {
const base = 'inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-60'
const justify = props.block ? 'justify-center' : ''
const variant = variantClassMap[props.variant] || variantClassMap.neutral

return [base, justify, variant].filter(Boolean).join(' ')
})
</script>
16 changes: 13 additions & 3 deletions assets/vue/components/base/BaseBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,28 @@ import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'neutral', // neutral | counter
default: 'neutral', // neutral | counter | success | danger | info | warning | purple
},
})

const badgeClass = computed(() => {
const base = 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium';
switch (props.variant) {
case 'counter':
return `${base} bg-indigo-50 text-ext-wf3 border border-indigo-100`;
return `${base} bg-indigo-50 dark:bg-indigo-500/10 text-ext-wf3 dark:text-indigo-300 border border-indigo-100 dark:border-indigo-500/20`;
case 'success':
return `${base} bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400`;
case 'danger':
return `${base} bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400`;
case 'info':
return `${base} bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400`;
case 'warning':
return `${base} bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400`;
case 'purple':
return `${base} bg-purple-100 text-purple-700 dark:bg-purple-500/10 dark:text-purple-400`;
case 'neutral':
default:
return `${base} bg-gray-100 text-gray-800`;
return `${base} bg-gray-100 dark:bg-slate-700 text-gray-800 dark:text-slate-200`;
}
})
</script>
Loading
Loading