Initial PoC commit

This commit is contained in:
Fenix
2026-01-28 20:39:59 -08:00
commit 94811ca7c1
18 changed files with 1831 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

135
Server/.env Normal file
View File

@@ -0,0 +1,135 @@
# ===========================================
# HTR API Configuration - Enhanced Version
# ===========================================
# --- Model Selection ---
# Change these when switching models:
VLLM_MODEL_PATH=/llm/models/Qwen3-VL-8B-AWQ4
VLLM_MODEL_NAME=Qwen3-VL-8B-AWQ4
# --- vLLM Server Settings ---
VLLM_HOST=0.0.0.0
VLLM_PORT=8001
VLLM_GPU_UTIL=0.90
# Model-specific max context length
VLLM_MAX_MODEL_LEN=2560
# --- PERFORMANCE BOOST: KV Cache Quantization ---
# This is the #1 performance optimization!
# Options: auto (default), fp8, fp8_e4m3, fp8_e5m2, int8
# fp8 = 2x more concurrent users, minimal quality loss (<0.1%)
VLLM_KV_CACHE_DTYPE=fp8
# --- Sampling Parameters (Override model defaults!) ---
# CRITICAL: These override model config.json defaults
# Without these, vLLM uses model-specific defaults which may vary
# Temperature: 0.0 = deterministic, higher = more creative
# For HTR: 0.1 is perfect (consistent but not stuck in loops)
SAMPLING_TEMPERATURE=0.1
# Max tokens to generate per request
SAMPLING_MAX_TOKENS=500
# Top-p sampling (0.0-1.0) - use top 95% of probability mass
SAMPLING_TOP_P=0.95
# Top-k sampling (0 = disabled)
SAMPLING_TOP_K=0
# Presence penalty (0.0 = none, positive = encourage diversity)
SAMPLING_PRESENCE_PENALTY=0.0
# Frequency penalty (0.0 = none, positive = reduce repetition)
SAMPLING_FREQUENCY_PENALTY=0.0
# --- CRITICAL: Stop Sequences ---
# This is WHY your 2B strips newlines in vLLM but not LM Studio!
# Model config.json may have default stop tokens like ["\n", "\n\n"]
# Empty = Override model defaults, preserve ALL newlines
SAMPLING_STOP_SEQUENCES=
# Alternative: Use only model's special tokens (if needed)
# SAMPLING_STOP_SEQUENCES=<|endoftext|>,<|im_end|>
# --- FastAPI Settings ---
VLLM_ENDPOINT=http://127.0.0.1:8001/v1/chat/completions
# --- Database ---
DATABASE_PATH=/home/fenix/htr-api/htr_usage.db
# --- Limits ---
MAX_IMAGE_SIZE=10485760
# --- OpenRouter (for future cloud fallback) ---
# OPENROUTER_API_KEY=your_key_here
# OPENROUTER_ENDPOINT=https://openrouter.ai/api/v1/chat/completions
# OPENROUTER_MODEL=qwen/qwen3-vl-8b-instruct
# ===========================================
# Model-Specific Presets (Copy to active settings above)
# ===========================================
# --- Qwen3-VL-2B-FP8 (Fastest: 161 tok/s) ---
# VLLM_MODEL_PATH=/llm/models/Qwen3-VL-2B-FP8
# VLLM_MODEL_NAME=Qwen3-VL-2B-FP8
# VLLM_MAX_MODEL_LEN=8192
# VLLM_KV_CACHE_DTYPE=fp8
# Model size: 3.5GB | Concurrent: ~35-40 users with fp8 cache
# --- Qwen3-VL-2B-BF16 (Fast: 121.8 tok/s) ---
# VLLM_MODEL_PATH=/llm/models/Qwen3-VL-2B-BF16
# VLLM_MODEL_NAME=Qwen3-VL-2B-BF16
# VLLM_MAX_MODEL_LEN=8192
# VLLM_KV_CACHE_DTYPE=fp8
# Model size: 4.3GB | Concurrent: ~35-40 users with fp8 cache
# --- Qwen3-VL-8B-AWQ4 (Balanced: 88 tok/s, 99.17% accuracy) ---
# VLLM_MODEL_PATH=/llm/models/qwen3-vl-8b-awq4
# VLLM_MODEL_NAME=qwen3-vl-8b-awq4
# VLLM_MAX_MODEL_LEN=8192
# VLLM_KV_CACHE_DTYPE=fp8
# Model size: 7.5GB | Concurrent: ~25-30 users with fp8 cache
# --- Qwen3-VL-8B-AWQ8 (Good: 61 tok/s) ---
# VLLM_MODEL_PATH=/llm/models/Qwen3-VL-8B-AWQ-8bit
# VLLM_MODEL_NAME=Qwen3-VL-8B-AWQ-8bit
# VLLM_MAX_MODEL_LEN=8192
# VLLM_KV_CACHE_DTYPE=fp8
# Model size: 10.8GB | Concurrent: ~20-25 users with fp8 cache
# --- Qwen3-VL-8B-BF16 (Highest quality: 42.1 tok/s) ---
# VLLM_MODEL_PATH=/llm/models/qwen3-vl-8b-bf16
# VLLM_MODEL_NAME=qwen3-vl-8b-bf16
# VLLM_MAX_MODEL_LEN=8192
# VLLM_KV_CACHE_DTYPE=fp8
# Model size: 17.5GB | Concurrent: ~15-20 users with fp8 cache
# ===========================================
# Performance Impact Summary
# ===========================================
# KV_CACHE_DTYPE Impact on Concurrent Capacity (A6000 48GB):
# ┌─────────────┬──────────────┬─────────────┬────────────┐
# │ Model │ auto (16bit) │ fp8 (8bit) │ Speedup │
# ├─────────────┼──────────────┼─────────────┼────────────┤
# │ 2B-FP8 │ ~18-20 users │ ~35-40 users│ 2.0x │
# │ 2B-BF16 │ ~16-18 users │ ~35-40 users│ 2.2x │
# │ 8B-AWQ4 │ ~14-16 users │ ~25-30 users│ 1.9x │
# │ 8B-AWQ8 │ ~12-14 users │ ~20-25 users│ 1.8x │
# │ 8B-BF16 │ ~8-10 users │ ~15-20 users│ 2.0x │
# └─────────────┴──────────────┴─────────────┴────────────┘
# Accuracy impact: <0.1% drop with fp8 cache (negligible)
# Recommendation: ALWAYS use fp8 for production
# Temperature Impact:
# 0.0 = Fully deterministic (can cause loops)
# 0.1 = Near-deterministic, robust (RECOMMENDED for HTR)
# 0.3 = Slight variation
# 0.7+ = Too creative for transcription
# Stop Sequences Impact:
# WITH newline stops: 97.10% char accuracy (paragraphs merged)
# WITHOUT newline stops: 99.17% char accuracy (preserves formatting)

