Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This project is a comprehensive full‑stack web app for doing simple, local fil
**PDF Tools:**

- Convert PDF pages to PNG (single page, range, or all pages)
- Footer “Stay Updated” signup validates email and opens a local mailto compose (no server storage)
- Batch-convert multiple PDFs to PNG with unique ZIP folders for same-named files
- Merge multiple PDF files into one document, including files with the same name
- Split a PDF by extracting a page range into a new document
Expand Down
1 change: 0 additions & 1 deletion backend/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
'reportlab.lib.styles',
'reportlab.platypus',
'pdf2image',
'PIL',
'pdf2docx',
'markdown2',
'bs4',
Expand Down
91 changes: 83 additions & 8 deletions frontend/src/components/Landing/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { Link } from "react-router-dom";
import {
FileText,
Expand All @@ -13,9 +13,52 @@ import {
Instagram
} from "lucide-react";
import { SiX } from "react-icons/si";
import {
buildNewsletterMailto,
isValidEmail,
} from "../../utils/newsletterSignup";

const Footer = () => {
const currentYear = new Date().getFullYear();
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">(
"idle",
);
const [statusMessage, setStatusMessage] = useState("");

const handleSubscribe = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

if (status === "loading") return;

if (!isValidEmail(email)) {
setStatus("error");
setStatusMessage("Enter a valid email address.");
return;
}

const contactAddress = import.meta.env.VITE_EMAIL;
if (!contactAddress) {
setStatus("error");
setStatusMessage("Newsletter contact email is not configured.");
return;
}

setStatus("loading");
setStatusMessage("Preparing your subscription request...");

try {
// Privacy-first: no backend storage or third-party newsletter API.
// Open the local mail client so the visitor can confirm interest.
window.location.href = buildNewsletterMailto(email, contactAddress);
setStatus("success");
setStatusMessage("Thanks! Confirm the message in your email client.");
setEmail("");
} catch {
setStatus("error");
setStatusMessage("Could not open your email client. Try again.");
}
};

return (
<footer className="relative z-10 bg-gradient-to-br from-slate-50 via-white to-slate-50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 border-t border-slate-200 dark:border-gray-700">
Expand Down Expand Up @@ -135,16 +178,48 @@ const Footer = () => {
{/* Newsletter Signup */}
<div className="mt-6">
<h4 className="text-sm font-semibold text-slate-900 mb-2">Stay Updated</h4>
<div className="flex gap-2">
<input
type="email"
<form className="flex gap-2" onSubmit={handleSubscribe} noValidate>
<input
type="email"
name="email"
value={email}
onChange={(event) => {
setEmail(event.target.value);
if (status !== "idle") {
setStatus("idle");
setStatusMessage("");
}
}}
placeholder="Enter your email"
className="flex-1 px-3 py-2 text-sm border border-slate-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
autoComplete="email"
aria-invalid={status === "error"}
aria-describedby="footer-newsletter-status"
disabled={status === "loading"}
className="flex-1 px-3 py-2 text-sm border border-slate-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-60"
/>
<button className="px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-all duration-300">
Subscribe
<button
type="submit"
disabled={status === "loading"}
className="px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm rounded-lg transition-all duration-300 disabled:opacity-60 disabled:cursor-not-allowed"
>
{status === "loading" ? "Sending..." : "Subscribe"}
</button>
</div>
</form>
{statusMessage && (
<p
id="footer-newsletter-status"
role="status"
className={`mt-2 text-xs ${
status === "error"
? "text-red-500"
: status === "success"
? "text-green-600"
: "text-slate-500"
}`}
>
{statusMessage}
</p>
)}
</div>
</div>
</div>
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/utils/newsletterSignup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";

import {
buildNewsletterMailto,
isValidEmail,
} from "./newsletterSignup";

describe("newsletterSignup", () => {
it("validates common email shapes", () => {
expect(isValidEmail("user@example.com")).toBe(true);
expect(isValidEmail(" user@example.com ")).toBe(true);
expect(isValidEmail("not-an-email")).toBe(false);
expect(isValidEmail("missing@domain")).toBe(false);
expect(isValidEmail("")).toBe(false);
});

it("builds a mailto link without calling remote services", () => {
expect(buildNewsletterMailto("user@example.com", "hello@pdf.example")).toBe(
"mailto:hello@pdf.example?subject=pdfToPng%20newsletter%20interest&body=Please%20keep%20me%20updated%20about%20pdfToPng.%0A%0AEmail%3A%20user%40example.com%0A",
);
});
});
15 changes: 15 additions & 0 deletions frontend/src/utils/newsletterSignup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function isValidEmail(value: string) {
const email = value.trim();
return EMAIL_PATTERN.test(email);
}

export function buildNewsletterMailto(email: string, contactAddress: string) {
const trimmedEmail = email.trim();
const subject = encodeURIComponent("pdfToPng newsletter interest");
const body = encodeURIComponent(
`Please keep me updated about pdfToPng.\n\nEmail: ${trimmedEmail}\n`,
);
return `mailto:${contactAddress}?subject=${subject}&body=${body}`;
}
Loading