diff --git a/Makefile b/Makefile index 6b2525056..2b088ab09 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Pulse Makefile for development -.PHONY: build run dev frontend backend all clean +.PHONY: build run dev frontend backend all clean dev-hot # Build everything all: frontend backend @@ -32,6 +32,9 @@ run: build dev: frontend backend sudo systemctl restart pulse-backend +dev-hot: + ./scripts/dev-hot.sh + # Clean build artifacts clean: rm -f pulse @@ -39,4 +42,4 @@ clean: # Quick rebuild and restart for development restart: frontend backend - sudo systemctl restart pulse-backend \ No newline at end of file + sudo systemctl restart pulse-backend diff --git a/README.md b/README.md index 1abad8ff7..537cdfa2e 100644 --- a/README.md +++ b/README.md @@ -504,12 +504,14 @@ journalctl -u pulse -f ### Quick Start - Hot Reload (Recommended) ```bash -# Best development experience with instant frontend updates -./scripts/hot-dev.sh -# Frontend: http://localhost:5173 (hot reload) -# Backend: http://localhost:7655 +# Launch Vite + Go with automatic frontend proxying +make dev-hot +# Frontend HMR: http://127.0.0.1:5173 +# Backend API: http://127.0.0.1:7655 (served via the Go app) ``` +The backend now detects `FRONTEND_DEV_SERVER` and proxies requests straight to the Vite dev server. Edit files under `frontend-modern/src/` and the browser refreshes instantly—no manual rebuilds or service restarts required. Use `CTRL+C` to stop both processes. + ### Production-like Development ```bash # Watches files and rebuilds/embeds frontend into Go binary @@ -565,4 +567,4 @@ See Pulse in action with our [complete screenshot gallery →](docs/SCREENSHOTS. ## License -MIT - See [LICENSE](LICENSE) \ No newline at end of file +MIT - See [LICENSE](LICENSE) diff --git a/docs/frontend-style-guide.md b/docs/frontend-style-guide.md new file mode 100644 index 000000000..ff585105b --- /dev/null +++ b/docs/frontend-style-guide.md @@ -0,0 +1,82 @@ +# Frontend UI Style Guide + +This project now ships a handful of shared primitives to keep typography and form layouts consistent. The snippets below show the preferred usage. + +## Section headers + +Use `SectionHeader` for any inline card titles, modal headings, or sub-section titles instead of ad-hoc `

`/`

` elements. + +```tsx +import { SectionHeader } from '@/components/shared/SectionHeader'; + + +``` + +Pass `titleClass`/`descriptionClass` when you need to tweak color or emphasis without rebuilding the layout. + +## Empty states + +Whenever a panel needs to show a loading, error, or "no data" treatment, render `EmptyState` inside a `Card`. + +```tsx +import { Card } from '@/components/shared/Card'; +import { EmptyState } from '@/components/shared/EmptyState'; + + + } + title="No backups yet" + description="Run your first job or adjust the filters to see activity." + actions={( + + )} + /> + +``` + +Icons and actions are optional; omit them when not needed. + +## Form helpers + +Shared form styles live in `@/components/shared/Form`. Import the helpers and apply them to each field container, label, and control for a uniform look. + +```tsx +import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form'; + +
+ + +

+ Use HTTPS on port 8006 for Proxmox VE and 8007 for PBS. +