36
Server/config.py Normal file
View File

@@ -0,0 +1,36 @@
"""
Configuration settings for HTR API on Phoenix
"""
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
jwt_secret: str = "JWT_SECRET=73936f5c69eb84f013a531a35ffae040855cd6c7891ed1bb0872780fe8c56274"
jwt_algorithm: str = "HS256"
# vLLM Configuration - matches your local setup
vllm_endpoint: str = "http://127.0.0.1:8001/v1/chat/completions"
vllm_model: str = "qwen3-vl" # served-model-name from vLLM
# LLM Configuration (OpenRouter or local vLLM)
llm_endpoint: str = "http://127.0.0.1:8001/v1/chat/completions"
llm_model: str = "qwen3-vl"
openrouter_api_key: Optional[str] = None
# Database
database_path: str = "/home/fenix/htr-api/htr_usage.db"
# Limits
max_image_size: int = 10 * 1024 * 1024
# WordPress
upgrade_url: str = "https://prometheuscafe.com/plans"
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

129
Server/database.py Normal file
View File

@@ -0,0 +1,129 @@
"""
SQLite database for tracking daily usage
"""
import sqlite3
from datetime import datetime, timezone, timedelta
from config import settings
import threading
_local = threading.local()
def get_db():
"""Get thread-local database connection"""
if not hasattr(_local, "connection"):
_local.connection = sqlite3.connect(
settings.database_path,
check_same_thread=False
)
_local.connection.row_factory = sqlite3.Row
return _local.connection
def init_db():
"""Initialize database schema"""
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
date TEXT NOT NULL,
count INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date)
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_usage_user_date
ON usage(user_id, date)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS extraction_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
tier TEXT,
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
success INTEGER DEFAULT 1,
processing_time_ms INTEGER
)
""")
conn.commit()
cleanup_old_records()
def get_today_utc() -> str:
"""Get today's date in UTC as string"""
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def get_usage_today(user_id: str) -> int:
"""Get number of extractions used today for a user"""
conn = get_db()
cursor = conn.cursor()
today = get_today_utc()
cursor.execute(
"SELECT count FROM usage WHERE user_id = ? AND date = ?",
(user_id, today)
)
row = cursor.fetchone()
return row["count"] if row else 0
def increment_usage(user_id: str) -> int:
"""Increment usage count for today. Returns new count."""
conn = get_db()
cursor = conn.cursor()
today = get_today_utc()
now = datetime.now(timezone.utc).isoformat()
cursor.execute("""
INSERT INTO usage (user_id, date, count, updated_at)
VALUES (?, ?, 1, ?)
ON CONFLICT(user_id, date)
DO UPDATE SET
count = count + 1,
updated_at = ?
""", (user_id, today, now, now))
conn.commit()
return get_usage_today(user_id)
def cleanup_old_records(days_to_keep: int = 30):
"""Remove usage records older than specified days"""
conn = get_db()
cursor = conn.cursor()
cutoff = (datetime.now(timezone.utc) - timedelta(days=days_to_keep)).strftime("%Y-%m-%d")
cursor.execute("DELETE FROM usage WHERE date < ?", (cutoff,))
cursor.execute("DELETE FROM extraction_log WHERE date(timestamp) < ?", (cutoff,))
conn.commit()
def get_usage_stats(user_id: str, days: int = 7) -> list:
"""Get usage history for a user"""
conn = get_db()
cursor = conn.cursor()
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
cursor.execute("""
SELECT date, count
FROM usage
WHERE user_id = ? AND date >= ?
ORDER BY date DESC
""", (user_id, cutoff))
return [dict(row) for row in cursor.fetchall()]

29
Server/htr-api.service Normal file
View File

@@ -0,0 +1,29 @@
# ===========================================
# HTR FastAPI Service
# Reads configuration from /home/fenix/htr-api/.env
# ===========================================
# BACKUP COPY - After editing, run:
# sudo cp ~/htr-api/htr-api.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl restart htr-api
# ===========================================
[Unit]
Description=HTR FastAPI Service
After=network.target vllm-htr.service
Wants=vllm-htr.service
[Service]
Type=simple
User=fenix
WorkingDirectory=/home/fenix/htr-api
# Load environment variables from .env
EnvironmentFile=/home/fenix/htr-api/.env
ExecStart=/home/fenix/htr-api/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

139
Server/main.py Normal file
View File

