mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 19:53:45 -07:00
refactor code
This commit is contained in:
parent
e66faac5b0
commit
530866ae9d
4 changed files with 179 additions and 75 deletions
|
|
@ -17,7 +17,6 @@
|
|||
"@floating-ui/dom": "^1.6.13",
|
||||
"@types/node": "^22.13.9",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/kv": "^3.0.0",
|
||||
"astro": "^5.4.2",
|
||||
"feed": "^4.2.2",
|
||||
"ioredis": "^5.6.0",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
134
src/lib/db.ts
134
src/lib/db.ts
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue