Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
86617f4
Revert "ref: comment out admin data fetching logic in AdminLayout"
tatevikg1 Sep 23, 2026
1820a6a
feat: add reusable BaseModal and ActionButton components, update moda…
tatevikg1 Sep 23, 2026
3efbcd8
feat: create AttributeDirectory component and refactor admin/subscrib…
tatevikg1 Sep 23, 2026
e5d0525
feat: implement BaseDataTable component and refactor attribute, maili…
tatevikg1 Sep 23, 2026
6dfac1a
feat: enhance BaseBadge component with additional variants and refact…
tatevikg1 Sep 23, 2026
f4dfea5
Merge branch 'dev' of github.com:phpList/web-frontend into ref
tatevikg1 Sep 24, 2026
33b4747
Merge branch 'dev' of github.com:phpList/web-frontend into ref
tatevikg1 Sep 24, 2026
3cd8860
feat: refactor button components and replace button elements with Bas…
tatevikg1 Sep 24, 2026
7a2b4f1
feat: implement campaign pagination and statistics composables for ca…
tatevikg1 Sep 24, 2026
a6a8cfe
chore: add .claude/ to .gitignore
tatevikg1 Sep 24, 2026
8c02fe0
feat: enhance authentication error handling and remove deprecated Aut…
tatevikg1 Sep 28, 2026
f3a005e
feat: refactor session handling by introducing traits for login URL b…
tatevikg1 Sep 28, 2026
0bc3bfd
refactor: implement WantsJsonTrait for consistent JSON response handling
tatevikg1 Sep 28, 2026
e51f446
feat: introduce useApiValidationErrors composable for improved valida…
tatevikg1 Sep 28, 2026
31c367f
feat: implement useAsyncAction composable for improved async action h…
tatevikg1 Sep 28, 2026
9c1a1ab
feat: enhance AuthController to handle session tokens in about method
tatevikg1 Sep 28, 2026
46dfce3
feat: enhance useAsyncAction and useDashboardData for improved loadin…
tatevikg1 Sep 28, 2026
4a70c1a
feat: update deleteEmailsFromCurrentList to use subscriptionClient fo…
tatevikg1 Sep 28, 2026
307991f
Merge branch 'dev' of github.com:phpList/web-frontend into ref
tatevikg1 Sep 28, 2026
c96c07d
feat: update deleteEmailsFromCurrentList to fix subscription deletion…
tatevikg1 Sep 28, 2026
58de3b3
feat: rename loading and error variables for consistency across compo…
tatevikg1 Sep 28, 2026
45466ac
feat: update useAnalyticsData to handle initial loading state for Vue…
tatevikg1 Sep 28, 2026
b55c67f
feat: update router configuration to use dynamic imports for views
tatevikg1 Sep 28, 2026
82b4100
fix: test
tatevikg1 Sep 29, 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/
48 changes: 17 additions & 31 deletions assets/router/index.js
Original file line number Diff line number Diff line change
@@ -1,39 +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 StuckCampaignsView from '../vue/views/StuckCampaignsView.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: '/campaigns/stuck', name: 'stuck-campaigns', component: StuckCampaignsView, meta: { title: 'Stuck 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
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>
12 changes: 11 additions & 1 deletion assets/vue/components/base/BaseBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { computed } from 'vue'
const props = defineProps({
variant: {
type: String,
default: 'neutral', // neutral | counter
default: 'neutral', // neutral | counter | success | danger | info | warning | purple
},
})

