diff --git a/README.md b/README.md index f5c01a1..4c44bf5 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,13 @@ A modern service that turns email newsletters into RSS feeds, built with Cloudfl - **RSS Generation**: Serves standards-compliant RSS feeds - **Admin Interface**: Simple management UI for feeds and emails - **Storage**: Uses Cloudflare KV for efficient, low-cost storage +- **Deletion Support**: Email content can be removed from feeds, with cache updates ## Architecture ### 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 3. The Worker parses the email, extracts content, and stores it in KV 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 - **Feed Generator**: Modern RSS feed generator with minimal dependencies - **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 @@ -47,7 +59,6 @@ npm run deploy - `ADMIN_PASSWORD`: Password for the admin interface - `DOMAIN`: Your custom domain for receiving emails -- `FORWARDEMAIL_TOKEN`: Token for ForwardEmail.net webhook authentication ## Technology Stack @@ -75,6 +86,21 @@ This project follows a minimalist approach: - No Node.js-specific modules or polyfills - Modern TypeScript features - 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 diff --git a/src/data/nouns.ts b/src/data/nouns.ts new file mode 100644 index 0000000..e2dfb47 --- /dev/null +++ b/src/data/nouns.ts @@ -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' +]; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 67d188d..24ce5ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,15 +2,82 @@ import { Hono } from 'hono'; import { handle as handleInbound } from './routes/inbound'; import { handle as handleRSS } from './routes/rss'; import { handle as handleAdmin } from './routes/admin'; -import { Context, Next } from 'hono'; import { Env } from './types'; // 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 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 { + 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 app.use('*', async (c, next) => { const origin = c.req.header('Origin'); @@ -29,44 +96,34 @@ app.use('*', async (c, next) => { await next(); }); -// Create auth middleware function -const authMiddleware = async (c: Context, next: Next) => { - const authHeader = c.req.header('Authorization'); +// Webhook security middleware for /api/inbound - verify ForwardEmail.net IP +app.use('/api/inbound', async (c, next) => { + // 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 ')) { - c.header('WWW-Authenticate', 'Basic realm="Admin Area"'); - return c.text('Unauthorized', 401); - } - - const base64Credentials = authHeader.split(' ')[1]; - 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"'); + // Get the latest ForwardEmail.net IPs + const allowedIps = await getForwardEmailIps(); + + // Check if the request is coming from ForwardEmail.net + if (!allowedIps.includes(clientIP)) { + console.error(`Unauthorized webhook request from IP: ${clientIP}`); return c.text('Unauthorized', 401); } + console.log(`Authorized webhook request from ForwardEmail.net (${clientIP})`); await next(); -}; - -// Apply auth middleware to admin routes -app.use('/admin/*', authMiddleware); - -// Also apply auth middleware to root path -app.use('/', authMiddleware); +}); // Route handlers app.post('/api/inbound', handleInbound); app.get('/rss/:feedId', handleRSS); app.route('/admin', handleAdmin); -// Root path uses admin handler -app.route('/', handleAdmin); +// Root path redirects to admin dashboard +app.get('/', (c) => c.redirect('/admin')); // Catch-all for 404s app.all('*', (c) => c.text('Not Found', 404)); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 4af0f9a..069bff7 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -1,7 +1,10 @@ import { Hono } from 'hono'; -import { html } from 'hono/html'; +import { html, raw } from 'hono/html'; import { z } from 'zod'; -import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData } from '../types'; +import { Env, FeedConfig, FeedList, FeedMetadata, EmailMetadata, EmailData, FeedListItem } from '../types'; +import { generateFeedId } from '../utils/id-generator'; +import { designSystem } from '../styles/index'; +import { interactiveScripts, authHelpers } from '../scripts/index'; // Create a Hono app for admin routes const app = new Hono(); @@ -20,6 +23,117 @@ const updateFeedSchema = z.object({ language: z.string().optional().default('en') }); +// Authentication schema +const authSchema = z.object({ + password: z.string().min(1, 'Password is required') +}); + +// Base HTML layout with design system +const layout = (title: string, content: any) => { + return html` + + + ${title} - Email to RSS Admin + + + + + + + ${content} + + `; +}; + +// Login page +app.get('/login', (c) => { + const error = c.req.query('error'); + const errorMessage = error === 'invalid' ? 'Invalid password. Please try again.' : ''; + + return c.html(layout('Login', html` +
+
+ +

