diff --git a/.gitignore b/.gitignore index 4f648764..caa8269a 100755 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ /node_modules /drivers/ .codex +.claude/ diff --git a/README.md b/README.md index 0971e85f..c9269772 100644 --- a/README.md +++ b/README.md @@ -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 `/`. diff --git a/assets/editor/EditorAssetPicker.vue b/assets/editor/EditorAssetPicker.vue index ba16dc27..de31e468 100644 --- a/assets/editor/EditorAssetPicker.vue +++ b/assets/editor/EditorAssetPicker.vue @@ -8,37 +8,37 @@ aria-labelledby="editor-asset-picker-title" @keydown.esc="$emit('close')" > -
-
+
+
-

+

Choose an existing asset

-

Pick a file to insert it at the cursor position.

+

Pick a file to insert it at the cursor position.

-
+
-
+
Loading assets...
-
+
{{ error }}
- - - - - - - - + + + + + + + + - @@ -75,33 +75,33 @@ - - -
PreviewNameTypeSizeUpdated
PreviewNameTypeSizeUpdated
+ No assets found.
{{ extensionLabel(item.fileName) }}
-

{{ item.fileName }}

-

{{ item.url }}

+

{{ item.fileName }}

+

{{ item.url }}

+ {{ item.mimeType }} + {{ formatBytes(item.size) }} + {{ formatDate(item.modifiedAt) }} @@ -118,9 +118,9 @@
-
+
{{ filteredItems.length }} asset{{ filteredItems.length === 1 ? '' : 's' }} - Choose a row to insert it immediately. + Choose a row to insert it immediately.
diff --git a/assets/router/index.js b/assets/router/index.js index 702e426d..d65e1dae 100644 --- a/assets/router/index.js +++ b/assets/router/index.js @@ -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: '/' }, ], }); diff --git a/assets/styles/app.css b/assets/styles/app.css index 540b4461..ffcaa258 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -1,6 +1,8 @@ @import "tailwindcss"; @tailwind utilities; +@custom-variant dark (&:where(.dark, .dark *)); + @theme { --color-primary: #2563eb; --color-secondary: #6b7280; @@ -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; + } +} diff --git a/assets/vue/App.vue b/assets/vue/App.vue index dacfe524..8ce36134 100644 --- a/assets/vue/App.vue +++ b/assets/vue/App.vue @@ -1,5 +1,5 @@ diff --git a/assets/vue/api.js b/assets/vue/api.js index be71e858..3fecc3bd 100644 --- a/assets/vue/api.js +++ b/assets/vue/api.js @@ -38,7 +38,8 @@ 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.'); @@ -46,6 +47,7 @@ if (!apiBaseUrl) { const client = new Client(apiBaseUrl || '', { onAuthenticationError: redirectToLogin, + timeout: 90000, }); if (apiToken) { diff --git a/assets/vue/components/base/ActionButton.vue b/assets/vue/components/base/ActionButton.vue new file mode 100644 index 00000000..7c145a61 --- /dev/null +++ b/assets/vue/components/base/ActionButton.vue @@ -0,0 +1,47 @@ + + + + \ No newline at end of file diff --git a/assets/vue/components/base/BaseBadge.vue b/assets/vue/components/base/BaseBadge.vue index 217fd1ba..2ac0796b 100644 --- a/assets/vue/components/base/BaseBadge.vue +++ b/assets/vue/components/base/BaseBadge.vue @@ -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 }, }) @@ -19,10 +19,20 @@ 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`; } }) diff --git a/assets/vue/components/base/BaseButton.vue b/assets/vue/components/base/BaseButton.vue index aaeb2e84..c840b22b 100644 --- a/assets/vue/components/base/BaseButton.vue +++ b/assets/vue/components/base/BaseButton.vue @@ -5,27 +5,35 @@ type="button" v-bind="$attrs" > + \ No newline at end of file diff --git a/assets/vue/components/base/BaseIcon.vue b/assets/vue/components/base/BaseIcon.vue index 29551068..dc11ff50 100644 --- a/assets/vue/components/base/BaseIcon.vue +++ b/assets/vue/components/base/BaseIcon.vue @@ -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 @@ -94,7 +101,13 @@ const icons = { pause: ``, + sun: ``, + + moon: ``, + start: ``, + + plus: ``, }; const svg = computed(() => icons[props.name] || ""); @@ -107,10 +120,12 @@ const wrapperClass = computed(() => { "transition-colors", ]; - if (props.active) { - classes.push("text-ext-wf3"); + 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"); + classes.push("text-slate-400 group-hover:text-slate-600 dark:group-hover:text-slate-300"); } return classes.join(" "); diff --git a/assets/vue/components/base/BaseModal.vue b/assets/vue/components/base/BaseModal.vue new file mode 100644 index 00000000..2de8cd75 --- /dev/null +++ b/assets/vue/components/base/BaseModal.vue @@ -0,0 +1,66 @@ + + + \ No newline at end of file diff --git a/assets/vue/components/base/BaseProgressBar.vue b/assets/vue/components/base/BaseProgressBar.vue index 91ac7a77..6deb9567 100644 --- a/assets/vue/components/base/BaseProgressBar.vue +++ b/assets/vue/components/base/BaseProgressBar.vue @@ -1,6 +1,6 @@ diff --git a/assets/vue/components/bounces/BounceOverview.vue b/assets/vue/components/bounces/BounceOverview.vue index 02cf4796..9e00691b 100644 --- a/assets/vue/components/bounces/BounceOverview.vue +++ b/assets/vue/components/bounces/BounceOverview.vue @@ -1,14 +1,14 @@