@@ -0,0 +1,139 @@
"""
HTR API - Simplified Local vLLM Version
Reads configuration from environment variables (.env file)
"""
import os
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx
import base64
# ===========================================
# Configuration from Environment
# ===========================================
VLLM_ENDPOINT = os.getenv("VLLM_ENDPOINT", "http://127.0.0.1:8001/v1/chat/completions")
VLLM_MODEL_NAME = os.getenv("VLLM_MODEL_NAME")
MAX_IMAGE_SIZE = int(os.getenv("MAX_IMAGE_SIZE", 10 * 1024 * 1024))
# Fail fast if critical env vars aren't set
if not VLLM_MODEL_NAME:
raise RuntimeError("VLLM_MODEL_NAME not set! Check .env file and htr-api.service EnvironmentFile path.")
# ===========================================
# HTR Prompt
# ===========================================
HTR_PROMPT = """You are a handwriting transcription assistant.
Your task is to transcribe the handwritten pages into plain text paragraphs, preserving the original wording exactly.
Rules:
- Do not change, correct, or improve any spelling, grammar, punctuation, or sentence structure.
- Preserve paragraph breaks as they appear in the handwriting. Start a new paragraph only where the handwriting clearly starts a new paragraph that has a blank line. Do NOT insert line breaks on every sentence because the sentence is not fitting in the journal image. Just the paragraph break with the clear blank line between paragraphs.
- Also, insert 2 paragraphs breaks to clearly indicate a new paragraph.
- If you are unsure of a word or cannot read it, write ??? in its place.
- Do not add, remove, or rearrange any sentences.
Only output the transcribed text following these rules."""
# ===========================================
# FastAPI App
# ===========================================
app = FastAPI(
title="HTR API",
description="Handwriting Text Recognition API for Prometheus Cafe",
version="2.0.0"
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ===========================================
# Request/Response Models
# ===========================================
class ExtractRequest(BaseModel):
image: str # Base64 encoded image
class ExtractResponse(BaseModel):
text: str
# ===========================================
# Endpoints
# ===========================================
@app.get("/api/health")
async def health_check():
"""Health check endpoint"""
return {
"status": "healthy",
"backend": "local-vllm",
"model": VLLM_MODEL_NAME,
"endpoint": VLLM_ENDPOINT
}
@app.post("/api/extract", response_model=ExtractResponse)
async def extract_text(request: ExtractRequest):
"""Extract text from handwritten image using local vLLM."""
# Validate and clean image data
try:
image_data = request.image
if "," in image_data:
image_data = image_data.split(",")[1]
decoded = base64.b64decode(image_data)
if len(decoded) > MAX_IMAGE_SIZE:
raise HTTPException(
status_code=400,
detail=f"Image too large. Max size: {MAX_IMAGE_SIZE // 1024 // 1024}MB"
)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid image data: {str(e)}")
# Call local vLLM
try:
async with httpx.AsyncClient(timeout=180.0) as client:
response = await client.post(
VLLM_ENDPOINT,
headers={"Content-Type": "application/json"},
json={
"model": VLLM_MODEL_NAME,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": HTR_PROMPT},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_data}"
}
}
]
}
],
"max_tokens": 2048,
"temperature": 0.1,
}
)
if response.status_code != 200:
raise HTTPException(
status_code=500,
detail=f"vLLM error {response.status_code}: {response.text}"
)
result = response.json()
extracted_text = result["choices"][0]["message"]["content"]
return ExtractResponse(text=extracted_text)
except httpx.TimeoutException:
raise HTTPException(status_code=504, detail="vLLM request timed out")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Extraction failed: {str(e)}")

36
Server/vllm-htr.service Normal file
View File

@@ -0,0 +1,36 @@
# ===========================================
# vLLM HTR Service
# Reads configuration from /home/fenix/htr-api/.env
# ===========================================
# BACKUP COPY - After editing, run:
# sudo cp ~/htr-api/vllm-htr.service /etc/systemd/system/
# sudo systemctl daemon-reload && sudo systemctl restart vllm-htr
# ===========================================
[Unit]
Description=vLLM Server for HTR
After=network.target
[Service]
Type=simple
User=fenix
WorkingDirectory=/llm
# Load environment variables from .env
EnvironmentFile=/home/fenix/htr-api/.env
# Use environment variables in ExecStart
ExecStart=/llm/env/bin/vllm serve ${VLLM_MODEL_PATH} \
--host ${VLLM_HOST} \
--port ${VLLM_PORT} \
--max-model-len ${VLLM_MAX_MODEL_LEN} \
--gpu-memory-utilization ${VLLM_GPU_UTIL} \
--trust-remote-code \
--served-model-name ${VLLM_MODEL_NAME}
Restart=on-failure
RestartSec=10
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target

88
about.html Normal file
View File

@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>About This App Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>About This App</h1>
<p class="text-muted">
Pen2Post turns your handwritten journal pages into editable text you can refine, post, or publish.
</p>
<h2>Why Pen2Post exists</h2>
<p>
Pen2Post is for people who think best with pen and paper, but still need their words in digital form.
It lets you stay in the quiet, distractionfree space of a notebook and only go digital when you are ready.
</p>
<p>
I built this tool first for myself. I love writing and being creative, but chronic illness and limited energy
mean I need tools that help me capture my own voice instead of asking AI to write for me.
Pen2Post lets me fill a journal in peace, then bring those words into my computer later still sounding like me.
</p>
<h2>The problem it solves</h2>
<p>
Typing directly into a computer can pull you out of creative flow: spellcheck and grammar tools nag you,
notifications pop up, and the web is always one click away from a rabbit hole.
A notebook has none of that no alerts, no feeds, no tabs just you and the page.
</p>
<p>
The hard part is getting those handwritten pages into digital form without retyping everything by hand.
That is the friction Pen2Post removes.
</p>
<h2>What Pen2Post does</h2>
<p>
Pen2Post works like a digital court reporter for your notebook: it takes a photo of your page,
reads your handwriting, and turns it into text you can edit and share.
</p>
<ul class="section-list">
<li>Write freely in your journal like you always do.</li>
<li>Open Pen2Post and take a photo of a page or upload an existing photo.</li>
<li>Tap <strong>Extract Text</strong> and wait a short moment while the app reads your handwriting.</li>
<li>Fix any misread words, then share the text into your favorite apps or storage.</li>
</ul>
<p>
The app is intentionally simple: one main screen, a big text area, and a clear path from paper to post.
It is designed for real handwriting on real pages, not labperfect samples.
</p>
<h2>What it does not do</h2>
<p>
Pen2Post is not a writing coach or an autorewrite engine.
It does not try to correct your spelling, fix your grammar, or change your style.
</p>
<p>
The goal is to preserve your original voice. If the app cannot clearly read a word,
it may misinterpret it and you simply fix those spots as you shape the draft to match your journal.
</p>
<h2>Part of Prometheus Cafe</h2>
<p>
Pen2Post is part of Prometheus Cafe, a project focused on helping aspiring entrepreneurs and creatives
get their ideas out of their heads and into the world. This app is one small tool in that larger mission:
reduce friction, respect your energy, and keep you connected to your own words.
</p>
<p class="text-muted">
Ready to go from handwriting to posts? Snap a page, extract the text, and start shaping your next draft.
</p>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>

