refactor code

This commit is contained in:
ld3zian 2025-03-11 13:59:35 -07:00
parent e66faac5b0
commit 530866ae9d
4 changed files with 179 additions and 75 deletions

View file

@ -17,7 +17,6 @@
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
"@types/node": "^22.13.9", "@types/node": "^22.13.9",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@vercel/kv": "^3.0.0",
"astro": "^5.4.2", "astro": "^5.4.2",
"feed": "^4.2.2", "feed": "^4.2.2",
"ioredis": "^5.6.0", "ioredis": "^5.6.0",

View file

@ -7,13 +7,28 @@ interface Props {
const { announcement } = Astro.props; const { announcement } = Astro.props;
const bgColorClass = { // Define colors based on announcement type
info: 'bg-[rgb(var(--accent))] text-[rgb(var(--accent-text))]', const typeStyles = {
warning: 'bg-yellow-500 text-white', info: {
success: 'bg-emerald-500 text-white' bgColor: 'bg-[rgb(var(--accent))]',
}[announcement.type]; textColor: 'text-[rgb(var(--accent-text))]',
icon: 'info-icon'
},
warning: {
bgColor: 'bg-yellow-500',
textColor: 'text-white',
icon: 'warning-icon'
},
success: {
bgColor: 'bg-emerald-500',
textColor: 'text-white',
icon: 'success-icon'
}
};
// Create a simple hash of the announcement content const { bgColor, textColor, icon } = typeStyles[announcement.type];
// Create a simple hash of the announcement content for localStorage key
const announcementHash = btoa(JSON.stringify({ const announcementHash = btoa(JSON.stringify({
message: announcement.message, message: announcement.message,
type: announcement.type, type: announcement.type,
@ -21,44 +36,79 @@ const announcementHash = btoa(JSON.stringify({
})); }));
--- ---
<div class={`announcement-banner hidden w-full ${bgColorClass} px-6 py-4 shadow-lg`}> <div
class={`announcement-banner hidden w-full ${bgColor} ${textColor} px-6 py-4 shadow-lg`}
role="alert"
aria-live="polite"
>
<div class="container mx-auto flex items-center justify-between"> <div class="container mx-auto flex items-center justify-between">
<div class="flex items-center gap-3 text-lg"> <div class="flex items-center gap-3 text-lg">
{/* Info Icon */}
{announcement.type === 'info' && ( {announcement.type === 'info' && (
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
</svg> stroke-linecap="round"
)} stroke-linejoin="round"
{announcement.type === 'warning' && ( stroke-width="2"
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> />
</svg>
)}
{announcement.type === 'success' && (
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
)} )}
{/* Warning Icon */}
{announcement.type === 'warning' && (
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
)}
{/* Success Icon */}
{announcement.type === 'success' && (
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
)}
{/* Message */}
<span class="font-medium">{announcement.message}</span> <span class="font-medium">{announcement.message}</span>
{/* Link if provided */}
{announcement.link && ( {announcement.link && (
<a <a
href={announcement.link.url} href={announcement.link.url}
class="underline hover:opacity-80 transition-opacity font-semibold ml-2" class="underline hover:opacity-80 transition-opacity font-semibold ml-2"
target="_blank"
rel="noopener noreferrer"
> >
{announcement.link.text} → {announcement.link.text} →
</a> </a>
)} )}
</div> </div>
{/* Dismiss button if announcement is dismissible */}
{announcement.dismissible && ( {announcement.dismissible && (
<button <button
class="dismiss-announcement p-2 hover:bg-black/10 rounded-full transition-colors" class="dismiss-announcement p-2 hover:bg-black/10 rounded-full transition-colors"
aria-label="Dismiss announcement" aria-label="Dismiss announcement"
title="Dismiss"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> <path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg> </svg>
</button> </button>
)} )}
@ -66,11 +116,14 @@ const announcementHash = btoa(JSON.stringify({
</div> </div>
<script define:vars={{ dismissible: announcement.dismissible, announcementHash }}> <script define:vars={{ dismissible: announcement.dismissible, announcementHash }}>
// DOM elements
const banner = document.querySelector('.announcement-banner'); const banner = document.querySelector('.announcement-banner');
const dismissBtn = document.querySelector('.dismiss-announcement'); const dismissBtn = document.querySelector('.dismiss-announcement');
const storageKey = `announcement-dismissed-${announcementHash}`; const storageKey = `announcement-dismissed-${announcementHash}`;
// Check if announcement should be shown /**
* Check if the announcement should be shown based on localStorage
*/
function checkVisibility() { function checkVisibility() {
// Only check localStorage if the banner is dismissible // Only check localStorage if the banner is dismissible
const isDismissed = dismissible ? const isDismissed = dismissible ?
@ -82,20 +135,22 @@ const announcementHash = btoa(JSON.stringify({
} }
} }
// Run after DOM is loaded // Run after DOM is loaded to prevent flash of hidden content
document.addEventListener('DOMContentLoaded', checkVisibility); document.addEventListener('DOMContentLoaded', checkVisibility);
dismissBtn?.addEventListener('click', () => { // Add click handler for dismiss button if present
if (dismissible) { if (dismissBtn && dismissible) {
dismissBtn.addEventListener('click', () => {
banner?.classList.add('hidden'); banner?.classList.add('hidden');
localStorage.setItem(storageKey, 'true'); localStorage.setItem(storageKey, 'true');
} });
}); }
</script> </script>
<style> <style>
.announcement-banner { .announcement-banner {
animation: slideDown 0.5s ease-out; animation: slideDown 0.5s ease-out;
z-index: 50;
} }
@keyframes slideDown { @keyframes slideDown {

View file

@ -2,11 +2,18 @@ import Redis from "ioredis";
import type { FeedMapping } from "./types"; import type { FeedMapping } from "./types";
import { getChaptersForSlugs } from "./api"; import { getChaptersForSlugs } from "./api";
// Redis URLs // Configuration constants
const DEFAULT_CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 50;
const IS_VERCEL = process.env.VERCEL || false;
// Redis URLs from environment variables
const PRIMARY_REDIS_URL = const PRIMARY_REDIS_URL =
import.meta.env.PRIMARY_REDIS_URL || import.meta.env.PRIMARY_REDIS_URL ||
process.env.PRIMARY_REDIS_URL || process.env.PRIMARY_REDIS_URL ||
process.env.REDIS_URL; process.env.REDIS_URL;
const REPLICA_URLS = [ const REPLICA_URLS = [
import.meta.env.REPLICA_REDIS_URL_1 || import.meta.env.REPLICA_REDIS_URL_1 ||
process.env.REPLICA_REDIS_URL_1 || process.env.REPLICA_REDIS_URL_1 ||
@ -15,31 +22,43 @@ const REPLICA_URLS = [
import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3, import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
const IS_VERCEL = process.env.VERCEL || false;
const DEFAULT_CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
const MAX_RETRIES = 3; // Reduced retries to avoid Vercel timeout
const BASE_DELAY_MS = 50; // Faster retries for serverless
// Singleton clients // Singleton clients
let primaryClient: Redis | null = null; let primaryClient: Redis | null = null;
let replicaClients: Map<string, Redis> = new Map(); let replicaClients: Map<string, Redis> = new Map();
// Redis instance naming for logging // Redis instance naming for logging
const redisInstanceNames = new Map<string, string>(); const redisInstanceNames = new Map<string, string>();
/**
* Initialize Redis instance names for better logging
*/
function initializeRedisNames() { function initializeRedisNames() {
redisInstanceNames.set(PRIMARY_REDIS_URL!, "Primary"); if (PRIMARY_REDIS_URL) {
redisInstanceNames.set(PRIMARY_REDIS_URL, "Primary");
}
REPLICA_URLS.forEach((url, index) => REPLICA_URLS.forEach((url, index) =>
redisInstanceNames.set(url, `Replica ${index + 1}`) redisInstanceNames.set(url, `Replica ${index + 1}`)
); );
} }
initializeRedisNames(); initializeRedisNames();
/**
* Get the friendly name of a Redis instance
*/
const getRedisName = (url: string) => redisInstanceNames.get(url) || "Unknown"; const getRedisName = (url: string) => redisInstanceNames.get(url) || "Unknown";
// Create Redis client with Vercel-optimized settings /**
* Create a new Redis client with optimized settings
*/
async function createRedisClient(url: string): Promise<Redis> { async function createRedisClient(url: string): Promise<Redis> {
if (!url) throw new Error("Redis URL not set"); if (!url) {
throw new Error("Redis URL not provided");
}
const redisName = getRedisName(url); const redisName = getRedisName(url);
const client = new Redis(url, { const client = new Redis(url, {
retryStrategy: (times) => { retryStrategy: (times) => {
if (times >= MAX_RETRIES) return null; if (times >= MAX_RETRIES) return null;
@ -54,13 +73,14 @@ async function createRedisClient(url: string): Promise<Redis> {
enableReadyCheck: true, enableReadyCheck: true,
connectTimeout: IS_VERCEL ? 5000 : 10000, // 5s for Vercel, 10s local connectTimeout: IS_VERCEL ? 5000 : 10000, // 5s for Vercel, 10s local
keepAlive: IS_VERCEL ? 2000 : 10000, // 2s ping for Vercel keepAlive: IS_VERCEL ? 2000 : 10000, // 2s ping for Vercel
commandTimeout: 5000, // Align with Vercel’s Hobby tier timeout commandTimeout: 5000, // Align with Vercel's timeout
reconnectOnError: (err) => reconnectOnError: (err) =>
["READONLY", "ETIMEDOUT", "ECONNRESET"].some((e) => ["READONLY", "ETIMEDOUT", "ECONNRESET"].some((e) =>
err.message.includes(e) err.message.includes(e)
), ),
}); });
// Setup event listeners
client client
.on("ready", () => console.log(`${redisName} ready`)) .on("ready", () => console.log(`${redisName} ready`))
.on("error", (err) => console.error(`${redisName} error:`, err)) .on("error", (err) => console.error(`${redisName} error:`, err))
@ -70,28 +90,36 @@ async function createRedisClient(url: string): Promise<Redis> {
return client; return client;
} }
// Get or create a Redis client /**
async function getRedisClient(): Promise<Redis> { * Check if a Redis client is connected and responsive
const checkConnection = async (client: Redis, name: string) => { */
try { async function checkConnection(client: Redis, name: string): Promise<boolean> {
await Promise.race([ try {
client.ping(), await Promise.race([
new Promise((_, reject) => client.ping(),
setTimeout(() => reject(new Error("Ping timeout")), 2000) new Promise((_, reject) =>
), setTimeout(() => reject(new Error("Ping timeout")), 2000)
]); ),
return true; ]);
} catch (err) { return true;
console.warn(`${name} ping failed:`, err); } catch (err) {
return false; console.warn(`${name} ping failed:`, err);
} return false;
}; }
}
/**
* Get an active Redis client from the pool
*/
async function getRedisClient(): Promise<Redis> {
// Try primary client // Try primary client
if (!primaryClient || primaryClient.status !== "ready") { if (!primaryClient || primaryClient.status !== "ready") {
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!); primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
} }
if (await checkConnection(primaryClient, "Primary")) return primaryClient;
if (await checkConnection(primaryClient, "Primary")) {
return primaryClient;
}
// Try replicas // Try replicas
for (const url of REPLICA_URLS) { for (const url of REPLICA_URLS) {
@ -100,7 +128,10 @@ async function getRedisClient(): Promise<Redis> {
client = await createRedisClient(url); client = await createRedisClient(url);
replicaClients.set(url, client); replicaClients.set(url, client);
} }
if (await checkConnection(client, getRedisName(url))) return client;
if (await checkConnection(client, getRedisName(url))) {
return client;
}
} }
// Fallback: Force new primary connection // Fallback: Force new primary connection
@ -108,7 +139,9 @@ async function getRedisClient(): Promise<Redis> {
return primaryClient; return primaryClient;
} }
// Write to all Redis instances /**
* Write data to all available Redis instances
*/
async function writeToAllRedis( async function writeToAllRedis(
operation: (redis: Redis) => Promise<any> operation: (redis: Redis) => Promise<any>
): Promise<void> { ): Promise<void> {
@ -116,6 +149,7 @@ async function writeToAllRedis(
primaryClient, primaryClient,
...Array.from(replicaClients.values()), ...Array.from(replicaClients.values()),
].filter(Boolean) as Redis[]; ].filter(Boolean) as Redis[];
const errors: Error[] = []; const errors: Error[] = [];
for (const client of clients) { for (const client of clients) {
@ -132,33 +166,55 @@ async function writeToAllRedis(
} }
} }
if (errors.length === clients.length) { if (errors.length === clients.length && clients.length > 0) {
throw new Error("All Redis writes failed"); throw new Error("All Redis writes failed");
} }
} }
// Your existing functions (unchanged except for using getRedisClient/writeToAllRedis) /**
* Store a feed mapping in Redis
*/
export async function storeFeedMapping( export async function storeFeedMapping(
feedId: string, feedId: string,
slugs: string[], slugs: string[],
lang: string lang: string
): Promise<void> { ): Promise<void> {
const mapping = { slugs, lang, created_at: new Date().toISOString() }; const mapping = {
slugs,
lang,
created_at: new Date().toISOString()
};
await writeToAllRedis(async (redis) => await writeToAllRedis(async (redis) =>
redis.set(feedId, JSON.stringify(mapping), "EX", DEFAULT_CACHE_TTL) redis.set(feedId, JSON.stringify(mapping), "EX", DEFAULT_CACHE_TTL)
); );
} }
/**
* Retrieve a feed mapping from Redis
*/
export async function getFeedMapping( export async function getFeedMapping(
feedId: string feedId: string
): Promise<FeedMapping | null> { ): Promise<FeedMapping | null> {
const redis = await getRedisClient(); try {
const mapping = await redis.get(feedId); const redis = await getRedisClient();
if (!mapping) return null; const mapping = await redis.get(feedId);
await redis.expire(feedId, DEFAULT_CACHE_TTL);
const data = JSON.parse(mapping); if (!mapping) return null;
return { slugs: data.slugs, lang: data.lang };
// Reset expiration on access
await redis.expire(feedId, DEFAULT_CACHE_TTL);
const data = JSON.parse(mapping);
return {
slugs: data.slugs,
lang: data.lang
};
} catch (error) {
console.error(`Error retrieving feed mapping for ${feedId}:`, error);
return null;
}
} }
// Initialize connections lazily (no blocking startup) // Initialize connections lazily
console.log("Redis will connect on first use"); console.log("Redis will connect on first use");

View file

@ -2,9 +2,6 @@ import type { APIRoute } from "astro";
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { storeFeedMapping } from '../../lib/db'; import { storeFeedMapping } from '../../lib/db';
// In-memory storage (replace with database in production)
const feedMappings = new Map<string, { slugs: string[], lang: string }>();
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
try { try {
const { slugs, lang } = await request.json(); const { slugs, lang } = await request.json();
@ -17,7 +14,7 @@ export const POST: APIRoute = async ({ request }) => {
const dataString = `${slugs.sort().join(',')}:${lang}`; const dataString = `${slugs.sort().join(',')}:${lang}`;
const hash = createHash('md5').update(dataString).digest('hex').slice(0, 8); const hash = createHash('md5').update(dataString).digest('hex').slice(0, 8);
// Store the mapping in Vercel KV // Store the mapping in Redis
await storeFeedMapping(hash, slugs, lang); await storeFeedMapping(hash, slugs, lang);
return new Response(JSON.stringify({ feedId: hash }), { return new Response(JSON.stringify({ feedId: hash }), {
@ -30,7 +27,4 @@ export const POST: APIRoute = async ({ request }) => {
console.error('Error creating feed:', error); console.error('Error creating feed:', error);
return new Response("Internal server error", { status: 500 }); return new Response("Internal server error", { status: 500 });
} }
}; };
// Export the mappings for use in other files
export { feedMappings };