+
+ + +``` + +Helper summary: + +- `formField`: wraps a label + control stack. +- `labelClass(extra?)`: base typography for labels, with optional extra classes. +- `controlClass(extra?)`: base input styling; append sizing tweaks (`px-2 py-1.5`) as needed. +- `formHelpText`: small secondary text (validation notes, hints). +- `formCheckbox`: shared checkbox styling for toggles inside copy-heavy forms. + +Stick to these helpers when building new settings panels, modals, or detail cards. If a component needs a variant that the helpers do not cover, extend them in `Form.ts` so the convention remains centralized. diff --git a/frontend-modern/src/api/notifications.ts b/frontend-modern/src/api/notifications.ts index 6e59f0e5d..cb1cc3ea3 100644 --- a/frontend-modern/src/api/notifications.ts +++ b/frontend-modern/src/api/notifications.ts @@ -107,7 +107,8 @@ export class NotificationsAPI { // Webhook management static async getWebhooks(): Promise { - return apiFetchJSON(`${this.baseUrl}/webhooks`); + const data = await apiFetchJSON(`${this.baseUrl}/webhooks`); + return Array.isArray(data) ? data : []; } static async createWebhook(webhook: Omit): Promise { diff --git a/frontend-modern/src/components/Alerts/CustomRulesTab.tsx b/frontend-modern/src/components/Alerts/CustomRulesTab.tsx index 9035fdf56..135223c34 100644 --- a/frontend-modern/src/components/Alerts/CustomRulesTab.tsx +++ b/frontend-modern/src/components/Alerts/CustomRulesTab.tsx @@ -1,5 +1,8 @@ import { For, Show } from 'solid-js'; import type { CustomAlertRule } from '@/types/alerts'; +import { Card } from '@/components/shared/Card'; +import { SectionHeader } from '@/components/shared/SectionHeader'; +import { EmptyState } from '@/components/shared/EmptyState'; interface CustomRulesTabProps { rules: CustomAlertRule[]; @@ -46,13 +49,15 @@ export function CustomRulesTab(props: CustomRulesTabProps) { return (
{/* Header */} -
-

Custom Alert Rules

-

- Custom rules apply specific thresholds to guests matching filter conditions. - Rules are evaluated in priority order (higher number = higher priority). -

-
+ + + {/* Priority Order Explanation */}
@@ -73,21 +78,23 @@ export function CustomRulesTab(props: CustomRulesTabProps) { {/* Rules List */} 0} fallback={ -
- - - -

- No custom alert rules defined yet. Create rules from the Dashboard by applying filters and clicking "Create Alert". -

-
+ + + + + )} + title="No custom alert rules" + description="Create rules from the Dashboard by applying filters and choosing Create Alert." + /> + }>
b.priority - a.priority)}> {(rule) => ( -
-
-
+ +

{rule.name}

@@ -132,7 +139,7 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
-
+
Filters:
@@ -167,12 +174,11 @@ export function CustomRulesTab(props: CustomRulesTabProps) {
-
-
+ )}
); -} \ No newline at end of file +} diff --git a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx index a03315599..b32d23501 100644 --- a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx +++ b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx @@ -1,5 +1,6 @@ import { createSignal, createEffect, Show, For } from 'solid-js'; import { NotificationsAPI } from '@/api/notifications'; +import { formField, labelClass, controlClass, formHelpText, formCheckbox } from '@/components/shared/Form'; interface EmailProvider { name: string; @@ -14,8 +15,8 @@ interface EmailProvider { interface EmailConfig { enabled: boolean; provider: string; - server: string; // Fixed: use 'server' not 'smtpHost' - port: number; // Fixed: use 'port' not 'smtpPort' + server: string; + port: number; from: string; username: string; password: string; @@ -37,10 +38,10 @@ interface EmailProviderSelectProps { export function EmailProviderSelect(props: EmailProviderSelectProps) { const [providers, setProviders] = createSignal([]); - const [showProviders, setShowProviders] = createSignal(false); const [showAdvanced, setShowAdvanced] = createSignal(false); - - // Load email providers + const [showInstructions, setShowInstructions] = createSignal(false); + + // Load email providers once createEffect(async () => { try { const data = await NotificationsAPI.getEmailProviders(); @@ -49,284 +50,261 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) { console.error('Failed to load email providers:', err); } }); - - const selectProvider = (provider: EmailProvider) => { + + const applyProvider = (provider: EmailProvider | undefined) => { + if (!provider) { + props.onChange({ ...props.config, provider: '' }); + setShowInstructions(false); + return; + } + props.onChange({ ...props.config, provider: provider.name, - server: provider.smtpHost, // Fixed: use 'server' not 'smtpHost' - port: provider.smtpPort, // Fixed: use 'port' not 'smtpPort' + server: provider.smtpHost, + port: provider.smtpPort, tls: provider.tls, startTLS: provider.startTLS, username: provider.name === 'SendGrid' ? 'apikey' : props.config.username, }); - setShowProviders(false); + setShowInstructions(true); }; - - const currentProvider = () => providers().find(p => p.name === props.config.provider); - + + const handleProviderChange = (value: string) => { + if (!value) { + applyProvider(undefined); + return; + } + const provider = providers().find((p) => p.name === value); + applyProvider(provider); + }; + + const currentProvider = () => providers().find((p) => p.name === props.config.provider); + return ( -
- {/* Provider Selection */} -
-
- - + +
- - {/* Basic Configuration */} -
-
- + + +
+ + +
+ {currentProvider()!.instructions} +
+
+
+ +
+ +
+
+ props.onChange({ ...props.config, server: e.currentTarget.value })} placeholder="smtp.example.com" - class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" + class={controlClass('px-2 py-1.5')} />
- -
- + +
+ props.onChange({ ...props.config, port: parseInt(e.currentTarget.value) || 587 })} placeholder="587" - class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" + class={controlClass('px-2 py-1.5')} />
- -
- + +
+ props.onChange({ ...props.config, from: e.currentTarget.value })} placeholder="noreply@example.com" - class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" + class={controlClass('px-2 py-1.5')} />
- -
- + +
+ props.onChange({ ...props.config, replyTo: e.currentTarget.value })} - placeholder="admin@example.com (optional)" - class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" + placeholder="admin@example.com" + class={controlClass('px-2 py-1.5')} />
- -
- + +
+ props.onChange({ ...props.config, username: e.currentTarget.value })} placeholder={props.config.provider === 'SendGrid' ? 'apikey' : 'username@example.com'} - class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" + class={controlClass('px-2 py-1.5')} />
- -
- + +
+ props.onChange({ ...props.config, password: e.currentTarget.value })} placeholder="••••••••" - class="w-full px-3 py-2 text-sm border rounded-lg dark:bg-gray-700 dark:border-gray-600" + class={controlClass('px-2 py-1.5')} />
- - {/* Recipients */} -
- + +
+