102
credits.html Normal file
View File

@@ -0,0 +1,102 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Credits &amp; Rewards Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>Credits &amp; Rewards</h1>
<p class="text-muted">
One credit equals one page processed. Use this screen to see your balance, daily gifts, and ways to earn or buy more.
</p>
<section>
<h2>Your credits</h2>
<div class="card">
<p>
<strong>Current balance:</strong>
<span id="credit-balance"></span> credits
</p>
<p>
<strong>Daily gift:</strong>
<span id="daily-gift-status">Available once per day.</span>
</p>
<p>
<strong>Streak bonus:</strong>
<span id="streak-status">Keep a 5day streak to earn bonus credits.</span>
</p>
</div>
</section>
<section>
<h2>Get more credits</h2>
<div class="card card-info">
<h3>Earn free credits</h3>
<p>
Watch a short rewarded ad to earn a small number of extra credits each day.
</p>
<button type="button" class="btn-primary" onclick="onWatchAd()">
Watch ad to earn credits
</button>
<p class="text-muted">
Limited number of rewarded ads per day. Availability may vary by region and network.
</p>
</div>
<div class="card card-warn">
<h3>Buy credits</h3>
<p>
When you need more pages for older notebooks or big projects you can buy a onetime credit pack.
No subscriptions or automatic renewals.
</p>
<div class="credits-pack-list">
<button type="button" class="btn-outline" onclick="onBuyPack('note-taker')">
<strong>Note Taker</strong> 25 credits
</button>
<button type="button" class="btn-outline" onclick="onBuyPack('writer')">
<strong>Writer</strong> 150 credits
</button>
<button type="button" class="btn-outline" onclick="onBuyPack('archivist')">
<strong>Archivist</strong> 500 credits
</button>
</div>
<p class="text-muted">
All purchases are handled securely by the app store on your device.
Prices are shown there in your local currency.
</p>
</div>
</section>
<section>
<h2>How credits and rewards work</h2>
<ul class="section-list">
<li>Every processed page uses one credit.</li>
<li>You get a small daily gift so you can keep a regular journaling habit.</li>
<li>Using the app several days in a row can unlock streak bonuses.</li>
<li>You can mix free credits, adearned credits, and paid bundles.</li>
</ul>
</section>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>

73
feedback.html Normal file
View File

@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Feedback Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>Feedback</h1>
<p class="text-muted">
Im a solo developer. I cant reply to everyone, but I do read all feedback and use it to improve Pen2Post.
</p>
<section>
<h2>Send a quick note</h2>
<form class="feedback-form" onsubmit="submitFeedback(); return false;">
<label for="feedbackMessage">
Your message
</label>
<textarea
id="feedbackMessage"
class="feedback-textarea"
placeholder="Tell me whats working, whats confusing, or what youd like to see next…"
></textarea>
<label for="feedbackEmail">
Email (optional)
</label>
<input
id="feedbackEmail"
type="email"
class="feedback-email-input"
placeholder="Add an email if youd like a reply (not required)."
>
<div class="feedback-actions">
<button type="submit" class="btn-primary">
Send feedback
</button>
<button type="button" class="btn-secondary" onclick="goBackToApp()">
Cancel
</button>
</div>
</form>
</section>
<section>
<h2>What happens next</h2>
<p>
Feedback is collected so I can see which parts of the app are working well and where people get stuck.
</p>
<p class="text-muted">
I may use anonymized feedback to guide future updates, but I wont share your message or email publicly.
</p>
</section>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>

149
help.html Normal file
View File

