Implement web ui

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

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";