Email to RSS Admin

+ ${errorMessage ? html`
${errorMessage}
` : ''} +
+
+ + +
+ +
+
+
+ `)); +}); + +// Handle login +app.post('/login', async (c) => { + const env = c.env as unknown as Env; + + try { + const formData = await c.req.formData(); + const password = formData.get('password')?.toString() || ''; + + // Validate password + authSchema.parse({ password }); + + // Check password against environment variable + if (password === env.ADMIN_PASSWORD) { + // Set a cookie for server-side authentication + c.header('Set-Cookie', `admin_auth=true; Path=/; HttpOnly; SameSite=Strict; Max-Age=${60 * 60 * 24 * 7}`); // 1 week + + // Also use localStorage for client-side checks + return c.html(html` + + `); + } else { + // Incorrect password - redirect back to login with an error message + return c.redirect('/admin/login?error=invalid'); + } + } catch (error) { + console.error('Login error:', error); + return c.redirect('/admin/login?error=invalid'); + } +}); + +// Logout route +app.get('/logout', (c) => { + return c.html(html` + + `); +}); + // Admin dashboard route app.get('/', async (c) => { // Type assertion for environment variables @@ -29,116 +143,33 @@ app.get('/', async (c) => { // List all feeds const feedList = await listAllFeeds(emailStorage); - return c.html( - html` - - - Email to RSS Admin - - - - - - -
+ // Fetch full feed configs to get descriptions + const feedsWithConfig = await Promise.all( + feedList.map(async (feed) => { + const configKey = `feed:${feed.id}:config`; + const config = await emailStorage.get(configKey, { type: 'json' }) as FeedConfig | null; + return { + ...feed, + description: config?.description || '' + }; + }) + ); + + return c.html(layout('Dashboard', html` +
+
+

Email to RSS Admin

Manage your email newsletter feeds

- +
+ Logout +
+
+ +

Create New Feed