@@ -0,0 +1,149 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Help &amp; Tips Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>Help &amp; Tips</h1>
<p class="text-muted">
Answers to common questions, plus simple ways to get better results from your handwritten pages.
</p>
<!-- Quick questions -->
<h2>Quick questions</h2>
<h3>What is Pen2Post for?</h3>
<p>
Pen2Post turns photos of your handwritten journal pages into editable text,
while keeping your original wording and structure.
</p>
<h3>Will it fix my spelling or grammar?</h3>
<p>
No. Pen2Post is designed to copy what is on the page, not rewrite it.
You stay in control of the meaning and final edits.
</p>
<h3>Can I process multiple pages at once?</h3>
<p>
You process one page at a time. Each new extraction is added to the text area,
so your pages stay in the order you choose.
</p>
<h3>How long does it take?</h3>
<p>
Most pages take under a minute, depending on how long the page is and how busy the system is.
</p>
<h3>How many pages can I do per day?</h3>
<p>
Your daily limit depends on your credits and plan.
The app shows your current balance and will let you know when you are running low.
</p>
<!-- Tips -->
<h2>Tips for better results</h2>
<h3>Write better</h3>
<ul class="section-list">
<li>Write a little slower and slightly larger than your tiniest handwriting.</li>
<li>Avoid extremely loose cursive if it tends to run together.</li>
<li>If needed, try printing instead of cursive for tricky words or names.</li>
</ul>
<h3>Snap better</h3>
<ul class="section-list">
<li>Use good, even lighting and avoid strong shadows across the page.</li>
<li>Hold your phone steady and keep the page flat in the frame.</li>
<li>Use dark ink on light paper for strong contrast.</li>
<li>Fill most of the camera view with the page, but keep all edges visible.</li>
</ul>
<h3>Share better (backup options)</h3>
<ul class="section-list">
<li>
If recognition struggles with your writing, you can read your journal aloud using your
keyboards microphone and let builtin speechtotext handle the transcription.
</li>
<li>
You can also add quick voice notes or comments about a Pen2Post extract and clean
everything up later in your main writing app.
</li>
</ul>
<!-- Troubleshooting -->
<h2>Troubleshooting</h2>
<h3>The text has lots of mistakes</h3>
<ul class="section-list">
<li>Check lighting and make sure there are no heavy shadows or glare on the page.</li>
<li>Try rewriting a short sample more clearly and testing again.</li>
<li>Make sure the page fills most of the photo and is in focus.</li>
</ul>
<h3>Nothing seems to happen after I tap Extract</h3>
<ul class="section-list">
<li>Give it a bit of time; some pages can take close to a minute when the system is busy.</li>
<li>Check your internet connection and try again if the connection was weak.</li>
<li>If the app repeatedly fails, close and reopen it, then try another page.</li>
</ul>
<h3>My credits or daily limit look wrong</h3>
<ul class="section-list">
<li>Wait a few seconds and check again; sometimes it takes a moment to refresh.</li>
<li>If you just watched an ad or bought credits, reopen the app to trigger a refresh.</li>
<li>If the problem continues, note roughly when it happened so you can mention it in feedback.</li>
</ul>
<!-- Privacy -->
<h2>Privacy and your pages</h2>
<p>
Your handwritten pages and extracted text are only processed for as long as needed to create the result.
Once the text is shown in the app, it lives on your device.
</p>
<p>
If you clear the text or close the app without copying or sharing it, it cannot be recovered.
Before you clear anything, make sure you have copied or shared the text into a safe place.
</p>
<!-- Accuracy & refunds -->
<h2>Accuracy and refunds</h2>
<p>
Handwriting recognition is never perfect. Results depend on your handwriting, lighting,
and how clear the photo is. Pen2Post is meant to give you a strong rough draft, not a final,
errorfree document.
</p>
<p>
To protect your privacy, the app does not store your writing on its servers or keep detailed
records of individual pages. Because of this, pages cannot be reviewed or “reprocessed” on request.
</p>
<p>
Individual credits cannot be refunded based on dissatisfaction with output quality.
Please use your free daily pages and any bonus test credits to make sure Pen2Post works well enough
with your handwriting before buying larger bundles.
</p>
<p class="text-muted">
If you still have questions after reading this page, you can send feedback from the Feedback section in the menu.
While replies are not guaranteed, your notes help improve the app over time.
</p>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>

67
index.html Normal file
View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Journal</title>
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<!-- Top Right Floating Buttons -->
<div class="top-buttons">
<button id="shareBtn" class="floating-button" aria-label="Share">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="18" cy="5" r="3"></circle>
<circle cx="6" cy="12" r="3"></circle>
<circle cx="18" cy="19" r="3"></circle>
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line>
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line>
</svg>
</button>
<button id="menuBtn" class="floating-button" aria-label="Menu">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
</div>
<!-- Bottom Left Floating Buttons -->
<div class="bottom-buttons">
<button id="cameraBtn" class="floating-button" aria-label="Extract from Image">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path>
<circle cx="12" cy="13" r="4"></circle>
</svg>
</button>
<button id="clearBtn" class="floating-button" aria-label="Clear">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 5H9l-7 7 7 7h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Z"></path>
<line x1="18" x2="12" y1="9" y2="15"></line>
<line x1="12" x2="18" y1="9" y2="15"></line>
</svg>
</button>
<button id="saveBtn" class="floating-button" aria-label="Save">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
</button>
</div>
<!-- Full Screen Textarea -->
<textarea id="noteTextarea" class="notes-textarea" placeholder="Start typing..."></textarea>
</div>
<!-- Hidden input: system chooser (camera/photos/files) -->
<input type="file" id="fileInput" accept="image/*" style="display:none">
<!-- Toast Container -->
<div id="toastContainer" class="toast-container"></div>
<script src="journal.js"></script>
</body>
</html>

266
journal.css Normal file
View File

