Implement web ui

This commit is contained in:
nemunaire 2025-10-17 18:45:53 +07:00
commit 6a4909c1a7
13 changed files with 1141 additions and 0 deletions

152
web/src/app.css Normal file
View file

@ -0,0 +1,152 @@
:root {
--bs-primary: #1cb487;
--bs-primary-rgb: 28, 180, 135;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
.spin {
animation: spin 1s linear infinite;
}
/* Score styling */
.score-excellent {
color: #198754;
}
.score-good {
color: #20c997;
}
.score-warning {
color: #ffc107;
}
.score-poor {
color: #fd7e14;
}
.score-bad {
color: #dc3545;
}
/* Custom card styling */
.card {
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
/* Check status badges */
.check-status {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.check-pass {
background-color: #d1e7dd;
color: #0f5132;
}
.check-fail {
background-color: #f8d7da;
color: #842029;
}
.check-warn {
background-color: #fff3cd;
color: #664d03;
}
.check-info {
background-color: #cfe2ff;
color: #084298;
}
/* Clipboard button */
.clipboard-btn {
cursor: pointer;
transition: all 0.2s ease;
}
.clipboard-btn:hover {
transform: scale(1.1);
}
.clipboard-btn:active {
transform: scale(0.95);
}
/* Progress bar animation */
.progress-bar {
transition: width 0.6s ease;
}
/* Hero section */
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
/* Feature icons */
.feature-icon {
width: 4rem;
height: 4rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1rem;
}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import type { Check } from "$lib/api/types.gen";
interface Props {
check: Check;
}
let { check }: Props = $props();
function getCheckIcon(status: string): string {
switch (status) {
case "pass":
return "bi-check-circle-fill text-success";
case "fail":
return "bi-x-circle-fill text-danger";
case "warn":
return "bi-exclamation-triangle-fill text-warning";
case "info":
return "bi-info-circle-fill text-info";
default:
return "bi-question-circle-fill text-secondary";
}
}
</script>
<div class="card mb-3">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="fs-4">
<i class={getCheckIcon(check.status)}></i>
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<h5 class="fw-bold mb-1">{check.name}</h5>
<span class="badge bg-secondary text-capitalize">{check.category}</span>
</div>
<span class="badge bg-light text-dark">{check.score.toFixed(1)} pts</span>
</div>
<p class="mt-2 mb-2">{check.message}</p>
{#if check.advice}
<div class="alert alert-light border mb-2" role="alert">
<i class="bi bi-lightbulb me-2"></i>
<strong>Recommendation:</strong>
{check.advice}
</div>
{/if}
{#if check.details}
<details class="small text-muted">
<summary class="cursor-pointer">Technical Details</summary>
<pre class="mt-2 mb-0 small bg-light p-2 rounded">{check.details}</pre>
</details>
{/if}
</div>
</div>
</div>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
</style>

View file

@ -0,0 +1,46 @@
<script lang="ts">
interface Props {
email: string;
}
let { email }: Props = $props();
let copied = $state(false);
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(email);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
}
</script>
<div class="bg-light rounded p-4">
<div class="d-flex align-items-center justify-content-center gap-3">
<code class="fs-5 text-primary fw-bold">{email}</code>
<button
class="btn btn-sm btn-outline-primary clipboard-btn"
onclick={copyToClipboard}
title="Copy to clipboard"
>
<i class={copied ? "bi bi-check2" : "bi bi-clipboard"}></i>
</button>
</div>
{#if copied}
<small class="text-success d-block mt-2">
<i class="bi bi-check2"></i> Copied to clipboard!
</small>
{/if}
</div>
<style>
.clipboard-btn {
transition: all 0.2s ease;
}
.clipboard-btn:hover {
transform: scale(1.1);
}
</style>

View file

@ -0,0 +1,33 @@
<script lang="ts">
interface Props {
icon: string;
title: string;
description: string;
variant?: "primary" | "success" | "warning" | "danger" | "info" | "secondary";
}
let { icon, title, description, variant = "primary" }: Props = $props();
</script>
<div class="card h-100 text-center p-4">
<div class="feature-icon bg-{variant} bg-opacity-10 text-{variant} mx-auto">
<i class="bi {icon}"></i>
</div>
<h5 class="fw-bold">{title}</h5>
<p class="text-muted small mb-0">
{description}
</p>
</div>
<style>
.feature-icon {
width: 4rem;
height: 4rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1rem;
}
</style>

View file

@ -0,0 +1,17 @@
<script lang="ts">
interface Props {
step: number;
title: string;
description: string;
}
let { step, title, description }: Props = $props();
</script>
<div class="card h-100 text-center p-4">
<div class="display-1 text-primary fw-bold opacity-25">{step}</div>
<h5 class="fw-bold mt-3">{title}</h5>
<p class="text-muted mb-0">
{description}
</p>
</div>

View file

@ -0,0 +1,115 @@
<script lang="ts">
import type { Test } from "$lib/api/types.gen";
import EmailAddressDisplay from "./EmailAddressDisplay.svelte";
interface Props {
test: Test;
}
let { test }: Props = $props();
</script>
<div class="row justify-content-center">
<div class="col-lg-8 fade-in">
<div class="card shadow-lg">
<div class="card-body p-5 text-center">
<div class="pulse mb-4">
<i class="bi bi-envelope-paper display-1 text-primary"></i>
</div>
<h2 class="fw-bold mb-3">Waiting for Your Email</h2>
<p class="text-muted mb-4">Send your test email to the address below:</p>
<div class="mb-4">
<EmailAddressDisplay email={test.email} />
</div>
<div class="alert alert-info mb-4" role="alert">
<i class="bi bi-lightbulb me-2"></i>
<strong>Tip:</strong> Send an email that represents your actual use case (newsletters,
transactional emails, etc.) for the most accurate results.
</div>
{#if test.status === "received"}
<div class="alert alert-success" role="alert">
<i class="bi bi-check-circle me-2"></i>
Email received! Analyzing...
</div>
{/if}
<div class="d-flex align-items-center justify-content-center gap-2 text-muted">
<div class="spinner-border spinner-border-sm" role="status"></div>
<small>Checking for email every 3 seconds...</small>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="card mt-4">
<div class="card-body">
<h5 class="fw-bold mb-3">
<i class="bi bi-info-circle me-2"></i>What we'll check:
</h5>
<div class="row g-3">
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> SPF, DKIM, DMARC
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> DNS Records
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> SpamAssassin Score
</li>
</ul>
</div>
<div class="col-md-6">
<ul class="list-unstyled mb-0">
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> Blacklist Status
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> Content Quality
</li>
<li class="mb-2">
<i class="bi bi-check2 text-success me-2"></i> Header Validation
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.pulse {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>

View file

@ -0,0 +1,71 @@
<script lang="ts">
import type { ScoreSummary } from "$lib/api/types.gen";
interface Props {
score: number;
summary?: ScoreSummary;
}
let { score, summary }: Props = $props();
function getScoreClass(score: number): string {
if (score >= 9) return "score-excellent";
if (score >= 7) return "score-good";
if (score >= 5) return "score-warning";
if (score >= 3) return "score-poor";
return "score-bad";
}
function getScoreLabel(score: number): string {
if (score >= 9) return "Excellent";
if (score >= 7) return "Good";
if (score >= 5) return "Fair";
if (score >= 3) return "Poor";
return "Critical";
}
</script>
<div class="card shadow-lg bg-white">
<div class="card-body p-5 text-center">
<h1 class="display-1 fw-bold mb-3 {getScoreClass(score)}">
{score.toFixed(1)}/10
</h1>
<h3 class="fw-bold mb-2">{getScoreLabel(score)}</h3>
<p class="text-muted mb-4">Overall Deliverability Score</p>
{#if summary}
<div class="row g-3 text-start">
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Authentication</small>
<strong class="fs-5">{summary.authentication_score.toFixed(1)}/3</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Spam Score</small>
<strong class="fs-5">{summary.spam_score.toFixed(1)}/2</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Blacklists</small>
<strong class="fs-5">{summary.blacklist_score.toFixed(1)}/2</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Content</small>
<strong class="fs-5">{summary.content_score.toFixed(1)}/2</strong>
</div>
</div>
<div class="col-md-6 col-lg">
<div class="p-3 bg-light rounded">
<small class="text-muted d-block">Headers</small>
<strong class="fs-5">{summary.header_score.toFixed(1)}/1</strong>
</div>
</div>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type { SpamAssassinResult } from "$lib/api/types.gen";
interface Props {
spamassassin: SpamAssassinResult;
}
let { spamassassin }: Props = $props();
</script>
<div class="card">
<div class="card-header bg-warning bg-opacity-10">
<h5 class="mb-0 fw-bold">
<i class="bi bi-bug me-2"></i>SpamAssassin Analysis
</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-md-6">
<strong>Score:</strong>
<span class={spamassassin.is_spam ? "text-danger" : "text-success"}>
{spamassassin.score.toFixed(2)} / {spamassassin.required_score.toFixed(1)}
</span>
</div>
<div class="col-md-6">
<strong>Classified as:</strong>
<span class="badge {spamassassin.is_spam ? 'bg-danger' : 'bg-success'} ms-2">
{spamassassin.is_spam ? "SPAM" : "HAM"}
</span>
</div>
</div>
{#if spamassassin.tests && spamassassin.tests.length > 0}
<div class="mb-2">
<strong>Tests Triggered:</strong>
<div class="mt-2">
{#each spamassassin.tests as test}
<span class="badge bg-light text-dark me-1 mb-1">{test}</span>
{/each}
</div>
</div>
{/if}
{#if spamassassin.report}
<details class="mt-3">
<summary class="cursor-pointer fw-bold">Full Report</summary>
<pre class="mt-2 small bg-light p-3 rounded">{spamassassin.report}</pre>
</details>
{/if}
</div>
</div>
<style>
.cursor-pointer {
cursor: pointer;
}
details summary {
user-select: none;
}
details summary:hover {
color: var(--bs-primary);
}
</style>

View file

@ -0,0 +1,8 @@
// Component exports
export { default as FeatureCard } from "./FeatureCard.svelte";
export { default as HowItWorksStep } from "./HowItWorksStep.svelte";
export { default as ScoreCard } from "./ScoreCard.svelte";
export { default as CheckCard } from "./CheckCard.svelte";
export { default as SpamAssassinCard } from "./SpamAssassinCard.svelte";
export { default as EmailAddressDisplay } from "./EmailAddressDisplay.svelte";
export { default as PendingState } from "./PendingState.svelte";

View file

@ -0,0 +1,150 @@
<script lang="ts">
import { page } from "$app/stores";
let status = $derived($page.status);
let message = $derived($page.error?.message || "An unexpected error occurred");
function getErrorTitle(status: number): string {
switch (status) {
case 404:
return "Page Not Found";
case 403:
return "Access Denied";
case 500:
return "Server Error";
case 503:
return "Service Unavailable";
default:
return "Something Went Wrong";
}
}
function getErrorDescription(status: number): string {
switch (status) {
case 404:
return "The page you're looking for doesn't exist or has been moved.";
case 403:
return "You don't have permission to access this resource.";
case 500:
return "Our server encountered an error while processing your request.";
case 503:
return "The service is temporarily unavailable. Please try again later.";
default:
return "An unexpected error occurred. Please try again.";
}
}
function getErrorIcon(status: number): string {
switch (status) {
case 404:
return "bi-search";
case 403:
return "bi-shield-lock";
case 500:
return "bi-exclamation-triangle";
case 503:
return "bi-clock-history";
default:
return "bi-exclamation-circle";
}
}
</script>
<svelte:head>
<title>{status} - {getErrorTitle(status)} | happyDeliver</title>
</svelte:head>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-lg-6 text-center fade-in">
<!-- Error Icon -->
<div class="error-icon-wrapper mb-4">
<i class="bi {getErrorIcon(status)} text-danger"></i>
</div>
<!-- Error Status -->
<h1 class="display-1 fw-bold text-primary mb-3">{status}</h1>
<!-- Error Title -->
<h2 class="fw-bold mb-3">{getErrorTitle(status)}</h2>
<!-- Error Description -->
<p class="text-muted mb-4">{getErrorDescription(status)}</p>
<!-- Error Message (if available) -->
{#if message !== getErrorDescription(status)}
<div class="alert alert-light border mb-4" role="alert">
<i class="bi bi-info-circle me-2"></i>
{message}
</div>
{/if}
<!-- Action Buttons -->
<div class="d-flex flex-column flex-sm-row gap-3 justify-content-center">
<a href="/" class="btn btn-primary btn-lg px-4">
<i class="bi bi-house-door me-2"></i>
Go Home
</a>
<button
class="btn btn-outline-primary btn-lg px-4"
onclick={() => window.history.back()}
>
<i class="bi bi-arrow-left me-2"></i>
Go Back
</button>
</div>
<!-- Additional Help -->
{#if status === 404}
<div class="mt-5">
<p class="text-muted small mb-2">Looking for something specific?</p>
<div class="d-flex flex-wrap gap-2 justify-content-center">
<a href="/" class="badge bg-light text-dark text-decoration-none">Home</a>
<a href="/#features" class="badge bg-light text-dark text-decoration-none"
>Features</a
>
<a
href="https://github.com/happyDomain/happydeliver"
class="badge bg-light text-dark text-decoration-none"
>
Documentation
</a>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.error-icon-wrapper {
font-size: 6rem;
line-height: 1;
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.badge {
padding: 0.5rem 1rem;
font-weight: normal;
transition: all 0.2s ease;
}
.badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
</style>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css";
import "../app.css";
interface Props {
children?: import("svelte").Snippet;
}
let { children }: Props = $props();
</script>
<div class="min-vh-100 d-flex flex-column">
<nav class="navbar navbar-expand-lg navbar-dark bg-primary shadow-sm">
<div class="container">
<a class="navbar-brand fw-bold" href="/">
<i class="bi bi-envelope-check me-2"></i>
happyDeliver
</a>
<span class="navbar-text text-white-50 small">
Open-Source Email Deliverability Tester
</span>
</div>
</nav>
<main class="flex-grow-1">
{@render children?.()}
</main>
<footer class="bg-dark text-light py-4">
<div class="container text-center">
<p class="mb-1">
<small class="d-flex justify-content-center gap-2">
Open-Source Email Deliverability Testing Platform
<span class="mx-1">&bull;</span>
<a
href="https://github.com/happyDomain/happyDeliver"
class="text-decoration-none"
>
<i class="bi bi-github"></i> GitHub
</a>
<a
href="https://framagit.com/happyDomain/happyDeliver"
class="text-decoration-none"
>
<i class="bi bi-gitlab"></i> GitLab
</a>
</small>
</p>
</div>
</footer>
</div>

216
web/src/routes/+page.svelte Normal file
View file

@ -0,0 +1,216 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { createTest as apiCreateTest } from "$lib/api";
import { FeatureCard, HowItWorksStep } from "$lib/components";
let loading = $state(false);
let error = $state<string | null>(null);
async function createTest() {
loading = true;
error = null;
try {
const response = await apiCreateTest();
if (response.data) {
goto(`/test/${response.data.id}`);
}
} catch (err) {
error = err instanceof Error ? err.message : "Failed to create test";
loading = false;
}
}
const features = [
{
icon: "bi-shield-check",
title: "Authentication",
description:
"SPF, DKIM, and DMARC validation with detailed results and recommendations.",
variant: "primary" as const,
},
{
icon: "bi-globe",
title: "DNS Records",
description: "Verify MX, SPF, DKIM, and DMARC records are properly configured.",
variant: "success" as const,
},
{
icon: "bi-bug",
title: "Spam Score",
description: "SpamAssassin analysis with detailed test results and scoring.",
variant: "warning" as const,
},
{
icon: "bi-list-check",
title: "Blacklists",
description: "Check if your IP is listed in major DNS-based blacklists (RBLs).",
variant: "danger" as const,
},
{
icon: "bi-file-text",
title: "Content Analysis",
description: "HTML structure, link validation, image analysis, and more.",
variant: "info" as const,
},
{
icon: "bi-card-heading",
title: "Header Quality",
description: "Validate required headers, check for missing fields and alignment.",
variant: "secondary" as const,
},
{
icon: "bi-bar-chart",
title: "Detailed Scoring",
description:
"0-10 deliverability score with breakdown by category and recommendations.",
variant: "primary" as const,
},
{
icon: "bi-lock",
title: "Privacy First",
description: "Self-hosted solution, your data never leaves your infrastructure.",
variant: "success" as const,
},
];
const steps = [
{
step: 1,
title: "Create Test",
description: "Click the button to generate a unique test email address.",
},
{
step: 2,
title: "Send Email",
description: "Send a test email from your mail server to the provided address.",
},
{
step: 3,
title: "View Results",
description: "Get instant detailed analysis with actionable recommendations.",
},
];
</script>
<svelte:head>
<title>happyDeliver - Email Deliverability Testing</title>
</svelte:head>
<!-- Hero Section -->
<section class="hero py-5">
<div class="container py-5">
<div class="row align-items-center">
<div class="col-lg-8 mx-auto text-center fade-in">
<h1 class="display-3 fw-bold mb-4">Test Your Email Deliverability</h1>
<p class="lead mb-4 opacity-90">
Get detailed insights into your email configuration, authentication, spam score,
and more. Open-source, self-hosted, and privacy-focused.
</p>
<button
class="btn btn-light btn-lg px-5 py-3 shadow"
onclick={createTest}
disabled={loading}
>
{#if loading}
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Creating Test...
{:else}
<i class="bi bi-envelope-plus me-2"></i>
Start Free Test
{/if}
</button>
{#if error}
<div class="alert alert-danger mt-4 d-inline-block" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
{/if}
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-5">
<div class="container py-4">
<div class="row text-center mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="display-5 fw-bold mb-3">Comprehensive Email Analysis</h2>
<p class="text-muted">
Your favorite deliverability tester, open-source and
self-hostable for complete privacy and control.
</p>
</div>
</div>
<div class="row g-4">
{#each features as feature}
<div class="col-md-6 col-lg-3">
<FeatureCard {...feature} />
</div>
{/each}
</div>
</div>
</section>
<!-- How It Works -->
<section class="bg-light py-5">
<div class="container py-4">
<div class="row text-center mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="display-5 fw-bold mb-3">How It Works</h2>
<p class="text-muted">
Simple three-step process to test your email deliverability
</p>
</div>
</div>
<div class="row g-4">
{#each steps as stepData}
<div class="col-md-4">
<HowItWorksStep {...stepData} />
</div>
{/each}
</div>
<div class="text-center mt-5">
<button
class="btn btn-primary btn-lg px-5 py-3"
onclick={createTest}
disabled={loading}
>
{#if loading}
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Creating Test...
{:else}
<i class="bi bi-rocket-takeoff me-2"></i>
Get Started Now
{/if}
</button>
</div>
</div>
</section>
<style>
.hero {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { page } from "$app/state";
import { getTest, getReport } from "$lib/api";
import type { Test, Report } from "$lib/api/types.gen";
import { ScoreCard, CheckCard, SpamAssassinCard, PendingState } from "$lib/components";
let testId = $derived(page.params.test);
let test = $state<Test | null>(null);
let report = $state<Report | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let pollInterval: ReturnType<typeof setInterval> | null = null;
async function fetchTest() {
try {
const testResponse = await getTest({ path: { id: testId } });
if (testResponse.data) {
test = testResponse.data;
if (test.status === "analyzed") {
const reportResponse = await getReport({ path: { id: testId } });
if (reportResponse.data) {
report = reportResponse.data;
}
stopPolling();
}
}
loading = false;
} catch (err) {
error = err instanceof Error ? err.message : "Failed to fetch test";
loading = false;
stopPolling();
}
}
function startPolling() {
pollInterval = setInterval(fetchTest, 3000);
}
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
onMount(() => {
fetchTest();
startPolling();
});
onDestroy(() => {
stopPolling();
});
</script>
<svelte:head>
<title>{test ? `Test ${test.id.slice(0, 8)} - happyDeliver` : "Loading..."}</title>
</svelte:head>
<div class="container py-5">
{#if loading}
<div class="text-center py-5">
<div
class="spinner-border text-primary"
role="status"
style="width: 3rem; height: 3rem;"
>
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-muted">Loading test...</p>
</div>
{:else if error}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="alert alert-danger" role="alert">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
</div>
</div>
</div>
{:else if test && test.status !== "analyzed"}
<!-- Pending State -->
<PendingState {test} />
{:else if report}
<!-- Results State -->
<div class="fade-in">
<!-- Score Header -->
<div class="row mb-4">
<div class="col-12">
<ScoreCard score={report.score} summary={report.summary} />
</div>
</div>
<!-- Detailed Checks -->
<div class="row mb-4">
<div class="col-12">
<h3 class="fw-bold mb-3">Detailed Checks</h3>
{#each report.checks as check}
<CheckCard {check} />
{/each}
</div>
</div>
<!-- Additional Information -->
{#if report.spamassassin}
<div class="row mb-4">
<div class="col-12">
<SpamAssassinCard spamassassin={report.spamassassin} />
</div>
</div>
{/if}
<!-- Test Again Button -->
<div class="row">
<div class="col-12 text-center">
<a href="/" class="btn btn-primary btn-lg">
<i class="bi bi-arrow-repeat me-2"></i>
Test Another Email
</a>
</div>
</div>
</div>
{/if}
</div>
<style>
.fade-in {
animation: fadeIn 0.6s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>