-
+
@@ -149,40 +180,204 @@ app.get('/', async (c) => {
-
- - -
+
- -

Your Feeds

- ${feedList.length > 0 ? - html`
    - ${feedList.map(feed => html` -
  • -

    ${feed.title}

    -

    ${feed.description || 'No description'}

    -

    Email: newsletter-${feed.id}@${env.DOMAIN}

    -

    RSS Feed: https://api.${env.DOMAIN}/rss/${feed.id}

    -

    - Edit - View Emails - -

    -
  • - `)} -
` : - html`

You don't have any feeds yet. Create one above.

` +
+ +

Your Feeds

+ + ${feedsWithConfig.length > 0 ? + html`
    + ${feedsWithConfig.map((feed, index: number) => html` +
  • +
    +

    ${feed.title}

    + + ${feed.description ? + html`

    ${feed.description}

    ` : + html`

    No description

    ` + } + +
    +
    +
    + Email: +
    + ${feed.id}@${env.DOMAIN} +
    + + + + + + + +
    +
    +
    +
    + RSS Feed: +
    + https://${env.DOMAIN}/rss/${feed.id} +
    + + + + + + + +
    +
    +
    +
    +
    +
    + + View Emails +
    +
    + +
    +
    +
  • + `)} +
` : + html`

You don't have any feeds yet. Create one above.

` + } +
+ + + `)); }); // Create a new feed -app.post('/feeds', async (c) => { +app.post('/feeds/create', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; @@ -200,25 +395,28 @@ app.post('/feeds', async (c) => { language }); - // Generate a unique feed ID - const feedId = generateRandomId(); + // Generate a feed ID + const feedId = generateFeedId(); - // Store feed configuration - const feedConfigKey = `feed:${feedId}:config`; - await emailStorage.put(feedConfigKey, JSON.stringify({ + // Create feed configuration + const feedConfig: FeedConfig = { title: parsedData.title, description: parsedData.description, language: parsedData.language, - site_url: `https://api.${env.DOMAIN}/rss/${feedId}`, - feed_url: `https://api.${env.DOMAIN}/rss/${feedId}`, - created_at: Date.now() - })); + site_url: `https://${env.DOMAIN}/rss/${feedId}`, + feed_url: `https://${env.DOMAIN}/rss/${feedId}`, + created_at: Date.now(), + updated_at: Date.now() + }; - // Create empty metadata for the feed - const feedMetadataKey = `feed:${feedId}:metadata`; - await emailStorage.put(feedMetadataKey, JSON.stringify({ + // Create feed metadata + const feedMetadata: FeedMetadata = { emails: [] - })); + }; + + // Store feed configuration and metadata + await emailStorage.put(`feed:${feedId}:config`, JSON.stringify(feedConfig)); + await emailStorage.put(`feed:${feedId}:metadata`, JSON.stringify(feedMetadata)); // Add feed to the list of all feeds await addFeedToList(emailStorage, feedId, parsedData.title); @@ -231,7 +429,7 @@ app.post('/feeds', async (c) => { } }); -// Edit feed form +// Edit feed page app.get('/feeds/:feedId/edit', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; @@ -240,68 +438,24 @@ app.get('/feeds/:feedId/edit', async (c) => { // Get feed configuration const feedConfigKey = `feed:${feedId}:config`; - const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null; + const feedConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; if (!feedConfig) { return c.text('Feed not found', 404); } - return c.html( - html` - - - Edit Feed - Email to RSS Admin - - - - - -
-

Edit Feed

-

Back to Dashboard

+ return c.html(layout('Edit Feed', html` +
+
+
+

${feedConfig.title} - Edit Feed

- + +
+ +
@@ -313,16 +467,13 @@ app.get('/feeds/:feedId/edit', async (c) => {
-
- - -
+
- - ` - ); +
+
+ `)); }); // Update feed @@ -347,7 +498,7 @@ app.post('/feeds/:feedId/edit', async (c) => { // Get existing feed config const feedConfigKey = `feed:${feedId}:config`; - const existingConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null; + const existingConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; if (!existingConfig) { return c.text('Feed not found', 404); @@ -383,7 +534,7 @@ app.post('/feeds/:feedId/delete', async (c) => { try { // Get feed metadata to find all email keys const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = await emailStorage.get(feedMetadataKey, 'json') as FeedMetadata | null; + const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; if (!feedMetadata) { return c.text('Feed not found', 404); @@ -409,120 +560,106 @@ app.post('/feeds/:feedId/delete', async (c) => { } }); -// View emails for a feed +// View all emails for a feed app.get('/feeds/:feedId/emails', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; const emailStorage = env.EMAIL_STORAGE; const feedId = c.req.param('feedId'); - // Get feed configuration + // Get feed configuration and metadata const feedConfigKey = `feed:${feedId}:config`; - const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null; + const feedMetadataKey = `feed:${feedId}:metadata`; - if (!feedConfig) { + const feedConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; + const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; + + if (!feedConfig || !feedMetadata) { return c.text('Feed not found', 404); } - // Get feed metadata (list of emails) - const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = (await emailStorage.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata; - - return c.html( - html` - - - ${feedConfig.title} - Emails - - - - - -
+ return c.html(layout(`${feedConfig.title} - Emails`, html` +
+
+

${feedConfig.title} - Emails

-

Back to Dashboard

- + +
+ +
+

Feed Details

-

Email Address: newsletter-${feedId}@${env.DOMAIN}

-

RSS Feed: https://api.${env.DOMAIN}/rss/${feedId}

+
+ Email Address: +
+ ${feedId}@${env.DOMAIN} +
+ + + + + + + +
+
+
+
+ RSS Feed: +
+ https://${env.DOMAIN}/rss/${feedId} +
+ + + + + + + +
+
+
- -

Emails (${feedMetadata.emails.length})

- ${feedMetadata.emails.length > 0 ? - html`` : - html`

No emails received yet. Subscribe to newsletters using the email address above.

` - } - - - - ` - ); + } + `)} + + `)); }); // View email content @@ -532,15 +669,16 @@ app.get('/emails/:emailKey', async (c) => { const emailStorage = env.EMAIL_STORAGE; const emailKey = c.req.param('emailKey'); - // Get email content - const emailData = await emailStorage.get(emailKey, 'json') as EmailData | null; + // Get email data + const emailData = await emailStorage.get(emailKey, { type: 'json' }) as EmailData | null; if (!emailData) { return c.text('Email not found', 404); } - // Extract feed ID from the key - const feedId = emailKey.split(':')[1]; + // Extract feed ID from the key format (feed:ID:emails:timestamp) + const keyParts = emailKey.split(':'); + const feedId = keyParts[1]; // Create a sanitized HTML content with CSS for the iframe const htmlContent = ` @@ -551,11 +689,12 @@ app.get('/emails/:emailKey', async (c) => { @@ -581,122 +731,142 @@ app.get('/emails/:emailKey', async (c) => { return btoa(String.fromCharCode(...new Uint8Array(bytes))); })(); - return c.html( - html` - - - ${emailData.subject} - - - - - - -
-

