send-to-ereader/public/index.html

573 lines
16 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Send to E-Reader — Web Articles to EPUB</title>
<!-- Primary Meta Tags -->
<meta
name="description"
content="Convert web articles to EPUB and email them straight to your e-reader (Kindle, PocketBook, or any device that accepts emailed files). No ads, no tracking, open source."
/>
<meta
name="keywords"
content="send to ereader, send to kindle, send to pocketbook, article to epub, web to ereader, web article epub"
/>
<meta name="author" content="Stefan Atanasov" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://read.atanasov.fi" />
<meta
property="og:title"
content="Send to E-Reader — Web Articles to EPUB"
/>
<meta
property="og:description"
content="Convert web articles to EPUB and email them to your e-reader in one click."
/>
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://read.atanasov.fi" />
<meta
name="twitter:title"
content="Send to E-Reader — Web Articles to EPUB"
/>
<meta
name="twitter:description"
content="Convert web articles to EPUB and email them to your e-reader in one click."
/>
<!-- Canonical URL -->
<link rel="canonical" href="https://read.atanasov.fi" />
<style>
body {
font-family: "Courier New", Courier, monospace;
line-height: 1.4;
max-width: 800px;
margin: 8px auto;
padding: 8px;
background: #f6f6ef;
}
.container {
background: #f6f6ef;
}
h1 {
color: #000;
margin: 0 0 16px 0;
font-size: 16px;
}
.setup-info {
border: 1px solid #828282;
padding: 8px;
margin-bottom: 16px;
font-size: 14px;
}
.setup-info summary {
cursor: pointer;
}
.setup-info summary h2 {
display: inline;
font-size: 15px;
margin: 0;
}
.setup-info code {
background: #fff;
padding: 2px 4px;
}
.setup-info table {
border-collapse: collapse;
margin: 8px 0;
font-size: 13px;
}
.setup-info table th,
.setup-info table td {
text-align: left;
padding: 2px 8px 2px 0;
}
.send-section {
border: 1px solid #828282;
padding: 8px;
margin-bottom: 12px;
}
.send-section h3 {
margin: 0 0 8px 0;
font-size: 14px;
}
.input-group {
margin-bottom: 12px;
}
.input-group label {
display: block;
margin-bottom: 4px;
font-size: 14px;
}
input[type="url"],
input[type="email"] {
width: 100%;
padding: 4px;
border: 1px solid #828282;
font-family: "Courier New", Courier, monospace;
font-size: 14px;
background: #fff;
margin-bottom: 8px;
box-sizing: border-box;
}
input[type="url"]:focus,
input[type="email"]:focus {
outline: none;
border-color: #000;
}
button {
background: #1ce783;
color: #000;
border: 1px solid #828282;
padding: 4px 8px;
font-family: "Courier New", Courier, monospace;
font-size: 14px;
cursor: pointer;
}
button:hover {
background: #00b45a;
}
button:disabled {
background: #cccccc;
cursor: not-allowed;
}
.dropzone {
border: 2px dashed #828282;
background: #fff;
padding: 24px 12px;
text-align: center;
font-size: 14px;
color: #555;
cursor: pointer;
margin-bottom: 8px;
}
.dropzone.dragover {
border-color: #00b45a;
background: #eaffea;
color: #000;
}
.dropzone .picked {
display: block;
margin-top: 6px;
font-size: 13px;
color: #000;
}
.dropzone .picked.empty {
color: #888;
}
.message {
margin-top: 12px;
padding: 8px;
font-size: 14px;
border: 1px solid #828282;
display: none;
}
.success {
background: #dfd;
display: block;
}
.error {
background: #fdd;
display: block;
}
.loading {
background: #ffffd1;
display: block;
}
#credits {
font-size: 12px;
margin-top: 16px;
}
</style>
</head>
<body>
<header>
<h1>Send to E-Reader</h1>
</header>
<main>
<article class="container">
<details class="setup-info">
<summary><h2>guide</h2></summary>
<ol>
<li>
whitelist <code>send@read.atanasov.fi</code> on your device — one-time setup:
<ul>
<li>
<strong>Kindle</strong>: add it in
<a
href="https://www.amazon.com/hz/mycd/myx#/home/settings/payment"
target="_blank"
rel="noopener noreferrer"
>Amazon → Manage Your Content and Devices → Preferences →
Personal Document Settings → Approved Personal Document E-mail List</a
>
before you send the first article.
</li>
<li>
<strong>PocketBook</strong>: no dashboard step needed. Send
the first article — PocketBook will email you a confirmation
with an "add to whitelist" link. Click it once, and every
future article will be delivered automatically.
</li>
</ul>
</li>
<li>enter your e-reader's email below</li>
<li>
<strong>either</strong> paste an article URL and hit Send Article,
<strong>or</strong> drop / pick a file (EPUB, PDF, etc.) and hit
Send File
</li>
</ol>
<h3>e-reader email formats</h3>
<table>
<tr>
<th>device</th>
<th>email pattern</th>
</tr>
<tr>
<td>Amazon Kindle</td>
<td><code>*@kindle.com</code></td>
</tr>
<tr>
<td>PocketBook</td>
<td><code>*@pbsync.com</code></td>
</tr>
</table>
<h3>notes:</h3>
<ul>
<li>
only <code>@kindle.com</code> and <code>@pbsync.com</code>
destination addresses are accepted (subdomains too, e.g.
<code>free.kindle.com</code>). This is to prevent the form being
abused as an open email relay.
</li>
<li>article should be publicly accessible (no paywalls / login required)</li>
<li>uploaded files are passed through as-is — your device decides what formats it accepts (EPUB, PDF, MOBI, etc.)</li>
<li>max file size: 25 MB</li>
<li>articles are converted to EPUB and files are stored on the server only until they're emailed, then deleted immediately</li>
<li>
your e-reader email is saved in your browser
<code>localStorage</code> for future use; nothing is stored on the server
</li>
</ul>
</details>
<section class="send-section">
<div class="input-group">
<label for="readerEmail">E-Reader Email:</label>
<input
type="email"
id="readerEmail"
name="readerEmail"
placeholder="your-device@kindle.com"
autocomplete="email"
required
aria-required="true"
/>
</div>
</section>
<section class="send-section">
<h3>send an article URL</h3>
<form id="urlForm" aria-label="Send article URL">
<div class="input-group">
<label for="articleUrl">Article URL:</label>
<input
type="url"
id="articleUrl"
name="articleUrl"
placeholder="https://example.com/article"
autocomplete="off"
aria-required="true"
/>
</div>
<button type="submit" id="sendUrlButton">Send Article</button>
</form>
</section>
<section class="send-section">
<h3>send a file</h3>
<form id="fileForm" aria-label="Send file">
<div
id="dropzone"
class="dropzone"
tabindex="0"
role="button"
aria-label="Drop a file here, or click to pick one"
>
<div>drop a file here or click to pick one</div>
<div id="picked" class="picked empty">no file selected</div>
</div>
<input
type="file"
id="fileInput"
name="file"
hidden
/>
<button type="submit" id="sendFileButton">Send File</button>
</form>
</section>
<div
id="message"
class="message"
role="alert"
aria-live="polite"
></div>
</article>
</main>
<footer>
<p id="credits">
v 0.2.2 •
<a
href="https://atanasov.fi"
target="_blank"
rel="noopener noreferrer"
>atanasov.fi</a
>
<a
href="https://git.atanasov.fi/atanasov/send-to-ereader"
target="_blank"
rel="noopener noreferrer"
>source</a
>
</p>
</footer>
<script>
const urlInput = document.getElementById("articleUrl");
const emailInput = document.getElementById("readerEmail");
const sendUrlButton = document.getElementById("sendUrlButton");
const sendFileButton = document.getElementById("sendFileButton");
const messageDiv = document.getElementById("message");
const fileInput = document.getElementById("fileInput");
const dropzone = document.getElementById("dropzone");
const picked = document.getElementById("picked");
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
// Migrate any previously-saved key from the old version
const legacy = localStorage.getItem("kindleEmail");
if (legacy && !localStorage.getItem("readerEmail")) {
localStorage.setItem("readerEmail", legacy);
localStorage.removeItem("kindleEmail");
}
if (localStorage.getItem("readerEmail")) {
emailInput.value = localStorage.getItem("readerEmail");
}
function showMessage(text, type) {
messageDiv.textContent = text;
messageDiv.className = `message ${type}`;
}
function getEmailOrShow() {
const email = emailInput.value.trim();
if (!email) {
showMessage("please enter your e-reader email", "error");
return null;
}
localStorage.setItem("readerEmail", email);
return email;
}
async function sendArticle(event) {
event.preventDefault();
const url = urlInput.value.trim();
const email = getEmailOrShow();
if (!email) return;
if (!url) {
showMessage("please enter a valid url", "error");
return;
}
try {
sendUrlButton.disabled = true;
showMessage("processing article...", "loading");
const response = await fetch("/send-article", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, readerEmail: email }),
});
const data = await response.json();
if (data.success) {
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
urlInput.value = "";
} else {
throw new Error(data.error || data.message || "failed to send article");
}
} catch (error) {
showMessage(error.message || "error sending article", "error");
} finally {
sendUrlButton.disabled = false;
}
}
function setPickedFile(file) {
if (!file) {
picked.textContent = "no file selected";
picked.classList.add("empty");
return;
}
if (file.size > MAX_UPLOAD_BYTES) {
showMessage(`file too large (${(file.size / 1024 / 1024).toFixed(1)} MB — max 25 MB)`, "error");
fileInput.value = "";
picked.textContent = "no file selected";
picked.classList.add("empty");
return;
}
picked.textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
picked.classList.remove("empty");
}
async function sendFile(event) {
event.preventDefault();
const email = getEmailOrShow();
if (!email) return;
const file = fileInput.files && fileInput.files[0];
if (!file) {
showMessage("please pick or drop a file first", "error");
return;
}
try {
sendFileButton.disabled = true;
showMessage(`sending "${file.name}"...`, "loading");
const formData = new FormData();
formData.append("file", file);
formData.append("readerEmail", email);
const response = await fetch("/send-file", {
method: "POST",
body: formData,
});
const data = await response.json();
if (data.success) {
showMessage(`success! "${data.title}" sent to your e-reader`, "success");
fileInput.value = "";
setPickedFile(null);
} else {
throw new Error(data.error || data.message || "failed to send file");
}
} catch (error) {
showMessage(error.message || "error sending file", "error");
} finally {
sendFileButton.disabled = false;
}
}
// URL form
document.getElementById("urlForm").addEventListener("submit", sendArticle);
// File form
document.getElementById("fileForm").addEventListener("submit", sendFile);
// Dropzone click → trigger file picker
dropzone.addEventListener("click", () => fileInput.click());
dropzone.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
fileInput.click();
}
});
fileInput.addEventListener("change", () => {
setPickedFile(fileInput.files && fileInput.files[0]);
});
// Drag-and-drop
["dragenter", "dragover"].forEach((ev) => {
dropzone.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add("dragover");
});
});
["dragleave", "drop"].forEach((ev) => {
dropzone.addEventListener(ev, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove("dragover");
});
});
dropzone.addEventListener("drop", (e) => {
const dt = e.dataTransfer;
if (!dt || !dt.files || dt.files.length === 0) return;
// Assign dropped file to the input so form submission works uniformly
fileInput.files = dt.files;
setPickedFile(dt.files[0]);
});
// Prevent the browser from navigating away if a file is dropped outside the dropzone
["dragover", "drop"].forEach((ev) => {
window.addEventListener(ev, (e) => e.preventDefault());
});
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Send to E-Reader",
"applicationCategory": "UtilityApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
},
"creator": {
"@type": "Person",
"name": "Stefan Atanasov",
"url": "https://atanasov.fi"
},
"description": "Convert web articles to EPUB and email them to your e-reader, or upload any file (EPUB, PDF) directly.",
"operatingSystem": "All",
"browserRequirements": "Requires JavaScript"
}
</script>
</body>
</html>