@@ -0,0 +1,266 @@
.notes-container {
height: 100vh;
width: 100%;
background-color: #fffbf5;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.top-buttons {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.5rem;
z-index: 10;
}
.bottom-buttons {
position: absolute;
bottom: 1rem;
left: 1rem;
display: flex;
gap: 0.5rem;
z-index: 10;
}
.floating-button {
width: 2.75rem;
height: 2.75rem;
border-radius: 9999px;
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: transform 0.15s ease;
}
.floating-button:active {
transform: scale(0.95);
}
.floating-button svg {
width: 1.25rem;
height: 1.25rem;
color: #007AFF;
}
.side-menu {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
display: none;
align-items: flex-start;
justify-content: flex-end;
z-index: 20;
}
.side-menu.open {
display: flex;
}
.side-menu-content {
width: 260px;
max-width: 80%;
background: #fffbf5;
box-shadow: -8px 0 20px rgba(0, 0, 0, 0.1);
padding: 1.25rem 1rem 1.5rem 1.25rem;
border-radius: 16px 0 0 16px;
margin-top: 3.5rem;
margin-right: 0.75rem;
}
.side-menu-close {
border: none;
background: transparent;
font-size: 1.4rem;
cursor: pointer;
margin-left: auto;
display: block;
}
.side-menu-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
}
.side-menu-nav a {
text-decoration: none;
color: #1f2933;
font-size: 0.95rem;
padding: 0.45rem 0.25rem;
border-radius: 6px;
}
.side-menu-nav a:hover {
background: rgba(0, 122, 255, 0.06);
}
.notes-textarea {
flex: 1;
width: 100%;
padding: 1.5rem;
background-color: transparent;
resize: none;
outline: none;
border: none;
font-size: 17px;
line-height: 1.625;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.notes-textarea::placeholder {
color: #9ca3af;
}
/* Toast notifications */
.toast-container {
position: fixed;
top: 5rem;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
background-color: rgba(0, 0, 0, 0.85);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background-color: rgba(52, 199, 89, 0.95);
}
.toast-error {
background-color: rgba(255, 59, 48, 0.95);
}
.toast-info {
background-color: rgba(0, 122, 255, 0.95);
}
.page-main {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
.card {
border-radius: 12px;
background: #fffaf2;
border: 1px solid #e5d6bf;
padding: 1rem;
margin-top: 0.5rem;
}
.card-info {
background: #f0fbff;
border-color: #cbe4ff;
}
.card-warn {
background: #fff8f2;
border-color: #f3d3b2;
}
.btn-primary {
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: none;
background: #007aff;
color: #fff;
font-size: 0.95rem;
cursor: pointer;
}
.btn-outline {
padding: 0.6rem 1.2rem;
border-radius: 8px;
border: 1px solid #e5d6bf;
background: #ffffff;
text-align: left;
cursor: pointer;
}
.text-muted {
color: #555;
font-size: 0.95rem;
}
.feedback-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 1rem;
}
.feedback-textarea {
width: 100%;
min-height: 140px;
padding: 0.75rem 1rem;
border-radius: 8px;
border: 1px solid #e5d6bf;
background: #fffaf2;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 0.95rem;
resize: vertical;
}
.feedback-textarea::placeholder {
color: #9ca3af;
}
.feedback-email-input {
width: 100%;
padding: 0.6rem 0.9rem;
border-radius: 8px;
border: 1px solid #e5d6bf;
background: #ffffff;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 0.95rem;
}
.feedback-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.25rem;
}
.btn-secondary {
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: 1px solid #e5d6bf;
background: #ffffff;
color: #333;
font-size: 0.95rem;
cursor: pointer;
}

315
journal.js Normal file
View File