Expand All @@ -20,6 +20,16 @@ const badgeClass = computed(() => {
switch (props.variant) {
case 'counter':
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 dark:bg-slate-700 text-gray-800 dark:text-slate-200`;
Expand Down
14 changes: 11 additions & 3 deletions assets/vue/components/base/BaseButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,35 @@
type="button"
v-bind="$attrs"
>
<BaseIcon v-if="icon" :name="icon" class="w-3.5 h-3.5" inherit-color />
<slot />
</button>
</template>

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

const props = defineProps({
variant: {
type: String,
default: 'primary', // primary | secondary | ghost
default: 'primary', // primary | secondary | ghost | accent
},
icon: {
type: String,
default: '',
},
})

const buttonClass = computed(() => {
const base = 'inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2';
const base = 'inline-flex items-center gap-2 px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2';
switch (props.variant) {
case 'secondary':
return `${base} text-gray-700 dark:text-slate-200 bg-white dark:bg-slate-800 border-gray-300 dark:border-slate-600 hover:bg-gray-50 dark:hover:bg-slate-700 focus:ring-blue-500`;
case 'ghost':
return 'inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-600 dark:text-blue-400 bg-transparent hover:bg-blue-50 dark:hover:bg-blue-500/10 focus:outline-none';
return 'inline-flex items-center gap-2 px-4 py-2 border border-transparent text-sm font-medium rounded-md text-blue-600 dark:text-blue-400 bg-transparent hover:bg-blue-50 dark:hover:bg-blue-500/10 focus:outline-none';
case 'accent':
return 'inline-flex shrink-0 items-center gap-2 whitespace-nowrap min-w-max px-4 py-2 border border-transparent text-xs font-bold rounded-lg shadow-sm bg-ext-wf1 text-white hover:bg-ext-wf3 shadow-indigo-500/20 transition-shadow disabled:opacity-60 focus:outline-none';
case 'primary':
default:
return `${base} text-white bg-blue-600 hover:bg-blue-700 focus:ring-blue-500`;
Expand Down
79 changes: 79 additions & 0 deletions assets/vue/components/base/BaseDataTable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<!--
Shared desktop-table / mobile-card layout with built-in loading, error
and empty states. Consumers supply column headers, row cells and the
mobile card body via scoped slots; everything else (wrapper markup,
state rows/cards, colspan handling) lives here once.
-->
<template>
<div class="overflow-x-auto">
<table class="w-full text-left text-sm hidden md:table">
<thead class="bg-slate-50 dark:bg-slate-900 text-slate-500 dark:text-slate-400 font-medium">
<tr>
<slot name="head" />
</tr>
</thead>

<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
<tr
v-for="item in items"
:key="rowKey(item)"
class="hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
<slot name="row" :item="item" />
</tr>

<tr v-if="isLoading">
<td :colspan="colspan" class="px-6 py-8 text-center text-slate-500 dark:text-slate-400">
{{ loadingMessage }}
</td>
</tr>

<tr v-else-if="loadError">
<td :colspan="colspan" class="px-6 py-8 text-center text-red-600 dark:text-red-400">
{{ loadError }}
</td>
</tr>

<tr v-else-if="items.length === 0">
<td :colspan="colspan" class="px-6 py-8 text-center text-slate-500 dark:text-slate-400">
{{ emptyMessage }}
</td>
</tr>
</tbody>
</table>

<div class="block md:hidden divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="item in items"
:key="`mobile-${rowKey(item)}`"
class="p-4 space-y-3"
>
<slot name="card" :item="item" />
</div>

<div v-if="isLoading" class="px-4 py-8 text-center text-slate-500 dark:text-slate-400 text-sm">
{{ loadingMessage }}
</div>

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

<div v-else-if="items.length === 0" class="px-4 py-8 text-center text-slate-500 dark:text-slate-400 text-sm">
{{ emptyMessage }}
</div>
</div>
</div>
</template>

<script setup>
const props = defineProps({
items: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
loadError: { type: String, default: '' },
emptyMessage: { type: String, default: 'No items found.' },
loadingMessage: { type: String, default: 'Loading...' },
colspan: { type: Number, default: 1 },
rowKey: { type: Function, default: (item) => item.id }
})
</script>
13 changes: 12 additions & 1 deletion assets/vue/components/base/BaseIcon.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ const props = defineProps({
type: Boolean,
default: false,
},

// when true, skips the default slate/indigo color so the icon inherits
// currentColor from its parent (e.g. white text on a solid button)
inheritColor: {
type: Boolean,
default: false,
},
});

// Simple internal SVG registry
Expand Down Expand Up @@ -99,6 +106,8 @@ const icons = {
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-moon" aria-hidden="true"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"></path></svg>`,

start: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="6 4 20 12 6 20 6 4" rx="1"></polygon></svg>`,

plus: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus" aria-hidden="true"><path d="M5 12h14"></path><path d="M12 5v14"></path></svg>`,
};

const svg = computed(() => icons[props.name] || "");
Expand All @@ -111,7 +120,9 @@ const wrapperClass = computed(() => {
"transition-colors",
];

if (props.active) {
if (props.inheritColor) {
// no color class: SVG's stroke="currentColor" picks up the parent's text color
} else if (props.active) {
classes.push("text-ext-wf3 dark:text-indigo-400");
} else {
classes.push("text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-300");
Expand Down
66 changes: 66 additions & 0 deletions assets/vue/components/base/BaseModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<template>
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
role="dialog"
aria-modal="true"
@click.self="handleBackdropClick"
>
<div :class="['w-full rounded-xl bg-white dark:bg-slate-800 shadow-xl max-h-[90vh] overflow-y-auto', maxWidthClass]">
<div class="border-b border-slate-200 dark:border-slate-700 px-6 py-4 flex justify-between items-center">
<slot name="title">
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{{ title }}</h2>
</slot>

<button
v-if="showClose"
type="button"
aria-label="Close"
class="text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
@click="$emit('close')"
>
✕
</button>
</div>

<slot />

<div
v-if="$slots.footer"
class="bg-slate-50 dark:bg-slate-900 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2 rounded-b-xl"
>
<slot name="footer" />
</div>
</div>
</div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
isOpen: Boolean,
title: { type: String, default: '' },
maxWidth: { type: String, default: 'lg' }, // sm | md | lg | xl | 2xl
showClose: { type: Boolean, default: true },
closeOnBackdrop: { type: Boolean, default: true }
})

const emit = defineEmits(['close'])

const maxWidthMap = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl'
}

const maxWidthClass = computed(() => maxWidthMap[props.maxWidth] || maxWidthMap.lg)

const handleBackdropClick = () => {
if (props.closeOnBackdrop) {
emit('close')
}
}
</script>
Loading
Loading