Enhance admin interface, security, and feed management with improved UX and authentication

This commit is contained in:
Young Lee
2025-02-27 18:04:01 -08:00
parent 8839aac24b
commit 56a8263f33
19 changed files with 2022 additions and 523 deletions
+28 -2
View File
@@ -10,11 +10,13 @@ A modern service that turns email newsletters into RSS feeds, built with Cloudfl
- **RSS Generation**: Serves standards-compliant RSS feeds - **RSS Generation**: Serves standards-compliant RSS feeds
- **Admin Interface**: Simple management UI for feeds and emails - **Admin Interface**: Simple management UI for feeds and emails
- **Storage**: Uses Cloudflare KV for efficient, low-cost storage - **Storage**: Uses Cloudflare KV for efficient, low-cost storage
- **Deletion Support**: Email content can be removed from feeds, with cache updates
## Architecture ## Architecture
### Email Flow ### Email Flow
1. A newsletter arrives at `newsletter-XYZ@yourdomain.com`
1. A newsletter arrives at `apple.mountain.42@yourdomain.com` (feed ID format: noun1.noun2.XX)
2. ForwardEmail.net forwards it to your Cloudflare Worker 2. ForwardEmail.net forwards it to your Cloudflare Worker
3. The Worker parses the email, extracts content, and stores it in KV 3. The Worker parses the email, extracts content, and stores it in KV
4. The RSS feed is updated with the new content 4. The RSS feed is updated with the new content
@@ -24,6 +26,16 @@ A modern service that turns email newsletters into RSS feeds, built with Cloudfl
- **Email Parser**: Lightweight custom parser that works in edge environments - **Email Parser**: Lightweight custom parser that works in edge environments
- **Feed Generator**: Modern RSS feed generator with minimal dependencies - **Feed Generator**: Modern RSS feed generator with minimal dependencies
- **Admin UI**: Simple interface to manage feeds and view emails - **Admin UI**: Simple interface to manage feeds and view emails
- **ID Generator**: Creates memorable, collision-resistant feed IDs
- **Data Store**: Organized module for common nouns used in ID generation
### Code Structure
- **src/routes/**: API and UI route handlers
- **src/utils/**: Utility functions including email parsing and ID generation
- **src/data/**: Data files like the nouns list for feed IDs
- **src/types/**: TypeScript type definitions
- **src/index.ts**: Main application entry point
## Development ## Development
@@ -47,7 +59,6 @@ npm run deploy
- `ADMIN_PASSWORD`: Password for the admin interface - `ADMIN_PASSWORD`: Password for the admin interface
- `DOMAIN`: Your custom domain for receiving emails - `DOMAIN`: Your custom domain for receiving emails
- `FORWARDEMAIL_TOKEN`: Token for ForwardEmail.net webhook authentication
## Technology Stack ## Technology Stack
@@ -75,6 +86,21 @@ This project follows a minimalist approach:
- No Node.js-specific modules or polyfills - No Node.js-specific modules or polyfills
- Modern TypeScript features - Modern TypeScript features
- Clean, maintainable code structure - Clean, maintainable code structure
- Modular organization for improved maintainability
## Feed ID System
The system generates memorable, user-friendly feed IDs in the format `noun1.noun2.XX` where:
- `noun1` and `noun2` are randomly selected from a curated list of ~500 common nouns
- `XX` is a random two-digit number between 10 and 99
This format provides:
- Easy to read and share email addresses
- Low collision probability (can handle thousands of feeds)
- Simple to remember for users
- ~22.5 million possible combinations
## License ## License
+80
View File
@@ -0,0 +1,80 @@
/**
* Collection of common nouns for feed ID generation
*/
export const nouns = [
'actor', 'almond', 'amber', 'anchor', 'angel', 'animal', 'answer', 'apple',
'autumn', 'avenue', 'badge', 'bagel', 'baker', 'ballet', 'bamboo',
'banana', 'basket', 'beach', 'beard', 'beauty', 'beetle', 'berry', 'bicycle',
'bird', 'blanket', 'blossom', 'boat', 'bottle', 'bowl', 'breeze', 'bubble',
'bucket', 'button', 'cabin', 'cactus', 'cafe', 'camera', 'candle', 'candy',
'canvas', 'canyon', 'captain', 'carpet', 'carrot', 'castle', 'cave', 'cellar',
'chair', 'chalk', 'cheese', 'cherry', 'chest', 'chicken', 'chimney',
'circus', 'cliff', 'clock', 'cloud', 'clover', 'coast', 'cobalt', 'cocoa',
'coffee', 'coin', 'comet', 'compass', 'cookie', 'copper', 'coral', 'corner',
'cotton', 'cradle', 'craft', 'creek', 'cricket', 'crown', 'crystal', 'cube',
'cupboard', 'curtain', 'cushion', 'daisy', 'dance', 'date', 'dawn', 'deer',
'desert', 'dew', 'diamond', 'dinner', 'dish', 'doctor', 'dolphin',
'donut', 'door', 'dream', 'dress', 'drink', 'drum', 'duck', 'dusk',
'eagle', 'earth', 'echo', 'emerald', 'engine', 'evening', 'face', 'fairy',
'fall', 'family', 'fan', 'farm', 'feather', 'fence', 'ferry', 'field',
'finger', 'fire', 'fish', 'flag', 'flame', 'flash', 'flavor', 'flight',
'floor', 'flour', 'flower', 'flute', 'fog', 'foil', 'forest', 'fork',
'fox', 'frame', 'friend', 'frog', 'frost', 'fruit', 'garden', 'garlic',
'gate', 'gem', 'gift', 'ginger', 'giraffe', 'glacier', 'glass',
'glitter', 'glove', 'glow', 'goat', 'gold', 'grape', 'grass', 'gravel',
'gravity', 'guitar', 'gum', 'hair', 'hammer', 'hand', 'harbor', 'harp',
'hat', 'hawk', 'heart', 'heath', 'heaven', 'helmet', 'herb', 'hill',
'hippo', 'honey', 'hood', 'horn', 'horse', 'hotel', 'hour', 'house',
'hunter', 'ice', 'icicle', 'idea', 'ink', 'insect', 'iron', 'island',
'ivy', 'jacket', 'jade', 'jam', 'jasmine', 'jelly', 'jewel',
'joke', 'journal', 'journey', 'joy', 'judge', 'jungle', 'kettle', 'key',
'kid', 'kingdom', 'kitchen', 'kite', 'kitten', 'knight',
'lab', 'ladder', 'lake', 'lamb', 'lamp', 'land', 'lantern',
'laptop', 'laugh', 'lava', 'lawn', 'leaf', 'legend', 'lemon', 'letter',
'library', 'light', 'lily', 'lime', 'lion', 'lip', 'lobby', 'lock',
'locket', 'lodge', 'lotus', 'love', 'lunch', 'lyric', 'magic', 'magnet',
'mango', 'maple', 'marble', 'market', 'mask', 'meadow', 'melody', 'melon',
'memory', 'metal', 'meteor', 'milk', 'mint', 'mirror', 'mist', 'mitten',
'moon', 'morning', 'moth', 'motor', 'mountain', 'mouse',
'movie', 'muffin', 'museum', 'music', 'myth', 'napkin', 'nectar', 'needle',
'nest', 'net', 'nickel', 'night', 'nose', 'note', 'novel', 'number',
'nurse', 'nutmeg', 'oasis', 'ocean', 'olive', 'onion', 'opera', 'orange',
'orbit', 'orchard', 'orchid', 'ostrich', 'otter', 'oven', 'owl', 'oxygen',
'oyster', 'page', 'paint', 'palace', 'palm', 'pan', 'pancake', 'panda',
'paper', 'parade', 'parcel', 'park', 'parrot', 'party', 'pasta', 'patch',
'path', 'peach', 'peanut', 'pear', 'pearl', 'pebble', 'pencil', 'penny',
'people', 'pepper', 'petal', 'phone', 'photo', 'piano', 'pickle', 'picture',
'pie', 'pillow', 'pine', 'pink', 'pirate', 'pizza', 'planet',
'plant', 'plum', 'pocket', 'poem', 'poet', 'point', 'pony', 'pool',
'popcorn', 'porch', 'port', 'potato', 'powder', 'prairie', 'pretzel', 'prism',
'prose', 'puppet', 'puppy', 'puzzle', 'quail', 'quartz', 'queen', 'quilt',
'rabbit', 'raccoon', 'radio', 'raft', 'rain', 'rainbow', 'raisin',
'ranch', 'rapids', 'raven', 'ray', 'record', 'reef', 'ribbon', 'rice',
'ring', 'river', 'road', 'robin', 'robot', 'rock', 'rocket', 'rodeo',
'roof', 'room', 'root', 'rope', 'rose', 'ruby', 'rug', 'ruler', 'sage',
'sail', 'salad', 'salmon', 'salt', 'sand', 'sandal', 'sauce', 'saucer',
'scale', 'scarf', 'school', 'sea', 'seed', 'shadow', 'shell', 'ship',
'shirt', 'shoe', 'shop', 'shower', 'shrimp', 'side', 'sign', 'silk',
'silver', 'singer', 'sink', 'sky', 'sled', 'sleet', 'sleigh', 'slice',
'slide', 'slipper', 'slope', 'smoke', 'snail', 'snake', 'snow', 'soap',
'sock', 'soda', 'sofa', 'soil', 'song', 'soup', 'spade', 'spark', 'sparrow',
'spice', 'spider', 'spoon', 'spot', 'spring', 'sprout', 'square', 'squirrel',
'stable', 'stage', 'stair', 'stamp', 'star', 'station', 'steam', 'steel',
'stem', 'stick', 'stone', 'stork', 'storm', 'story', 'stove', 'straw',
'stream', 'street', 'string', 'studio', 'sugar', 'summer', 'sun', 'sunset',
'swan', 'sweater', 'sweets', 'sword', 'table', 'tablet', 'tail', 'talent',
'tangerine', 'tank', 'tea', 'team', 'teapot', 'tear', 'temple', 'tennis',
'tent', 'theater', 'thistle', 'thought', 'thread', 'thunder',
'ticket', 'tide', 'tiger', 'tile', 'time', 'toast', 'toffee', 'tomato',
'tooth', 'top', 'torch', 'tower', 'town', 'toy', 'track',
'train', 'tree', 'triangle', 'trick', 'truck', 'trumpet', 'tulip', 'tunnel',
'turkey', 'turtle', 'twig', 'uncle', 'unicorn', 'universe', 'vacuum', 'valley',
'vanilla', 'vase', 'velvet', 'vessel', 'village', 'vine', 'violin',
'voice', 'volcano', 'voyage', 'wagon', 'walnut', 'waltz', 'water',
'wave', 'wax', 'weather', 'web', 'wedding', 'whale', 'wheat',
'wheel', 'whistle', 'whisper', 'willow', 'wind', 'window', 'wine', 'wing',
'winter', 'wire', 'wish', 'wizard', 'wood', 'wool',
'word', 'world', 'wreath', 'wrist', 'writer', 'xylophone', 'yacht',
'yard', 'yarn', 'year', 'yolk', 'zebra', 'zephyr', 'zinc', 'zipper',
'zone', 'zoo'
];
+86 -29
View File
@@ -2,15 +2,82 @@ import { Hono } from 'hono';
import { handle as handleInbound } from './routes/inbound'; import { handle as handleInbound } from './routes/inbound';
import { handle as handleRSS } from './routes/rss'; import { handle as handleRSS } from './routes/rss';
import { handle as handleAdmin } from './routes/admin'; import { handle as handleAdmin } from './routes/admin';
import { Context, Next } from 'hono';
import { Env } from './types'; import { Env } from './types';
// Define allowed origins for CORS // Define allowed origins for CORS
const ALLOWED_ORIGINS = ['https://getmynews.app', 'https://api.getmynews.app']; const ALLOWED_ORIGINS = ['https://getmynews.app', 'https://www.getmynews.app'];
// Fallback ForwardEmail.net IP addresses in case API fetch fails
const FALLBACK_FORWARD_EMAIL_IPS = [
'138.197.213.185', // mx1.forwardemail.net
'121.127.44.56', // mx1.forwardemail.net (alternate)
'104.248.224.170' // mx2.forwardemail.net
];
// Create the main Hono app // Create the main Hono app
const app = new Hono(); const app = new Hono();
// Cache for ForwardEmail.net IPs with expiration
let forwardEmailIpsCache: {
ips: string[];
expiresAt: number;
} | null = null;
// Function to fetch ForwardEmail.net IPs from their API
async function getForwardEmailIps(): Promise<string[]> {
try {
// Return from cache if available and not expired
if (forwardEmailIpsCache && forwardEmailIpsCache.expiresAt > Date.now()) {
return forwardEmailIpsCache.ips;
}
// Fetch the latest IPs from ForwardEmail.net
const response = await fetch('https://forwardemail.net/ips/v4.json', {
headers: {
'User-Agent': 'Email-to-RSS/1.0',
},
cf: {
cacheTtl: 3600, // Cache for 1 hour in Cloudflare's cache
cacheEverything: true,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch IPs: ${response.status}`);
}
// Define the expected type for the API response
interface IpEntry {
hostname: string;
ipv4: string[];
updated: string;
}
const data = await response.json() as IpEntry[];
// Extract IPs for mx1 and mx2 servers
const mxIps = data
.filter(entry =>
entry.hostname === 'mx1.forwardemail.net' ||
entry.hostname === 'mx2.forwardemail.net'
)
.flatMap(entry => entry.ipv4);
// Store in cache for 24 hours
forwardEmailIpsCache = {
ips: mxIps,
expiresAt: Date.now() + (24 * 60 * 60 * 1000) // 24 hours
};
console.log('Fetched ForwardEmail.net IPs:', mxIps);
return mxIps;
} catch (error) {
console.error('Error fetching ForwardEmail.net IPs:', error);
// Return fallback IPs if fetch fails
return FALLBACK_FORWARD_EMAIL_IPS;
}
}
// CORS middleware // CORS middleware
app.use('*', async (c, next) => { app.use('*', async (c, next) => {
const origin = c.req.header('Origin'); const origin = c.req.header('Origin');
@@ -29,44 +96,34 @@ app.use('*', async (c, next) => {
await next(); await next();
}); });
// Create auth middleware function // Webhook security middleware for /api/inbound - verify ForwardEmail.net IP
const authMiddleware = async (c: Context, next: Next) => { app.use('/api/inbound', async (c, next) => {
const authHeader = c.req.header('Authorization'); // Get the client IP
const clientIP = c.req.header('CF-Connecting-IP') || // Cloudflare-specific header
c.req.header('X-Forwarded-For')?.split(',')[0].trim() ||
c.req.raw.headers.get('x-real-ip') ||
'0.0.0.0';
if (!authHeader || !authHeader.startsWith('Basic ')) { // Get the latest ForwardEmail.net IPs
c.header('WWW-Authenticate', 'Basic realm="Admin Area"'); const allowedIps = await getForwardEmailIps();
return c.text('Unauthorized', 401);
} // Check if the request is coming from ForwardEmail.net
if (!allowedIps.includes(clientIP)) {
const base64Credentials = authHeader.split(' ')[1]; console.error(`Unauthorized webhook request from IP: ${clientIP}`);
const credentials = atob(base64Credentials);
const [username, password] = credentials.split(':');
// Check against environment variable
const env = c.env as unknown as Env;
const adminPassword = env.ADMIN_PASSWORD;
if (username !== 'admin' || password !== adminPassword) {
c.header('WWW-Authenticate', 'Basic realm="Admin Area"');
return c.text('Unauthorized', 401); return c.text('Unauthorized', 401);
} }
console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`);
await next(); await next();
}; });
// Apply auth middleware to admin routes
app.use('/admin/*', authMiddleware);
// Also apply auth middleware to root path
app.use('/', authMiddleware);
// Route handlers // Route handlers
app.post('/api/inbound', handleInbound); app.post('/api/inbound', handleInbound);
app.get('/rss/:feedId', handleRSS); app.get('/rss/:feedId', handleRSS);
app.route('/admin', handleAdmin); app.route('/admin', handleAdmin);
// Root path uses admin handler // Root path redirects to admin dashboard
app.route('/', handleAdmin); app.get('/', (c) => c.redirect('/admin'));
// Catch-all for 404s // Catch-all for 404s
app.all('*', (c) => c.text('Not Found', 404)); app.all('*', (c) => c.text('Not Found', 404));
+645 -436
View File
File diff suppressed because it is too large Load Diff
+10 -1
View File
@@ -40,7 +40,7 @@ export async function handle(c: Context): Promise<Response> {
contentType: payload.html ? 'HTML' : 'Text' contentType: payload.html ? 'HTML' : 'Text'
}); });
// Extract feed ID from email address (e.g., newsletter-xyz@domain.com -> xyz) // Extract feed ID from email address (e.g., apple.mountain.42@domain.com -> apple.mountain.42)
const toAddress = payload.recipients?.[0] || ''; const toAddress = payload.recipients?.[0] || '';
const feedId = EmailParser.extractFeedId(toAddress); const feedId = EmailParser.extractFeedId(toAddress);
@@ -49,6 +49,15 @@ export async function handle(c: Context): Promise<Response> {
return new Response('Invalid email address format', { status: 400 }); return new Response('Invalid email address format', { status: 400 });
} }
// Check if the feed exists by looking up the feed configuration
const feedConfigKey = `feed:${feedId}:config`;
const feedConfig = await env.EMAIL_STORAGE.get(feedConfigKey, 'json');
if (!feedConfig) {
console.error(`Feed with ID ${feedId} does not exist or has been deleted`);
return new Response('Feed does not exist', { status: 404 });
}
// Parse the email using our simplified parser // Parse the email using our simplified parser
const emailData = EmailParser.parseForwardEmailPayload(payload); const emailData = EmailParser.parseForwardEmailPayload(payload);
+3 -3
View File
@@ -33,8 +33,8 @@ export async function handle(c: Context): Promise<Response> {
const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || { const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || {
title: `Newsletter Feed ${feedId}`, title: `Newsletter Feed ${feedId}`,
description: 'Converted email newsletter', description: 'Converted email newsletter',
site_url: `https://api.${env.DOMAIN}/rss/${feedId}`, site_url: `https://${env.DOMAIN}/rss/${feedId}`,
feed_url: `https://api.${env.DOMAIN}/rss/${feedId}`, feed_url: `https://${env.DOMAIN}/rss/${feedId}`,
language: 'en', language: 'en',
created_at: Date.now() created_at: Date.now()
}; };
@@ -52,7 +52,7 @@ export async function handle(c: Context): Promise<Response> {
} }
// Generate the RSS feed XML // Generate the RSS feed XML
const baseUrl = `https://api.${env.DOMAIN}`; const baseUrl = `https://${env.DOMAIN}`;
const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl); const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl);
// Return the RSS feed with appropriate content type // Return the RSS feed with appropriate content type
+43
View File
@@ -0,0 +1,43 @@
// Authentication helper functions
// Handles user authentication state
export const authHelpers = `
// Check if user is authenticated
function isAuthenticated() {
// Check localStorage first (client-side)
if (localStorage.getItem('authenticated') === 'true') {
return true;
}
// Check for cookie (server-side auth)
function getCookie(name) {
const value = \`; \${document.cookie}\`;
const parts = value.split(\`; \${name}=\`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
return getCookie('admin_auth') === 'true';
}
// Set authentication state
function setAuthenticated(value) {
localStorage.setItem('authenticated', value ? 'true' : 'false');
}
// Logout function
function logout() {
localStorage.removeItem('authenticated');
// Also clear the cookie by setting expiry in the past
document.cookie = 'admin_auth=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;';
window.location.href = '/admin/login';
}
// Check authentication on page load
document.addEventListener('DOMContentLoaded', () => {
const path = window.location.pathname;
if (path !== '/admin/login' && !isAuthenticated()) {
window.location.href = '/admin/login';
}
});
`;
+62
View File
@@ -0,0 +1,62 @@
// Clipboard functionality
// Handles copying text to clipboard with visual feedback
export const clipboardScripts = `
// Copy text to clipboard with animation feedback
function copyToClipboard(text, element) {
// Find the parent .copyable element and the content element
const copyableContainer = element.closest('.copyable');
const contentElement = copyableContainer?.querySelector('.copyable-content');
if (!copyableContainer || !contentElement) return;
navigator.clipboard.writeText(text).then(() => {
// Add the 'copied' class to the content element for success styling
contentElement.classList.add('copied');
// Remove the class after a delay (let CSS handle the transitions)
setTimeout(() => {
contentElement.classList.remove('copied');
}, 1500);
}).catch(err => {
console.error('Could not copy text: ', err);
});
}
// Initialize copyable elements
function setupCopyableElements() {
document.querySelectorAll('.copyable').forEach(container => {
const contentElement = container.querySelector('.copyable-content');
const valueElement = container.querySelector('.copyable-value');
if (contentElement && valueElement) {
const textToCopy = valueElement.getAttribute('data-copy') || valueElement.textContent.trim();
// Add click handler to the entire content area
contentElement.addEventListener('click', () => {
copyToClipboard(textToCopy, contentElement);
});
}
});
}
// Confirmation dialogs for deletion
function confirmDelete(feedId) {
if (confirm('Are you sure you want to delete this feed? This action cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/feeds/' + feedId + '/delete';
document.body.appendChild(form);
form.submit();
}
}
function confirmDeleteEmail(emailKey, feedId) {
if (confirm('Are you sure you want to delete this email? This action cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/emails/' + emailKey + '/delete?feedId=' + feedId;
document.body.appendChild(form);
form.submit();
}
}
`;
+17
View File
@@ -0,0 +1,17 @@
// Main scripts exports file
// Combines and re-exports all JavaScript functionality
import { modalScripts, emailViewScripts, initScripts } from './interactions';
import { clipboardScripts } from './clipboard';
import { authHelpers } from './auth';
// Combine all scripts into a single JavaScript string
export const interactiveScripts = `
${modalScripts}
${emailViewScripts}
${clipboardScripts}
${initScripts}
`;
// Re-export for modular usage if needed
export { modalScripts, emailViewScripts, initScripts, clipboardScripts, authHelpers };
+73
View File
@@ -0,0 +1,73 @@
// Interactive scripts for UI elements
// Handles modals, toggles, and other UI interactions
export const modalScripts = `
// Modal Functionality
function setupModals() {
const modalTriggers = document.querySelectorAll('[data-modal-target]');
modalTriggers.forEach(trigger => {
trigger.addEventListener('click', (e) => {
e.preventDefault();
const modalId = trigger.getAttribute('data-modal-target');
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('visible');
}
});
});
// Close modals when clicking outside or on close button
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-bg') || e.target.classList.contains('modal-close')) {
const modal = e.target.closest('.modal-bg');
if (modal) {
modal.classList.remove('visible');
}
}
});
// Close modals with ESC key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const visibleModals = document.querySelectorAll('.modal-bg.visible');
visibleModals.forEach(modal => {
modal.classList.remove('visible');
});
}
});
}
`;
export const emailViewScripts = `
// Toggle email view functions
function showRendered() {
document.getElementById('rendered-view').style.display = 'block';
document.getElementById('raw-view').style.display = 'none';
document.getElementById('rendered-button').classList.add('active');
document.getElementById('raw-button').classList.remove('active');
}
function showRaw() {
document.getElementById('rendered-view').style.display = 'none';
document.getElementById('raw-view').style.display = 'block';
document.getElementById('rendered-button').classList.remove('active');
document.getElementById('raw-button').classList.add('active');
}
`;
export const initScripts = `
// Initialize all interactive elements
function initInteractive() {
setupModals();
setupCopyableElements(); // Initialize copyable elements
// Make these functions globally available
window.showRendered = showRendered;
window.showRaw = showRaw;
}
// Run setup when DOM is fully loaded
document.addEventListener('DOMContentLoaded', initInteractive);
`;
+493
View File
@@ -0,0 +1,493 @@
// Component styles for the application
// Contains styles for buttons, cards, forms, modals, etc.
export const componentStyles = `
/* Card - Glass Effect */
.card {
background-color: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: var(--spacing-lg);
margin-bottom: var(--spacing-lg);
border: 1px solid var(--color-border);
transition: all var(--transition-normal);
backdrop-filter: blur(var(--blur-md));
-webkit-backdrop-filter: blur(var(--blur-md));
}
/* Remove top margin for h2 elements in cards */
.card h2 {
margin-top: 0;
}
/* Feed header styling */
.feed-header {
margin-bottom: var(--spacing-md);
}
.feed-title {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-xs);
color: var(--color-text-primary);
}
.feed-description {
font-size: var(--font-size-md);
color: var(--color-text-secondary);
margin-top: 0;
margin-bottom: var(--spacing-sm);
}
.feed-description.empty {
color: var(--color-text-tertiary);
font-style: italic;
}
/* In-place editing styles */
input.feed-title-edit,
.feed-title-edit {
width: 100%;
font-size: var(--font-size-xl) !important;
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
padding: 4px 6px !important;
background-color: transparent !important;
border: 1px solid var(--color-border);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
}
textarea.feed-description-edit,
.feed-description-edit {
width: 100%;
font-size: var(--font-size-md) !important;
margin-bottom: var(--spacing-sm);
padding: 4px 6px !important;
min-height: 60px;
background-color: transparent !important;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
font-family: var(--font-family);
border-radius: var(--radius-sm);
}
.hidden {
display: none !important;
}
/* Success button styles */
.button-success {
background-color: var(--color-primary);
color: var(--color-text-on-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.5s ease;
}
.button-success:hover, .button-success:focus {
background-color: rgba(10, 132, 255, 0.9);
}
.button-success.saved {
background-color: var(--color-success);
transition: all 0.5s ease;
}
/* Force saved button to stay green on hover */
.button-success.saved:hover {
background-color: var(--color-success);
}
/* Button container with space-between layout */
.feed-buttons {
display: flex;
justify-content: space-between;
gap: var(--spacing-sm);
}
.feed-buttons-left {
display: flex;
gap: var(--spacing-sm);
}
/* Fixed width for action buttons to prevent layout shifts during state changes */
.feed-buttons-left .button {
width: 140px; /* Fixed exact width instead of min-width */
justify-content: center; /* Ensure text is centered */
box-sizing: border-box; /* Ensure padding is included in width calculation */
}
/* Ensure anchor tags in button containers match button styling exactly */
.feed-buttons-left a.button {
width: 140px; /* Same fixed width */
box-sizing: border-box;
text-align: center;
}
.feed-buttons-right {
margin-left: auto;
}
/* Button - VisionOS Style with consistent height */
.button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 44px; /* Fixed height for consistency */
padding: 0 var(--spacing-lg);
border-radius: var(--radius-md);
font-size: var(--font-size-md);
font-weight: var(--font-weight-medium);
text-decoration: none;
cursor: pointer;
transition: all var(--transition-fast), color 0.3s ease, background-color 0.3s ease;
white-space: nowrap;
opacity: 1;
/* Glass effect for buttons */
background-color: rgba(10, 132, 255, 0.8);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
color: var(--color-text-on-primary);
}
.button:hover, .button:focus {
background-color: rgba(10, 132, 255, 0.9);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transform: translateY(-1px);
}
.button:active {
transform: translateY(0);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.button-secondary {
background-color: rgba(60, 60, 67, 0.1);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.button-secondary:hover, .button-secondary:focus {
background-color: rgba(60, 60, 67, 0.2);
}
/* Light mode specific button styling */
@media (prefers-color-scheme: light) {
.button-secondary {
background-color: rgba(60, 60, 67, 0.05);
border: 1px solid rgba(60, 60, 67, 0.1);
}
.button-secondary:hover, .button-secondary:focus {
background-color: rgba(60, 60, 67, 0.1);
}
}
/* Logout button styling */
.button-logout {
background-color: var(--color-logout);
color: var(--color-text-on-primary);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.button-logout:hover, .button-logout:focus {
background-color: rgba(255, 159, 10, 0.9);
}
/* Back button styling */
.button-back {
display: inline-flex;
align-items: center;
padding-left: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.button-back:before {
content: "←";
margin-right: var(--spacing-sm);
font-size: var(--font-size-lg);
}
.button-danger {
background-color: rgba(255, 69, 58, 0.8);
}
.button-danger:hover, .button-danger:focus {
background-color: rgba(255, 69, 58, 0.9);
}
/* Small button variation */
.button-small {
height: 36px; /* Smaller height */
padding: 0 var(--spacing-md);
font-size: var(--font-size-sm);
}
/* Form Elements */
.form-group {
margin-bottom: var(--spacing-lg);
}
label {
display: block;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
margin-bottom: var(--spacing-xs);
color: var(--color-text-secondary);
}
input[type="text"],
input[type="email"],
input[type="password"],
textarea {
display: block;
width: 100%;
padding: var(--spacing-md);
font-size: var(--font-size-md);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background-color: rgba(60, 60, 67, 0.1);
color: var(--color-text-primary);
transition: all var(--transition-fast);
box-sizing: border-box;
font-family: var(--font-family);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.2);
}
/* Modal */
.modal-bg {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(var(--blur-lg));
-webkit-backdrop-filter: blur(var(--blur-lg));
justify-content: center;
align-items: center;
z-index: 1000;
opacity: 0;
transition: opacity var(--transition-normal);
}
.modal-bg.visible {
display: flex;
opacity: 1;
}
.modal {
background-color: var(--color-card);
padding: var(--spacing-xl);
border-radius: var(--radius-lg);
max-width: 90%;
width: 500px;
box-shadow: var(--shadow-xl);
transform: scale(0.98);
opacity: 0;
transition: all var(--transition-normal);
border: 1px solid var(--color-border);
backdrop-filter: blur(var(--blur-md));
-webkit-backdrop-filter: blur(var(--blur-md));
}
.modal-bg.visible .modal {
transform: scale(1);
opacity: 1;
}
.modal-header {
margin-bottom: var(--spacing-lg);
}
.modal-buttons {
display: flex;
justify-content: flex-end;
gap: var(--spacing-md);
margin-top: var(--spacing-xl);
}
/* Toggle/Switch */
.toggle-switch {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-md);
}
.toggle-switch input[type="checkbox"] {
height: 0;
width: 0;
visibility: hidden;
position: absolute;
}
.toggle-switch label {
cursor: pointer;
width: 50px;
height: 28px;
background: var(--color-text-tertiary);
display: block;
border-radius: 100px;
position: relative;
transition: background-color var(--transition-fast);
}
.toggle-switch label:after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 24px;
height: 24px;
background: var(--color-text-on-primary);
border-radius: 90px;
transition: transform var(--transition-fast);
box-shadow: var(--shadow-sm);
}
.toggle-switch input:checked + label {
background: var(--color-primary);
}
.toggle-switch input:checked + label:after {
transform: translateX(22px);
}
/* Email Content Iframe */
.email-iframe-container {
width: 100%;
overflow: hidden;
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background-color: #ffffff;
}
.email-iframe {
width: 100%;
height: 500px; /* Smaller default height */
border: none;
background-color: #ffffff;
transition: height 0.3s ease;
}
/* Dark mode specific styling for email iframe */
@media (prefers-color-scheme: dark) {
.email-iframe-container {
background-color: #1c1c1e;
border-color: #3a3a3c;
}
.email-iframe {
background-color: #1c1c1e;
}
}
/* Email Raw View */
.email-raw {
padding: var(--spacing-md);
background-color: rgba(30, 30, 32, 0.7);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
overflow: auto;
max-height: 500px; /* Match iframe default height */
backdrop-filter: blur(var(--blur-sm));
}
/* Light mode specific styling for Raw HTML view */
@media (prefers-color-scheme: light) {
.email-raw {
background-color: rgba(240, 240, 245, 0.9);
}
}
.email-raw pre {
margin: 0;
font-family: 'Menlo', monospace;
font-size: 14px;
white-space: pre-wrap;
word-break: break-word;
}
/* Email Metadata Styling */
.email-meta {
margin-bottom: var(--spacing-md);
padding-bottom: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
}
.email-meta h2 {
margin-top: 0;
margin-bottom: var(--spacing-md);
font-size: var(--font-size-xl);
color: var(--color-text-primary);
}
.email-metadata-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(4, auto);
gap: var(--spacing-sm);
}
@media (min-width: 640px) {
.email-metadata-grid {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, auto);
}
}
/* Toggle buttons for email view */
.toggle-view {
display: flex;
gap: var(--spacing-xs);
margin-bottom: var(--spacing-md);
}
.toggle-button {
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
background-color: rgba(60, 60, 67, 0.1);
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
.toggle-button:hover {
background-color: rgba(60, 60, 67, 0.2);
}
.toggle-button.active {
background-color: var(--color-primary);
color: var(--color-text-on-primary);
border-color: var(--color-primary);
}
/* Email content container */
.email-content {
margin-top: var(--spacing-md);
border-radius: var(--radius-md);
overflow: hidden;
}
/* Feed and Email Lists */
.feed-list,
.email-list {
list-style: none;
padding: 0;
margin: 0;
}
`;
+7
View File
@@ -0,0 +1,7 @@
// This file is kept for backwards compatibility
// It re-exports the new modular design system
import { designSystem } from './index';
import { interactiveScripts, authHelpers } from '../scripts/index';
export { designSystem, interactiveScripts, authHelpers };
+20
View File
@@ -0,0 +1,20 @@
// Main style exports file
// Combines all style components and re-exports them for easy imports
import { variables, lightModeTheme, fontImport } from './variables';
import { layoutStyles } from './layout';
import { componentStyles } from './components';
import { utilityStyles } from './utilities';
// Combine all style components into a single CSS string
export const designSystem = `
${variables}
${lightModeTheme}
${fontImport}
${layoutStyles}
${componentStyles}
${utilityStyles}
`;
// Re-export everything for modular usage if needed
export { variables, lightModeTheme, fontImport, layoutStyles, componentStyles, utilityStyles };
+171
View File
@@ -0,0 +1,171 @@
// Layout styles for the application
// Contains styles for containers, headers, page structure
export const layoutStyles = `
/* Base Page Layout */
.page {
font-family: var(--font-family);
line-height: 1.5;
background-color: var(--color-background);
color: var(--color-text-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background-color var(--transition-normal);
overflow-x: hidden;
margin: 0;
padding: 0;
/* Add subtle gradient background */
background-image: linear-gradient(135deg,
rgba(0, 0, 0, 0.02) 0%,
rgba(255, 255, 255, 0.02) 100%);
background-attachment: fixed;
}
/* Main Container */
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: var(--spacing-xl);
box-sizing: border-box;
}
/* Header Styles */
.header {
margin-bottom: var(--spacing-xl);
}
.header h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
background: var(--gradient-title);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.header p {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
margin-top: 0;
max-width: 80%;
}
/* Authentication Screen Layout */
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background);
}
.auth-card {
width: 100%;
max-width: 400px;
padding: var(--spacing-xl);
background-color: var(--color-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
border: 1px solid var(--color-border);
transition: all var(--transition-normal);
backdrop-filter: blur(var(--blur-md));
-webkit-backdrop-filter: blur(var(--blur-md));
}
.auth-logo {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-title {
font-size: var(--font-size-xl);
text-align: center;
margin-bottom: var(--spacing-xl);
}
.auth-error {
color: var(--color-danger);
background-color: rgba(255, 59, 48, 0.1);
padding: var(--spacing-md);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-lg);
text-align: center;
font-weight: var(--font-weight-medium);
backdrop-filter: blur(var(--blur-sm));
-webkit-backdrop-filter: blur(var(--blur-sm));
}
.auth-form {
margin-bottom: var(--spacing-lg);
}
.auth-button {
width: 100%;
margin-top: var(--spacing-lg);
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.container {
padding: var(--spacing-md);
}
.header {
padding: var(--spacing-md) 0;
margin-bottom: var(--spacing-lg);
}
.header h1 {
font-size: var(--font-size-xl);
}
}
.header-with-actions {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--spacing-xl);
}
.header-title {
flex: 1;
}
.header h1, .header-title h1 {
font-size: var(--font-size-xxl);
font-weight: var(--font-weight-bold);
margin-bottom: var(--spacing-xs);
line-height: 1.2;
color: var(--color-text-primary); /* Fallback color */
background: var(--gradient-title);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
.header p, .header-title p {
font-size: var(--font-size-lg);
color: var(--color-text-secondary);
margin-top: 0;
max-width: 80%;
}
.header-actions {
display: flex;
align-items: center;
margin-top: var(--spacing-md);
}
.feed-title {
font-size: var(--font-size-xl);
margin-bottom: var(--spacing-md);
color: var(--color-text-primary);
}
`;
+108
View File
@@ -0,0 +1,108 @@
// Utility styles for the application
// Contains styles for utility classes like copyable text, animations, etc.
export const utilityStyles = `
/* Animations */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.fade-in {
animation: fadeIn 0.5s ease-out;
}
/* Copyable content styling */
.copyable {
position: relative;
display: flex;
align-items: center;
margin-bottom: var(--spacing-xs);
padding: var(--spacing-sm) var(--spacing-md);
background-color: rgba(60, 60, 67, 0.1);
border-radius: var(--radius-md);
transition: background-color var(--transition-fast);
}
/* When inside a grid, ensure proper fit */
.email-metadata-grid .copyable {
margin-bottom: 0; /* Remove bottom margin inside grid */
}
.copyable-label {
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin-right: var(--spacing-sm);
user-select: none;
white-space: nowrap;
}
.copyable-content {
display: inline-flex;
align-items: center;
cursor: pointer;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
transition: background-color var(--transition-fast);
width: fit-content;
overflow: hidden; /* Prevent overflow in small containers */
flex: 1; /* Take remaining space */
}
.copyable-content:hover {
background-color: rgba(60, 60, 67, 0.2);
}
.copyable-value {
word-break: break-all;
margin-right: var(--spacing-xs);
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.copy-icon-container {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 20px;
height: 20px;
}
.copy-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* Simplified icon states and transitions */
.copy-icon-original {
opacity: 0.6;
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* Only apply hover effect when not in copied state */
.copyable-content:hover:not(.copied) .copy-icon-original {
opacity: 1;
}
.copy-icon-success {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
color: var(--color-success);
transition: opacity 0.2s ease, transform 0.2s ease;
}
/* When copied, hide original and show success icon with a smooth transition */
.copyable-content.copied .copy-icon-original {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
.copyable-content.copied .copy-icon-success {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
`;
+108
View File
@@ -0,0 +1,108 @@
// Design system variables for the application
// Contains CSS variables for typography, colors, spacing, radius, animations, etc.
export const variables = `
:root {
/* Typography - Using Inter font */
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
--font-size-xs: 12px;
--font-size-sm: 14px;
--font-size-md: 16px;
--font-size-lg: 20px;
--font-size-xl: 24px;
--font-size-xxl: 32px;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Colors - Dark Mode (Default) */
--color-primary: #0a84ff;
--color-primary-dark: #409cff;
--color-secondary: #5e5ce6;
--color-success: #30d158;
--color-warning: #ff9f0a;
--color-danger: #ff453a;
--color-danger-dark: #ff6961;
--color-background: rgba(28, 28, 30, 0.95); /* Semi-transparent for glass effect */
--color-card: rgba(44, 44, 46, 0.8); /* Semi-transparent for glass effect */
--color-border: rgba(255, 255, 255, 0.08);
--color-text-primary: #ffffff;
--color-text-secondary: #ebebf5;
--color-text-tertiary: #8e8e93;
--color-text-on-primary: #ffffff;
--color-logout: rgba(255, 159, 10, 0.8); /* Orange-tinted for logout */
/* Gradients */
--gradient-title: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
/* Shadows for dark mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.2);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.2);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.2);
/* Spacing */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
--spacing-xxl: 48px;
/* Radius */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-pill: 9999px;
/* Animation - Subtle */
--transition-fast: 0.15s cubic-bezier(0.16, 1, 0.3, 1);
--transition-normal: 0.2s cubic-bezier(0.16, 1, 0.3, 1);
--transition-slow: 0.3s cubic-bezier(0.16, 1, 0.3, 1);
/* Blur for glass effect */
--blur-sm: 8px;
--blur-md: 12px;
--blur-lg: 20px;
}
`;
// Light mode variables
export const lightModeTheme = `
/* Light Mode Support - Based on device preference */
@media (prefers-color-scheme: light) {
:root {
--color-primary: #0070f3;
--color-primary-dark: #0051a8;
--color-secondary: #5e5ce6;
--color-success: #34c759;
--color-warning: #ff9500;
--color-danger: #ff3b30;
--color-danger-dark: #d70015;
--color-background: rgba(245, 245, 247, 0.9); /* Semi-transparent for glass effect */
--color-card: rgba(255, 255, 255, 0.8); /* Semi-transparent for glass effect */
--color-border: rgba(0, 0, 0, 0.06);
--color-text-primary: #000000;
--color-text-secondary: #666666;
--color-text-tertiary: #999999;
--color-text-on-primary: #ffffff;
--color-logout: rgba(255, 149, 0, 0.8); /* Orange-tinted for logout */
/* Reset shadows for light mode */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.03);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 8px 16px rgba(0, 0, 0, 0.06);
--shadow-xl: 0 12px 24px rgba(0, 0, 0, 0.08);
/* Update gradient for light mode */
--gradient-title: linear-gradient(135deg, var(--color-primary), var(--color-secondary));
}
}
`;
// Inter font import
export const fontImport = `
/* Inter Font Import */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
`;
+3 -2
View File
@@ -6,11 +6,12 @@ import { EmailData } from '../types';
export class EmailParser { export class EmailParser {
/** /**
* Extract the feed ID from an email address * Extract the feed ID from an email address
* @param emailAddress The email address (e.g., newsletter-xyz@domain.com) * @param emailAddress The email address (e.g., apple.mountain.42@domain.com)
* @returns The feed ID or null if not found * @returns The feed ID or null if not found
*/ */
static extractFeedId(emailAddress: string): string | null { static extractFeedId(emailAddress: string): string | null {
const match = emailAddress.match(/^newsletter-([a-zA-Z0-9]+)@/); // Match pattern for noun1.noun2.XY before the @ symbol
const match = emailAddress.match(/^([a-z]+\.[a-z]+\.\d{2})@/i);
return match ? match[1] : null; return match ? match[1] : null;
} }
+17
View File
@@ -0,0 +1,17 @@
import { nouns } from '../data/nouns';
/**
* Generates a random feed ID in the format noun1.noun2.XY
* @returns A random feed ID string
*/
export function generateFeedId(): string {
// Select two random nouns
const noun1 = nouns[Math.floor(Math.random() * nouns.length)];
const noun2 = nouns[Math.floor(Math.random() * nouns.length)];
// Generate a random 2-digit number between 10 and 99
const number = Math.floor(Math.random() * 90) + 10;
// Combine to create the ID with dots as separators
return `${noun1}.${noun2}.${number}`;
}
-2
View File
@@ -23,8 +23,6 @@ workers_dev = true
# Add any production-specific configuration here # Add any production-specific configuration here
workers_dev = false workers_dev = false
routes = [ routes = [
"https://api.getmynews.app/api/*",
"https://api.getmynews.app/rss/*",
"https://getmynews.app/*", "https://getmynews.app/*",
"https://www.getmynews.app/*" "https://www.getmynews.app/*"
] ]