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

@ -7,13 +7,28 @@ interface Props {
const { announcement } = Astro.props;
const bgColorClass = {
info: 'bg-[rgb(var(--accent))] text-[rgb(var(--accent-text))]',
warning: 'bg-yellow-500 text-white',
success: 'bg-emerald-500 text-white'
}[announcement.type];
// Define colors based on announcement type
const typeStyles = {
info: {
bgColor: 'bg-[rgb(var(--accent))]',
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({
message: announcement.message,
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="flex items-center gap-3 text-lg">
{/* Info Icon */}
{announcement.type === 'info' && (
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{announcement.type === 'warning' && (
<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="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 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"
/>
</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>
{/* Link if provided */}
{announcement.link && (
<a
href={announcement.link.url}
class="underline hover:opacity-80 transition-opacity font-semibold ml-2"
target="_blank"
rel="noopener noreferrer"
>
{announcement.link.text} →
</a>
)}
</div>
{/* Dismiss button if announcement is dismissible */}
{announcement.dismissible && (
<button
class="dismiss-announcement p-2 hover:bg-black/10 rounded-full transition-colors"
aria-label="Dismiss announcement"
title="Dismiss"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
<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"
/>
</svg>
</button>
)}
@ -66,11 +116,14 @@ const announcementHash = btoa(JSON.stringify({
</div>
<script define:vars={{ dismissible: announcement.dismissible, announcementHash }}>
// DOM elements
const banner = document.querySelector('.announcement-banner');
const dismissBtn = document.querySelector('.dismiss-announcement');
const storageKey = `announcement-dismissed-${announcementHash}`;
// Check if announcement should be shown
/**
* Check if the announcement should be shown based on localStorage
*/
function checkVisibility() {
// Only check localStorage if the banner is 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);
dismissBtn?.addEventListener('click', () => {
if (dismissible) {
// Add click handler for dismiss button if present
if (dismissBtn && dismissible) {
dismissBtn.addEventListener('click', () => {
banner?.classList.add('hidden');
localStorage.setItem(storageKey, 'true');
}
});
});
}
</script>
<style>
.announcement-banner {
animation: slideDown 0.5s ease-out;
z-index: 50;
}
@keyframes slideDown {

View file

@ -2,11 +2,18 @@ import Redis from "ioredis";
import type { FeedMapping } from "./types";
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 =
import.meta.env.PRIMARY_REDIS_URL ||
process.env.PRIMARY_REDIS_URL ||
process.env.REDIS_URL;
const REPLICA_URLS = [
import.meta.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,
].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
let primaryClient: Redis | null = null;
let replicaClients: Map<string, Redis> = new Map();
// Redis instance naming for logging
const redisInstanceNames = new Map<string, string>();
/**
* Initialize Redis instance names for better logging
*/
function initializeRedisNames() {
redisInstanceNames.set(PRIMARY_REDIS_URL!, "Primary");
if (PRIMARY_REDIS_URL) {
redisInstanceNames.set(PRIMARY_REDIS_URL, "Primary");
}
REPLICA_URLS.forEach((url, index) =>
redisInstanceNames.set(url, `Replica ${index + 1}`)
);
}
initializeRedisNames();
/**
* Get the friendly name of a Redis instance
*/
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> {
if (!url) throw new Error("Redis URL not set");
if (!url) {
throw new Error("Redis URL not provided");
}
const redisName = getRedisName(url);
const client = new Redis(url, {
retryStrategy: (times) => {
if (times >= MAX_RETRIES) return null;
@ -54,13 +73,14 @@ async function createRedisClient(url: string): Promise<Redis> {
enableReadyCheck: true,
connectTimeout: IS_VERCEL ? 5000 : 10000, // 5s for Vercel, 10s local
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) =>
["READONLY", "ETIMEDOUT", "ECONNRESET"].some((e) =>
err.message.includes(e)
),
});
// Setup event listeners
client
.on("ready", () => console.log(`${redisName} ready`))
.on("error", (err) => console.error(`${redisName} error:`, err))
@ -70,28 +90,36 @@ async function createRedisClient(url: string): Promise<Redis> {
return client;
}
// Get or create a Redis client
async function getRedisClient(): Promise<Redis> {
const checkConnection = async (client: Redis, name: string) => {
try {
await Promise.race([
client.ping(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Ping timeout")), 2000)
),
]);
return true;
} catch (err) {
console.warn(`${name} ping failed:`, err);
return false;
}
};
/**
* Check if a Redis client is connected and responsive
*/
async function checkConnection(client: Redis, name: string): Promise<boolean> {
try {
await Promise.race([
client.ping(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Ping timeout")), 2000)
),
]);
return true;
} catch (err) {
console.warn(`${name} ping failed:`, err);
return false;
}
}
/**
* Get an active Redis client from the pool
*/
async function getRedisClient(): Promise<Redis> {
// Try primary client
if (!primaryClient || primaryClient.status !== "ready") {
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
}
if (await checkConnection(primaryClient, "Primary")) return primaryClient;
if (await checkConnection(primaryClient, "Primary")) {
return primaryClient;
}
// Try replicas
for (const url of REPLICA_URLS) {
@ -100,7 +128,10 @@ async function getRedisClient(): Promise<Redis> {
client = await createRedisClient(url);
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
@ -108,7 +139,9 @@ async function getRedisClient(): Promise<Redis> {
return primaryClient;
}
// Write to all Redis instances
/**
* Write data to all available Redis instances
*/
async function writeToAllRedis(
operation: (redis: Redis) => Promise<any>
): Promise<void> {
@ -116,6 +149,7 @@ async function writeToAllRedis(
primaryClient,
...Array.from(replicaClients.values()),
].filter(Boolean) as Redis[];
const errors: Error[] = [];
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");
}
}
// Your existing functions (unchanged except for using getRedisClient/writeToAllRedis)
/**
* Store a feed mapping in Redis
*/
export async function storeFeedMapping(
feedId: string,
slugs: string[],
lang: string
): Promise<void> {
const mapping = { slugs, lang, created_at: new Date().toISOString() };
const mapping = {
slugs,
lang,
created_at: new Date().toISOString()
};
await writeToAllRedis(async (redis) =>
redis.set(feedId, JSON.stringify(mapping), "EX", DEFAULT_CACHE_TTL)
);
}
/**
* Retrieve a feed mapping from Redis
*/
export async function getFeedMapping(
feedId: string
): Promise<FeedMapping | null> {
const redis = await getRedisClient();
const mapping = await redis.get(feedId);
if (!mapping) return null;
await redis.expire(feedId, DEFAULT_CACHE_TTL);
const data = JSON.parse(mapping);
return { slugs: data.slugs, lang: data.lang };
try {
const redis = await getRedisClient();
const mapping = await redis.get(feedId);
if (!mapping) return null;
// 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");

View file

@ -2,9 +2,6 @@ import type { APIRoute } from "astro";
import { createHash } from 'crypto';
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 }) => {
try {
const { slugs, lang } = await request.json();
@ -17,7 +14,7 @@ export const POST: APIRoute = async ({ request }) => {
const dataString = `${slugs.sort().join(',')}:${lang}`;
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);
return new Response(JSON.stringify({ feedId: hash }), {
@ -30,7 +27,4 @@ export const POST: APIRoute = async ({ request }) => {
console.error('Error creating feed:', error);
return new Response("Internal server error", { status: 500 });
}
};
// Export the mappings for use in other files
export { feedMappings };
};