${emailData.subject}

-

Back to Emails

+ return c.html(layout(`Email: ${emailData.subject}`, html` +
+
+
+

Email Content

- + +
+ +
- - + +
+
+ + + `)); }); -// Delete an email +// Delete email app.post('/emails/:emailKey/delete', async (c) => { // Type assertion for environment variables const env = c.env as unknown as Env; @@ -704,28 +874,29 @@ app.post('/emails/:emailKey/delete', async (c) => { const emailKey = c.req.param('emailKey'); try { - // Get the feed ID from form data - const formData = await c.req.formData(); - const feedId = formData.get('feedId')?.toString() || ''; + // Get feedId from query parameters instead of form data + const feedId = c.req.query('feedId'); if (!feedId) { - return c.text('Missing feed ID', 400); + return c.text('Feed ID is required', 400); } - // Delete the email from KV storage + // Delete the email await emailStorage.delete(emailKey); // Remove the email from the feed metadata const feedMetadataKey = `feed:${feedId}:metadata`; - const feedMetadata = (await emailStorage.get(feedMetadataKey, 'json') || { emails: [] }) as FeedMetadata; + const feedMetadata = await emailStorage.get(feedMetadataKey, { type: 'json' }) as FeedMetadata | null; - // Filter out the deleted email - feedMetadata.emails = feedMetadata.emails.filter(email => email.key !== emailKey); + if (feedMetadata) { + // Filter out the deleted email + feedMetadata.emails = feedMetadata.emails.filter(email => email.key !== emailKey); + + // Update feed metadata + await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); + } - // Update the feed metadata - await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata)); - - // Redirect back to the emails list + // Redirect back to the feed emails page return c.redirect(`/admin/feeds/${feedId}/emails`); } catch (error) { console.error('Error deleting email:', error); @@ -733,38 +904,12 @@ app.post('/emails/:emailKey/delete', async (c) => { } }); -// Helper function to generate a random feed ID -function generateRandomId(length = 8): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < length; i++) { - result += chars.charAt(Math.floor(Math.random() * chars.length)); - } - return result; -} - // Helper function to list all feeds -async function listAllFeeds(emailStorage: KVNamespace): Promise { +async function listAllFeeds(emailStorage: KVNamespace): Promise { try { const feedListKey = 'feeds:list'; - const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] }; - - // Fetch detailed information for each feed - const feeds = []; - for (const feed of feedList.feeds) { - const feedConfigKey = `feed:${feed.id}:config`; - const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null; - - if (feedConfig) { - feeds.push({ - id: feed.id, - title: feedConfig.title, - description: feedConfig.description - }); - } - } - - return feeds; + const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null; + return feedList?.feeds || []; } catch (error) { console.error('Error listing feeds:', error); return []; @@ -773,39 +918,103 @@ async function listAllFeeds(emailStorage: KVNamespace): Promise { // Helper function to add a feed to the list of all feeds async function addFeedToList(emailStorage: KVNamespace, feedId: string, title: string): Promise { - const feedListKey = 'feeds:list'; - const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] }; - - feedList.feeds.push({ - id: feedId, - title - }); - - await emailStorage.put(feedListKey, JSON.stringify(feedList)); + try { + const feedListKey = 'feeds:list'; + const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null || { feeds: [] }; + + // Add new feed to the list + feedList.feeds.push({ + id: feedId, + title + }); + + // Store updated list + await emailStorage.put(feedListKey, JSON.stringify(feedList)); + } catch (error) { + console.error('Error adding feed to list:', error); + } } // Helper function to update a feed in the list of all feeds async function updateFeedInList(emailStorage: KVNamespace, feedId: string, title: string): Promise { - const feedListKey = 'feeds:list'; - const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] }; - - const feedIndex = feedList.feeds.findIndex((feed) => feed.id === feedId); - - if (feedIndex >= 0) { - feedList.feeds[feedIndex].title = title; - await emailStorage.put(feedListKey, JSON.stringify(feedList)); + try { + const feedListKey = 'feeds:list'; + const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null || { feeds: [] }; + + // Find and update the feed in the list + const feedIndex = feedList.feeds.findIndex(feed => feed.id === feedId); + if (feedIndex !== -1) { + feedList.feeds[feedIndex].title = title; + + // Store updated list + await emailStorage.put(feedListKey, JSON.stringify(feedList)); + } + } catch (error) { + console.error('Error updating feed in list:', error); } } // Helper function to remove a feed from the list of all feeds async function removeFeedFromList(emailStorage: KVNamespace, feedId: string): Promise { - const feedListKey = 'feeds:list'; - const feedList = await emailStorage.get(feedListKey, 'json') as FeedList | null || { feeds: [] }; - - feedList.feeds = feedList.feeds.filter((feed) => feed.id !== feedId); - - await emailStorage.put(feedListKey, JSON.stringify(feedList)); + try { + const feedListKey = 'feeds:list'; + const feedList = await emailStorage.get(feedListKey, { type: 'json' }) as FeedList | null || { feeds: [] }; + + // Filter out the removed feed + feedList.feeds = feedList.feeds.filter(feed => feed.id !== feedId); + + // Store updated list + await emailStorage.put(feedListKey, JSON.stringify(feedList)); + } catch (error) { + console.error('Error removing feed from list:', error); + } } +// Update feed via API (for in-place editing) +app.post('/api/feeds/:feedId/update', async (c) => { + // Type assertion for environment variables + const env = c.env as unknown as Env; + const emailStorage = env.EMAIL_STORAGE; + const feedId = c.req.param('feedId'); + + try { + // Parse JSON data from request + const data = await c.req.json(); + const { title, description } = data; + + // Validate inputs + const parsedData = updateFeedSchema.parse({ + title, + description, + language: 'en' // We're defaulting to English + }); + + // Get existing feed config + const feedConfigKey = `feed:${feedId}:config`; + const existingConfig = await emailStorage.get(feedConfigKey, { type: 'json' }) as FeedConfig | null; + + if (!existingConfig) { + return c.json({ error: 'Feed not found' }, 404); + } + + // Update feed configuration + await emailStorage.put(feedConfigKey, JSON.stringify({ + ...existingConfig, + title: parsedData.title, + description: parsedData.description, + updated_at: Date.now() + })); + + // Update feed in the list of all feeds + await updateFeedInList(emailStorage, feedId, parsedData.title); + + // Return success response + return c.json({ success: true }); + } catch (error) { + console.error('Error updating feed via API:', error); + return c.json({ error: 'Error updating feed' }, 400); + } +}); + // Export the Hono app export const handle = app; \ No newline at end of file diff --git a/src/routes/inbound.ts b/src/routes/inbound.ts index 6a3948a..6b59c68 100644 --- a/src/routes/inbound.ts +++ b/src/routes/inbound.ts @@ -40,7 +40,7 @@ export async function handle(c: Context): Promise { 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 feedId = EmailParser.extractFeedId(toAddress); @@ -49,6 +49,15 @@ export async function handle(c: Context): Promise { 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 const emailData = EmailParser.parseForwardEmailPayload(payload); diff --git a/src/routes/rss.ts b/src/routes/rss.ts index 33f7d01..b9b195c 100644 --- a/src/routes/rss.ts +++ b/src/routes/rss.ts @@ -33,8 +33,8 @@ export async function handle(c: Context): Promise { const feedConfig = await emailStorage.get(feedConfigKey, 'json') as FeedConfig | null || { title: `Newsletter Feed ${feedId}`, description: 'Converted email newsletter', - site_url: `https://api.${env.DOMAIN}/rss/${feedId}`, - feed_url: `https://api.${env.DOMAIN}/rss/${feedId}`, + site_url: `https://${env.DOMAIN}/rss/${feedId}`, + feed_url: `https://${env.DOMAIN}/rss/${feedId}`, language: 'en', created_at: Date.now() }; @@ -52,7 +52,7 @@ export async function handle(c: Context): Promise { } // Generate the RSS feed XML - const baseUrl = `https://api.${env.DOMAIN}`; + const baseUrl = `https://${env.DOMAIN}`; const rssXml = generateRssFeed(feedConfig, emailsData, baseUrl); // Return the RSS feed with appropriate content type diff --git a/src/scripts/auth.ts b/src/scripts/auth.ts new file mode 100644 index 0000000..9a92472 --- /dev/null +++ b/src/scripts/auth.ts @@ -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'; + } + }); +`; \ No newline at end of file diff --git a/src/scripts/clipboard.ts b/src/scripts/clipboard.ts new file mode 100644 index 0000000..9f70778 --- /dev/null +++ b/src/scripts/clipboard.ts @@ -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(); + } + } +`; \ No newline at end of file diff --git a/src/scripts/index.ts b/src/scripts/index.ts new file mode 100644 index 0000000..17a50ad --- /dev/null +++ b/src/scripts/index.ts @@ -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 }; \ No newline at end of file diff --git a/src/scripts/interactions.ts b/src/scripts/interactions.ts new file mode 100644 index 0000000..f022f46 --- /dev/null +++ b/src/scripts/interactions.ts @@ -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); +`; \ No newline at end of file diff --git a/src/styles/components.ts b/src/styles/components.ts new file mode 100644 index 0000000..92cd2a3 --- /dev/null +++ b/src/styles/components.ts @@ -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; + } +`; \ No newline at end of file diff --git a/src/styles/design-system.ts b/src/styles/design-system.ts new file mode 100644 index 0000000..2b2b100 --- /dev/null +++ b/src/styles/design-system.ts @@ -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 }; \ No newline at end of file diff --git a/src/styles/index.ts b/src/styles/index.ts new file mode 100644 index 0000000..811327a --- /dev/null +++ b/src/styles/index.ts @@ -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 }; \ No newline at end of file diff --git a/src/styles/layout.ts b/src/styles/layout.ts new file mode 100644 index 0000000..d747862 --- /dev/null +++ b/src/styles/layout.ts @@ -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); + } +`; \ No newline at end of file diff --git a/src/styles/utilities.ts b/src/styles/utilities.ts new file mode 100644 index 0000000..12612af --- /dev/null +++ b/src/styles/utilities.ts @@ -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); + } +`; \ No newline at end of file diff --git a/src/styles/variables.ts b/src/styles/variables.ts new file mode 100644 index 0000000..953d2c8 --- /dev/null +++ b/src/styles/variables.ts @@ -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'); +`; \ No newline at end of file diff --git a/src/utils/email-parser.ts b/src/utils/email-parser.ts index 8ee1222..03b6a32 100644 --- a/src/utils/email-parser.ts +++ b/src/utils/email-parser.ts @@ -6,11 +6,12 @@ import { EmailData } from '../types'; export class EmailParser { /** * 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 */ 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; } diff --git a/src/utils/id-generator.ts b/src/utils/id-generator.ts new file mode 100644 index 0000000..5e3a5cf --- /dev/null +++ b/src/utils/id-generator.ts @@ -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}`; +} \ No newline at end of file diff --git a/wrangler.toml b/wrangler.toml index e909dd8..59c1b09 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -23,8 +23,6 @@ workers_dev = true # Add any production-specific configuration here workers_dev = false routes = [ - "https://api.getmynews.app/api/*", - "https://api.getmynews.app/rss/*", "https://getmynews.app/*", "https://www.getmynews.app/*" ] \ No newline at end of file