@@ -0,0 +1,315 @@
// Journal UI (Figma skin) + Prometheus HTR extraction wiring
// - Camera button => opens system image chooser, posts to /api/extract
// - Clear => clears text + resets page counter
// - Save => saves to localStorage
// - Share => native share or clipboard
//
// Assumes your server accepts JSON: { image: "<base64>" } at POST /api/extract
// (matches your original working prototype).
// -------------------------
// Config
// -------------------------
const API_BASE = window.location.origin;
const MAX_IMAGE_DIMENSION = 1600;
// -------------------------
// DOM
// -------------------------
const noteTextarea = document.getElementById('noteTextarea');
const shareBtn = document.getElementById('shareBtn');
const menuBtn = document.getElementById('menuBtn');
const cameraBtn = document.getElementById('cameraBtn');
const clearBtn = document.getElementById('clearBtn');
const saveBtn = document.getElementById('saveBtn');
const toastContainer = document.getElementById('toastContainer');
const fileInput = document.getElementById('fileInput');
// -------------------------
// State
// -------------------------
let pageCount = 0;
let isProcessing = false;
// -------------------------
// Toasts
// -------------------------
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toastContainer.appendChild(toast);
// Trigger animation
setTimeout(() => toast.classList.add('show'), 10);
// Remove toast after 3 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// -------------------------
// Helpers
// -------------------------
function setBusy(busy) {
isProcessing = busy;
cameraBtn.disabled = busy;
clearBtn.disabled = busy;
saveBtn.disabled = busy;
shareBtn.disabled = busy;
cameraBtn.style.opacity = busy ? '0.6' : '';
}
function appendExtractedText(newText) {
const text = (newText || '').trim();
if (!text) {
showToast('No text returned', 'info');
return;
}
pageCount += 1;
const current = noteTextarea.value.trim();
if (current) {
noteTextarea.value = current + `\n\n--- Page ${pageCount} ---\n\n` + text;
} else {
noteTextarea.value = text;
}
// Keep cursor at end
noteTextarea.scrollTop = noteTextarea.scrollHeight;
}
// Resize image -> base64 (no data: prefix)
function resizeImageToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
let { width, height } = img;
if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) {
if (width > height) {
height = Math.round((height * MAX_IMAGE_DIMENSION) / width);
width = MAX_IMAGE_DIMENSION;
} else {
width = Math.round((width * MAX_IMAGE_DIMENSION) / height);
height = MAX_IMAGE_DIMENSION;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
const base64 = dataUrl.split(',')[1];
resolve(base64);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = e.target.result;
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsDataURL(file);
});
}
// -------------------------
// Extraction pipeline
// -------------------------
async function extractFromImageFile(file) {
if (isProcessing) return;
setBusy(true);
showToast('Extracting…', 'info');
try {
const resizedBase64 = await resizeImageToBase64(file);
const response = await fetch(`${API_BASE}/api/extract`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: resizedBase64 })
});
if (!response.ok) {
let msg = `HTTP ${response.status}`;
try {
const err = await response.json();
msg = err?.detail?.message || err?.detail || err?.message || msg;
} catch (_) {
// ignore json parse
try { msg = await response.text(); } catch (_) {}
}
throw new Error(msg || 'Extraction failed');
}
const result = await response.json();
appendExtractedText(result.text);
showToast(`Page extracted (${pageCount})`, 'success');
} catch (err) {
console.error(err);
showToast(err?.message ? `Extract failed: ${err.message}` : 'Extract failed', 'error');
} finally {
setBusy(false);
}
}
// -------------------------
// Button wiring
// -------------------------
// Share
shareBtn.addEventListener('click', async () => {
const text = noteTextarea.value.trim();
if (!text) {
showToast('Nothing to share', 'error');
return;
}
if (navigator.share) {
try {
await navigator.share({ text });
// no toast needed; iOS share sheet is the feedback
} catch (err) {
// user cancelled is fine; only show for real failures
if (err?.name !== 'AbortError') showToast('Failed to share', 'error');
}
return;
}
try {
await navigator.clipboard.writeText(text);
showToast('Copied to clipboard', 'success');
} catch (err) {
showToast('Clipboard blocked (long-press to copy)', 'error');
}
});
// Menu (Hamburger Menu - potential future addition skin-picker?)
menuBtn.addEventListener('click', () => {
window.location.href = 'menu.html';
});
// Extract from image
cameraBtn.addEventListener('click', () => {
if (isProcessing) return;
// Allow selecting same photo twice
fileInput.value = '';
fileInput.click();
});
fileInput.addEventListener('change', async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// reset input so selecting the same file again works
e.target.value = '';
await extractFromImageFile(file);
});
// Clear
clearBtn.addEventListener('click', () => {
const text = noteTextarea.value.trim();
if (!text) {
// Nothing to clear; no need to warn.
return;
}
const ok = window.confirm(
'Clear all text?\n\nThis cannot be undone. Make sure you have saved or shared anything important first.'
);
if (!ok) {
return;
}
noteTextarea.value = '';
pageCount = 0;
localStorage.removeItem('savedNote');
localStorage.removeItem('savedNotePageCount');
showToast('Cleared', 'success');
});
// Save - localStorage
saveBtn.addEventListener('click', () => {
const text = noteTextarea.value.trim();
if (!text) {
showToast('Nothing to save', 'error');
return;
}
localStorage.setItem('savedNote', text);
localStorage.setItem('savedNotePageCount', String(pageCount));
showToast('Saved', 'success');
});
// Load saved note on page load
window.addEventListener('DOMContentLoaded', () => {
const savedNote = localStorage.getItem('savedNote');
const savedCount = parseInt(localStorage.getItem('savedNotePageCount') || '0', 10);
if (savedNote) {
noteTextarea.value = savedNote;
pageCount = Number.isFinite(savedCount) ? savedCount : 0;
showToast('Loaded saved note', 'info');
}
});
// Navigation back to main app
function goBackToApp() {
window.location.href = "index.html"; // change if your main file has a different name/path
}
// Credits & Rewards actions
function onWatchAd() {
// TODO: integrate with your AdMob rewarded flow
// Example: AdMob.showRewardedAd().then(refreshCredits);
console.log("Watch-ad clicked call AdMob flow here.");
}
function onBuyPack(packId) {
// TODO: integrate with your in-app purchase / RevenueCat / store kit flow
console.log("Buy-pack clicked:", packId);
// Example: Purchases.purchasePackage(packId);
}
// Feedback handling
function submitFeedback() {
const messageEl = document.getElementById('feedbackMessage');
const emailEl = document.getElementById('feedbackEmail');
if (!messageEl) return;
const message = messageEl.value.trim();
const email = emailEl ? emailEl.value.trim() : '';
if (!message) {
showToast('Please add a short message before sending.', 'error');
return;
}
// For MVP: open mail client with a prefilled email.
const to = 'support@prometheus.cafe'; // change to your actual inbox
const subject = encodeURIComponent('Pen2Post feedback');
const bodyLines = [
message,
'',
email ? `Reply email (optional): ${email}` : '',
];
const body = encodeURIComponent(bodyLines.join('\n'));
window.location.href = `mailto:${to}?subject=${subject}&body=${body}`;
showToast('Opening your mail app to send feedback…', 'info');
}

137
legal.html Normal file
View File

@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Legal Stuff Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>Legal Stuff</h1>
<p class="text-muted">
A plainlanguage summary of how Pen2Post treats your data and what you can expect when you use the app.
</p>
<!-- Privacy Policy -->
<section>
<h2>Privacy Policy</h2>
<h3>What data Pen2Post uses</h3>
<p>
Pen2Post processes images of your handwritten pages to turn them into text.
The images are sent securely to the server, processed, and then discarded once the text is created.
</p>
<p>
The extracted text is shown only inside the app on your device.
It is not stored on the server after processing.
</p>
<h3>What is not stored</h3>
<p>
Pen2Post does not keep a copy of your pages or your text on its servers after the result is shown.
It also does not maintain detailed perpage history of what you wrote.
</p>
<p>
This means your writing stays private, but it also means that if you clear the text or close the app
without copying or sharing it, it cannot be recovered later.
</p>
<h3>How you should save your work</h3>
<p>
Before you tap Clear or leave the app, make sure you have copied or shared your text
to a safe place such as a notes app, email, or cloud drive.
</p>
<p>
Pen2Post does not offer its own cloud storage or document history.
Saving and backing up your work is handled by the other apps and services you choose.
</p>
<h3>Other information</h3>
<p>
Pen2Post may store basic account and usage information needed to manage your credits,
daily gifts, and streak bonuses, but not the content of your writing.
</p>
<p>
If you use rewarded ads or make purchases, the ad networks and app stores may collect their own data
under their own privacy policies.
</p>
</section>
<!-- Terms of Use -->
<section>
<h2>Terms of Use</h2>
<h3>What Pen2Post offers</h3>
<p>
Pen2Post is a handwriting recognition tool that helps you turn handwritten pages into editable text.
It is meant to give you a strong rough draft, not a perfect, errorfree document.
</p>
<h3>Accuracy and limitations</h3>
<p>
Handwriting recognition is never perfect. Results depend on your handwriting style, lighting,
and how clear the photo is.
</p>
<p>
You are responsible for reviewing and editing the text before you share it or publish it.
Pen2Post does not guarantee accuracy and is not responsible for errors in the output.
</p>
<h3>Credits, refunds, and billing</h3>
<p>
The app uses credits. One credit usually equals one processed page.
You can get free daily credits, earn some through rewarded ads, and buy bundles when you need more.
</p>
<p>
To protect your privacy, Pen2Post does not store the content of your pages or keep detailed perpage history.
Because of this, individual pages cannot be reviewed or reprocessed on request, and individual credits
cannot be refunded based on dissatisfaction with output quality.
</p>
<p>
All payments are handled by the Apple App Store or Google Play Store.
Pen2Post does not see or store your full payment details and cannot change or reverse storelevel transactions.
</p>
<h3>Fair use and availability</h3>
<p>
Pen2Post is provided “as is” and may change or be updated over time.
Reasonable limits on daily use, free credits, ads, and bundles may apply and can change as the service evolves.
</p>
<p>
Misuse of the app, attempts to bypass limits, or abusive behavior may result in restricted access.
</p>
<h3>Support</h3>
<p>
Pen2Post is a oneperson project. While technical issues are taken seriously,
support responses may be delayed and cannot be guaranteed in every case.
</p>
<p>
For billing issues such as failed charges or payment method problems, please contact the app store directly.
</p>
</section>
<section>
<h2>Questions</h2>
<p class="text-muted">
If you have concerns about privacy or terms, you can use the Feedback option in the menu to share them.
While individual replies are not guaranteed, feedback is reviewed to help improve the app over time.
</p>
</section>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>

34
menu.html Normal file
View File

@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Menu Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>Menu</h1>
<nav class="side-menu-nav">
<a href="about.html">About This App</a>
<a href="start.html">Quick Start</a>
<a href="help.html">Help &amp; Tips</a>
<a href="credits.html">Credits &amp; Rewards</a>
<a href="legal.html">Legal Stuff</a>
<a href="feedback.html">Feedback</a>
</nav>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>

BIN
screen.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

96
start.html Normal file
View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Quick Start Pen2Post</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="journal.css">
</head>
<body>
<div class="notes-container">
<div class="top-buttons">
<button class="floating-button" onclick="goBackToApp()" aria-label="Back to app">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
</svg>
</button>
</div>
<main class="page-main">
<h1>Quick Start</h1>
<p class="text-muted">
A short guide to go from a handwritten page to editable text in a minute or two.
</p>
<h2>5 steps: page to post</h2>
<ol class="section-list">
<li>
<strong>Write</strong> Fill a notebook page the way you normally journal. No special paper is required.
</li>
<li>
<strong>Snap</strong> Open Pen2Post and tap the <strong>camera button</strong> at the bottom left
to take a photo of your page, or choose an existing photo if your device offers that option.
</li>
<li>
<strong>Extract</strong> After the image is loaded, tap the main <strong>Extract Text</strong> button
(in the main app screen) and wait while your handwriting is turned into text.
</li>
<li>
<strong>Shape</strong> Read through the text in the big writing area, fix any misread words,
and make sure it matches your journal page.
</li>
<li>
<strong>Share</strong> Tap the round <strong>share button</strong> at the top right to send the text
into your notes app, email, or any other app your phone suggests.
</li>
</ol>
<p>
Pen2Post gives you a good rough draft of what you wrote. You stay in control of the meaning,
and you make the final edits.
</p>
<h2>Main buttons on the screen</h2>
<ul class="section-list">
<li><strong>Top right Share</strong>: sends the text to other apps on your device using the system share sheet.</li>
<li><strong>Top right Menu</strong>: opens options like About, Quick Start, Help &amp; Tips, Credits &amp; Rewards, Legal Stuff, and Feedback.</li>
<li><strong>Bottom left Camera</strong>: lets you capture a new photo of your handwritten page.</li>
<li><strong>Bottom center Clear</strong>: clears the current text and lets you start fresh. Use this only after you have saved or shared your text.</li>
<li><strong>Bottom right Save/Copy</strong>: copies the text or triggers saving, depending on how your device is set up, so you do not lose your work.</li>
</ul>
<h2>What a typical session looks like</h2>
<p>
You write a page in your journal, open Pen2Post, tap the camera button, and snap a clear photo.
You tap Extract Text, wait a short moment, fix a few words, then use the share button to send the
result into your favorite writing or notes app.
</p>
<h2>App screen preview</h2>
<p class="text-muted">
The screenshot below shows the main layout: the large writing area, the share and menu buttons at the top,
and the camera, clear, and save buttons along the bottom.
</p>
<div class="card">
<!-- Adjust src path if needed -->
<img src="screen.jpg" alt="Pen2Post main screen" class="quickstart-screenshot">
</div>
<h2>Tips for your first page</h2>
<ul class="section-list">
<li>Use good, even lighting and avoid strong shadows on the page.</li>
<li>Write a little larger and clearer than your tiniest handwriting.</li>
<li>Use dark ink on light paper for best results.</li>
</ul>
<p class="text-muted">
Once you are comfortable with a single page, repeat the same steps for the rest of your notebook.
</p>
</main>
</div>
<script src="journal.js"></script>
</body>
</html>