This commit is contained in:
ld3zian 2025-03-28 20:14:18 -07:00
parent 2f342807c5
commit 69b9e24d19
12 changed files with 1569 additions and 1278 deletions

878
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -12,15 +12,16 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/rss": "^4.0.11", "@astrojs/rss": "^4.0.11",
"@astrojs/tailwind": "^6.0.0", "@astrojs/tailwind": "^6.0.1",
"@astrojs/vercel": "^8.1.0", "@astrojs/vercel": "^8.1.3",
"@floating-ui/dom": "^1.6.13", "@floating-ui/dom": "^1.6.13",
"@types/node": "^22.13.9", "@types/node": "^22.13.11",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@vercel/kv": "^3.0.0", "astro": "^5.5.4",
"astro": "^5.4.1",
"feed": "^4.2.2", "feed": "^4.2.2",
"ioredis": "^5.5.0", "ioredis": "^5.6.0",
"p-throttle": "^7.0.0",
"redis-modules-sdk": "^1.4.0",
"tailwindcss": "^3.4.1" "tailwindcss": "^3.4.1"
} }
} }

BIN
public/cynthia1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

BIN
public/darling.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

BIN
public/granny.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 817 KiB

BIN
public/numi_lily.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

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

@ -1,25 +1,17 @@
--- ---
import { getFeedMapping } from '../lib/db'; import { getFeedMapping } from "../lib/db";
interface Props { interface Props {
currentSlugs?: string[]; currentSlugs?: string[];
} }
const { currentSlugs = [] } = Astro.props; const { currentSlugs = [] } = Astro.props;
import { availableLanguages, languageMap } from '../lib/api'; import { availableLanguages, languageMap } from "../lib/api";
const languages = [ const languages = Object.entries(languageMap).map(([value, label]) => ({
{ value: 'en', label: 'English' }, value,
{ value: 'ja', label: 'Japanese' }, label,
{ value: 'ko', label: 'Korean' }, }));
{ value: 'zh', label: 'Chinese' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
{ value: 'de', label: 'German' },
{ value: 'tr', label: 'Turkish' },
{ value: 'pl', label: 'Polish' },
];
--- ---
<div class="space-y-4 sm:space-y-6"> <div class="space-y-4 sm:space-y-6">
@ -37,9 +29,11 @@ const languages = [
id="lang-select" id="lang-select"
class="px-3 sm:px-4 py-2 bg-primary rounded-lg border border-hover/30 focus:outline-none focus:border-accent/50 transition-colors text-sm sm:text-base" class="px-3 sm:px-4 py-2 bg-primary rounded-lg border border-hover/30 focus:outline-none focus:border-accent/50 transition-colors text-sm sm:text-base"
> >
{languages.map(lang => ( {
<option value={lang.value}>{lang.label}</option> languages.map((lang) => (
))} <option value={lang.value}>{lang.label}</option>
))
}
</select> </select>
</div> </div>
@ -54,8 +48,16 @@ const languages = [
id="import-feed" id="import-feed"
class="px-4 py-2 bg-secondary hover:bg-hover transition-colors rounded-lg text-sm sm:text-base flex items-center gap-2" class="px-4 py-2 bg-secondary hover:bg-hover transition-colors rounded-lg text-sm sm:text-base flex items-center gap-2"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/> class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"
></path>
</svg> </svg>
Import Feed Import Feed
</button> </button>
@ -73,27 +75,46 @@ const languages = [
class="px-3 py-1.5 text-sm text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded-lg transition-colors flex items-center gap-2" class="px-3 py-1.5 text-sm text-red-500 hover:text-red-600 hover:bg-red-500/10 rounded-lg transition-colors flex items-center gap-2"
style="display: none;" style="display: none;"
> >
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg> </svg>
Clear All Clear All
</button> </button>
</div> </div>
<div id="slug-list" class="space-y-2"> <div id="slug-list" class="space-y-2">
{currentSlugs.map(slug => ( {
<div class="flex items-center justify-between p-3 bg-primary rounded-lg border border-hover/30" data-slug={slug}> currentSlugs.map((slug) => (
<span class="text-primary truncate pr-2">{slug}</span> <div
<button class="flex items-center justify-between p-3 bg-primary rounded-lg border border-hover/30"
class="text-red-500 hover:text-red-600 transition-colors flex-shrink-0 pl-2" data-slug={slug}
data-remove={slug}
> >
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <span class="text-primary truncate pr-2">{slug}</span>
<path d="M6 18L18 6M6 6l12 12"></path> <button
</svg> class="text-red-500 hover:text-red-600 transition-colors flex-shrink-0 pl-2"
</button> data-remove={slug}
</div> >
))} <svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))
}
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
@ -123,20 +144,26 @@ const languages = [
</div> </div>
<script> <script>
const searchInput = document.getElementById('search-input') as HTMLInputElement; const searchInput = document.getElementById(
const searchResults = document.getElementById('search-results'); "search-input"
const slugList = document.getElementById('slug-list'); ) as HTMLInputElement;
const generateRSSLink = document.getElementById('generate-rss'); const searchResults = document.getElementById("search-results");
const langSelect = document.getElementById('lang-select') as HTMLSelectElement; const slugList = document.getElementById("slug-list");
const feedIdInput = document.getElementById('feed-id-input') as HTMLInputElement; const generateRSSLink = document.getElementById("generate-rss");
const importFeedButton = document.getElementById('import-feed'); const langSelect = document.getElementById(
const clearAllButton = document.getElementById('clear-all'); "lang-select"
) as HTMLSelectElement;
const feedIdInput = document.getElementById(
"feed-id-input"
) as HTMLInputElement;
const importFeedButton = document.getElementById("import-feed");
const clearAllButton = document.getElementById("clear-all");
// Load saved slugs from localStorage // Load saved slugs from localStorage
let slugs = new Set(JSON.parse(localStorage.getItem('comic-slugs') || '[]')); let slugs = new Set(JSON.parse(localStorage.getItem("comic-slugs") || "[]"));
function updateSlugs() { function updateSlugs() {
localStorage.setItem('comic-slugs', JSON.stringify([...slugs])); localStorage.setItem("comic-slugs", JSON.stringify([...slugs]));
updateRSSLink(); updateRSSLink();
renderSlugs(); renderSlugs();
updateClearAllButton(); updateClearAllButton();
@ -144,13 +171,17 @@ const languages = [
function updateClearAllButton() { function updateClearAllButton() {
if (clearAllButton) { if (clearAllButton) {
clearAllButton.style.display = slugs.size > 0 ? 'flex' : 'none'; clearAllButton.style.display = slugs.size > 0 ? "flex" : "none";
} }
} }
// Clear all functionality // Clear all functionality
clearAllButton?.addEventListener('click', () => { clearAllButton?.addEventListener("click", () => {
if (confirm('Are you sure you want to clear all comics? This cannot be undone.')) { if (
confirm(
"Are you sure you want to clear all comics? This cannot be undone."
)
) {
slugs.clear(); slugs.clear();
updateSlugs(); updateSlugs();
} }
@ -158,11 +189,13 @@ const languages = [
async function renderSlugs() { async function renderSlugs() {
if (!slugList) return; if (!slugList) return;
// Show loading state // Show loading state
slugList.innerHTML = ` slugList.innerHTML = `
<div class="animate-pulse space-y-2"> <div class="animate-pulse space-y-2">
${[...slugs].map(() => ` ${[...slugs]
.map(
() => `
<div class="flex items-center gap-4 p-3 bg-primary rounded-lg border border-hover/30"> <div class="flex items-center gap-4 p-3 bg-primary rounded-lg border border-hover/30">
<div class="w-12 h-16 bg-hover/30 rounded"></div> <div class="w-12 h-16 bg-hover/30 rounded"></div>
<div class="flex-1"> <div class="flex-1">
@ -170,7 +203,9 @@ const languages = [
<div class="h-4 bg-hover/30 rounded w-1/2"></div> <div class="h-4 bg-hover/30 rounded w-1/2"></div>
</div> </div>
</div> </div>
`).join('')} `
)
.join("")}
</div> </div>
`; `;
@ -179,64 +214,73 @@ const languages = [
// Add timeout for comic info fetch // Add timeout for comic info fetch
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`/api/comic/${encodeURIComponent(slug)}`, { const response = await fetch(`/api/comic/${encodeURIComponent(slug)}`, {
signal: controller.signal signal: controller.signal,
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) throw new Error('Comic not found'); if (!response.ok) throw new Error("Comic not found");
const data = await response.json(); const data = await response.json();
const div = document.createElement('div'); const div = document.createElement("div");
div.className = 'flex items-center gap-4 p-4 bg-primary rounded-lg border border-hover/30 hover:border-accent/50 transition-all duration-300 group relative overflow-hidden'; div.className =
"flex items-center gap-4 p-4 bg-primary rounded-lg border border-hover/30 hover:border-accent/50 transition-all duration-300 group relative overflow-hidden";
// Add a link to the comic // Add a link to the comic
const link = document.createElement('a'); const link = document.createElement("a");
link.href = `https://comick.io/comic/${slug}`; link.href = `https://comick.io/comic/${slug}`;
link.target = '_blank'; link.target = "_blank";
link.rel = 'noopener noreferrer'; link.rel = "noopener noreferrer";
link.className = 'flex items-center gap-4 flex-1 min-w-0'; link.className = "flex items-center gap-4 flex-1 min-w-0";
// Handle cover image // Handle cover image
if (data.comic?.md_covers?.[0]?.b2key) { if (data.comic?.md_covers?.[0]?.b2key) {
const img = document.createElement('img'); const img = document.createElement("img");
img.src = `https://meo.comick.pictures/${data.comic.md_covers[0].b2key}`; img.src = `https://meo.comick.pictures/${data.comic.md_covers[0].b2key}`;
img.className = 'w-16 h-24 object-cover rounded shadow-md group-hover:scale-105 transition-transform duration-300'; img.className =
"w-16 h-24 object-cover rounded shadow-md group-hover:scale-105 transition-transform duration-300";
img.alt = data.comic.title || slug; img.alt = data.comic.title || slug;
img.onerror = () => { img.onerror = () => {
img.src = '/placeholder.png'; // Fallback image img.src = "/placeholder.png"; // Fallback image
img.onerror = null; img.onerror = null;
}; };
link.appendChild(img); link.appendChild(img);
} else { } else {
// Placeholder if no image // Placeholder if no image
const placeholder = document.createElement('div'); const placeholder = document.createElement("div");
placeholder.className = 'w-16 h-24 bg-hover/30 rounded flex items-center justify-center text-primary/30'; placeholder.className =
placeholder.innerHTML = '<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>'; "w-16 h-24 bg-hover/30 rounded flex items-center justify-center text-primary/30";
placeholder.innerHTML =
'<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>';
link.appendChild(placeholder); link.appendChild(placeholder);
} }
const info = document.createElement('div'); const info = document.createElement("div");
info.className = 'flex-1 min-w-0 truncate'; info.className = "flex-1 min-w-0 truncate";
info.innerHTML = ` info.innerHTML = `
<div class="font-medium text-lg truncate group-hover:text-accent transition-colors"> <div class="font-medium text-lg truncate group-hover:text-accent transition-colors">
${data.comic?.title || slug} ${data.comic?.title || slug}
</div> </div>
<div class="text-sm text-primary/60 truncate">${slug}</div> <div class="text-sm text-primary/60 truncate">${slug}</div>
${data.comic?.last_chapter ? ` ${
data.comic?.last_chapter
? `
<div class="text-xs text-accent mt-1"> <div class="text-xs text-accent mt-1">
Latest: Chapter ${data.comic.last_chapter} Latest: Chapter ${data.comic.last_chapter}
</div> </div>
` : ''} `
: ""
}
`; `;
link.appendChild(info); link.appendChild(info);
div.appendChild(link); div.appendChild(link);
const removeBtn = document.createElement('button'); const removeBtn = document.createElement("button");
removeBtn.className = 'text-red-500 hover:text-red-600 transition-colors p-2 hover:bg-red-500/10 rounded-full flex-shrink-0'; removeBtn.className =
"text-red-500 hover:text-red-600 transition-colors p-2 hover:bg-red-500/10 rounded-full flex-shrink-0";
removeBtn.innerHTML = ` removeBtn.innerHTML = `
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 18L18 6M6 6l12 12"></path> <path d="M6 18L18 6M6 6l12 12"></path>
@ -245,20 +289,21 @@ const languages = [
removeBtn.onclick = (e) => { removeBtn.onclick = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
div.classList.add('scale-95', 'opacity-0'); div.classList.add("scale-95", "opacity-0");
setTimeout(() => { setTimeout(() => {
slugs.delete(slug); slugs.delete(slug);
updateSlugs(); updateSlugs();
}, 200); }, 200);
}; };
div.appendChild(removeBtn); div.appendChild(removeBtn);
return div; return div;
} catch (error) { } catch (error) {
console.error(`Failed to fetch comic info for ${slug}:`, error); console.error(`Failed to fetch comic info for ${slug}:`, error);
// Improved error state // Improved error state
const div = document.createElement('div'); const div = document.createElement("div");
div.className = 'flex items-center justify-between p-4 bg-primary rounded-lg border border-red-500/30 group'; div.className =
"flex items-center justify-between p-4 bg-primary rounded-lg border border-red-500/30 group";
div.innerHTML = ` div.innerHTML = `
<div class="flex items-center gap-4 flex-1"> <div class="flex items-center gap-4 flex-1">
<div class="w-16 h-24 bg-red-500/10 rounded flex items-center justify-center text-red-500/50"> <div class="w-16 h-24 bg-red-500/10 rounded flex items-center justify-center text-red-500/50">
@ -279,13 +324,13 @@ const languages = [
</svg> </svg>
</button> </button>
`; `;
const removeButton = div.querySelector('button'); const removeButton = div.querySelector("button");
if (removeButton) { if (removeButton) {
removeButton.onclick = (e) => { removeButton.onclick = (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
div.classList.add('scale-95', 'opacity-0'); div.classList.add("scale-95", "opacity-0");
setTimeout(() => { setTimeout(() => {
slugs.delete(slug); slugs.delete(slug);
updateSlugs(); updateSlugs();
@ -295,22 +340,24 @@ const languages = [
return div; return div;
} }
}); });
Promise.all(promises).then(elements => { Promise.all(promises)
slugList.innerHTML = ''; .then((elements) => {
elements.forEach(element => slugList.appendChild(element)); slugList.innerHTML = "";
}).catch(error => { elements.forEach((element) => slugList.appendChild(element));
console.error('Failed to render slugs:', error); })
}); .catch((error) => {
console.error("Failed to render slugs:", error);
});
} }
let searchTimeout: NodeJS.Timeout; let searchTimeout: NodeJS.Timeout;
searchInput?.addEventListener('input', () => { searchInput?.addEventListener("input", () => {
clearTimeout(searchTimeout); clearTimeout(searchTimeout);
searchTimeout = setTimeout(async () => { searchTimeout = setTimeout(async () => {
const query = searchInput.value.trim(); const query = searchInput.value.trim();
if (query.length < 2) { if (query.length < 2) {
searchResults!.classList.add('hidden'); searchResults!.classList.add("hidden");
return; return;
} }
@ -320,22 +367,22 @@ const languages = [
Searching... Searching...
</div> </div>
`; `;
searchResults!.classList.remove('hidden'); searchResults!.classList.remove("hidden");
const response = await fetch('/api/search', { const response = await fetch("/api/search", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ query }) body: JSON.stringify({ query }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
const data = await response.json(); const data = await response.json();
if (!data || !Array.isArray(data) || data.length === 0) { if (!data || !Array.isArray(data) || data.length === 0) {
searchResults!.innerHTML = ` searchResults!.innerHTML = `
<div class="p-3 bg-primary rounded-lg border border-hover/30"> <div class="p-3 bg-primary rounded-lg border border-hover/30">
@ -344,38 +391,39 @@ const languages = [
`; `;
return; return;
} }
searchResults!.innerHTML = ''; searchResults!.innerHTML = "";
data.forEach((comic: any) => { data.forEach((comic: any) => {
const div = document.createElement('div'); const div = document.createElement("div");
div.className = 'flex items-center gap-3 p-3 bg-primary rounded-lg border border-hover/30 cursor-pointer hover:border-accent/50 transition-colors'; div.className =
"flex items-center gap-3 p-3 bg-primary rounded-lg border border-hover/30 cursor-pointer hover:border-accent/50 transition-colors";
div.onclick = () => { div.onclick = () => {
slugs.add(comic.slug); slugs.add(comic.slug);
updateSlugs(); updateSlugs();
searchInput.value = ''; searchInput.value = "";
searchResults!.classList.add('hidden'); searchResults!.classList.add("hidden");
}; };
if (comic.md_covers && comic.md_covers[0]) { if (comic.md_covers && comic.md_covers[0]) {
const img = document.createElement('img'); const img = document.createElement("img");
img.src = `https://meo.comick.pictures/${comic.md_covers[0].b2key}`; img.src = `https://meo.comick.pictures/${comic.md_covers[0].b2key}`;
img.className = 'w-12 h-16 object-cover rounded'; img.className = "w-12 h-16 object-cover rounded";
img.alt = comic.title; img.alt = comic.title;
div.appendChild(img); div.appendChild(img);
} }
const info = document.createElement('div'); const info = document.createElement("div");
info.innerHTML = ` info.innerHTML = `
<div class="font-medium">${comic.title}</div> <div class="font-medium">${comic.title}</div>
<div class="text-sm text-primary/60">${comic.slug}</div> <div class="text-sm text-primary/60">${comic.slug}</div>
`; `;
div.appendChild(info); div.appendChild(info);
searchResults!.appendChild(div); searchResults!.appendChild(div);
}); });
} catch (error) { } catch (error) {
console.error('Failed to search comics:', error); console.error("Failed to search comics:", error);
searchResults!.innerHTML = ` searchResults!.innerHTML = `
<div class="p-3 bg-primary rounded-lg border border-red-500/30 text-red-500"> <div class="p-3 bg-primary rounded-lg border border-red-500/30 text-red-500">
Failed to search comics. Please try again. Failed to search comics. Please try again.
@ -386,9 +434,9 @@ const languages = [
}); });
async function updateRSSLink() { async function updateRSSLink() {
const slugString = [...slugs].join(','); const slugString = [...slugs].join(",");
const lang = langSelect?.value || 'en'; const lang = langSelect?.value || "en";
if (slugString) { if (slugString) {
try { try {
// Add loading state // Add loading state
@ -402,22 +450,22 @@ const languages = [
`; `;
generateRSSLink!.onclick = null; generateRSSLink!.onclick = null;
const response = await fetch('/api/create-feed', { const response = await fetch("/api/create-feed", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify({
slugs: [...slugs], slugs: [...slugs],
lang lang,
}) }),
}); });
if (!response.ok) throw new Error('Failed to create feed'); if (!response.ok) throw new Error("Failed to create feed");
const { feedId } = await response.json(); const { feedId } = await response.json();
generateRSSLink!.href = `/rss/${feedId}.xml`; generateRSSLink!.href = `/rss/${feedId}.xml`;
// Clear loading state after successful generation // Clear loading state after successful generation
generateRSSLink!.innerHTML = ` generateRSSLink!.innerHTML = `
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
@ -431,7 +479,7 @@ const languages = [
`; `;
generateRSSLink!.onclick = null; // Remove any existing click handlers generateRSSLink!.onclick = null; // Remove any existing click handlers
} catch (error) { } catch (error) {
console.error('Failed to generate feed:', error); console.error("Failed to generate feed:", error);
generateRSSLink!.innerHTML = ` generateRSSLink!.innerHTML = `
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -443,7 +491,7 @@ const languages = [
} }
} else { } else {
// Reset to default state when no slugs // Reset to default state when no slugs
generateRSSLink!.href = '#'; generateRSSLink!.href = "#";
generateRSSLink!.innerHTML = ` generateRSSLink!.innerHTML = `
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-5 h-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@ -462,7 +510,7 @@ const languages = [
} }
// Import feed functionality // Import feed functionality
importFeedButton?.addEventListener('click', async () => { importFeedButton?.addEventListener("click", async () => {
const feedId = feedIdInput.value.trim(); const feedId = feedIdInput.value.trim();
if (!feedId) return; if (!feedId) return;
@ -478,48 +526,58 @@ const languages = [
const response = await fetch(`/api/feed/${feedId}`); const response = await fetch(`/api/feed/${feedId}`);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error(`Feed import failed with status ${response.status}: ${errorText}`); console.error(
`Feed import failed with status ${response.status}: ${errorText}`
);
// Display a more user-friendly error based on the status code // Display a more user-friendly error based on the status code
let userFriendlyError = 'Failed to import feed.'; let userFriendlyError = "Failed to import feed.";
if (response.status === 404) { if (response.status === 404) {
userFriendlyError = `Feed ID "${feedId}" not found. Please check the ID and try again.`; userFriendlyError = `Feed ID "${feedId}" not found. Please check the ID and try again.`;
} else if (response.status === 500) { } else if (response.status === 500) {
if (errorText.includes('ETIMEDOUT') || errorText.includes('timeout')) { if (
userFriendlyError = 'Connection to database timed out. This is common on the first request. Please try again in a few seconds.'; errorText.includes("ETIMEDOUT") ||
} else if (errorText.includes('ECONNREFUSED')) { errorText.includes("timeout")
userFriendlyError = 'Database connection refused. Please try again later.'; ) {
userFriendlyError =
"Connection to database timed out. This is common on the first request. Please try again in a few seconds.";
} else if (errorText.includes("ECONNREFUSED")) {
userFriendlyError =
"Database connection refused. Please try again later.";
} else { } else {
userFriendlyError = 'Server error while importing feed. Please try again later.'; userFriendlyError =
"Server error while importing feed. Please try again later.";
} }
} }
throw new Error(userFriendlyError); throw new Error(userFriendlyError);
} }
const data = await response.json(); const data = await response.json();
console.log('Feed import data:', data); // Diagnostic log console.log("Feed import data:", data); // Diagnostic log
// Validate response structure // Validate response structure
if (!data || typeof data !== 'object') { if (!data || typeof data !== "object") {
throw new Error('Invalid feed data format: not an object'); throw new Error("Invalid feed data format: not an object");
} }
const feedSlugs = data.slugs; const feedSlugs = data.slugs;
const lang = data.lang; const lang = data.lang;
// Validate slugs array // Validate slugs array
if (!feedSlugs || !Array.isArray(feedSlugs)) { if (!feedSlugs || !Array.isArray(feedSlugs)) {
throw new Error('Invalid feed data: missing or invalid slugs array'); throw new Error("Invalid feed data: missing or invalid slugs array");
} }
// Validate language // Validate language
if (!lang || typeof lang !== 'string') { if (!lang || typeof lang !== "string") {
console.warn('Feed has missing or invalid language, using default'); console.warn("Feed has missing or invalid language, using default");
} else if (langSelect) { } else if (langSelect) {
// Only update language if it's valid // Only update language if it's valid
const validLanguages = Array.from(langSelect.options).map(opt => opt.value); const validLanguages = Array.from(langSelect.options).map(
(opt) => opt.value
);
if (validLanguages.includes(lang)) { if (validLanguages.includes(lang)) {
langSelect.value = lang; langSelect.value = lang;
} else { } else {
@ -530,32 +588,32 @@ const languages = [
// Add new slugs to existing collection // Add new slugs to existing collection
let importCount = 0; let importCount = 0;
feedSlugs.forEach((slug: string) => { feedSlugs.forEach((slug: string) => {
if (typeof slug === 'string' && slug.trim()) { if (typeof slug === "string" && slug.trim()) {
slugs.add(slug.trim()); slugs.add(slug.trim());
importCount++; importCount++;
} else { } else {
console.warn('Skipping invalid slug in feed:', slug); console.warn("Skipping invalid slug in feed:", slug);
} }
}); });
// Update UI // Update UI
updateSlugs(); updateSlugs();
feedIdInput.value = ''; feedIdInput.value = "";
// Show success message // Show success message
const successDiv = document.createElement('div'); const successDiv = document.createElement("div");
successDiv.className = 'text-sm text-green-500 mt-1'; successDiv.className = "text-sm text-green-500 mt-1";
successDiv.textContent = `Feed imported successfully! Added ${importCount} comics.`; successDiv.textContent = `Feed imported successfully! Added ${importCount} comics.`;
feedIdInput.parentNode?.appendChild(successDiv); feedIdInput.parentNode?.appendChild(successDiv);
setTimeout(() => successDiv.remove(), 3000); setTimeout(() => successDiv.remove(), 3000);
} catch (error) { } catch (error) {
console.error('Failed to import feed:', error); console.error("Failed to import feed:", error);
const errorDiv = document.createElement('div'); const errorDiv = document.createElement("div");
errorDiv.className = 'text-sm text-red-500 mt-1'; errorDiv.className = "text-sm text-red-500 mt-1";
errorDiv.textContent = error instanceof Error errorDiv.textContent =
? `Import failed: ${error.message}` error instanceof Error
: 'Failed to import feed. Please check the feed ID and try again.'; ? `Import failed: ${error.message}`
: "Failed to import feed. Please check the feed ID and try again.";
feedIdInput.parentNode?.appendChild(errorDiv); feedIdInput.parentNode?.appendChild(errorDiv);
setTimeout(() => errorDiv.remove(), 5000); setTimeout(() => errorDiv.remove(), 5000);
} finally { } finally {
@ -575,12 +633,15 @@ const languages = [
updateClearAllButton(); updateClearAllButton();
// Update RSS link when language changes // Update RSS link when language changes
langSelect?.addEventListener('change', () => updateRSSLink()); langSelect?.addEventListener("change", () => updateRSSLink());
// Close search results when clicking outside // Close search results when clicking outside
document.addEventListener('click', (e) => { document.addEventListener("click", (e) => {
if (!searchResults?.contains(e.target as Node) && !searchInput?.contains(e.target as Node)) { if (
searchResults?.classList.add('hidden'); !searchResults?.contains(e.target as Node) &&
!searchInput?.contains(e.target as Node)
) {
searchResults?.classList.add("hidden");
} }
}); });
</script> </script>

View file

@ -33,7 +33,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://danbooru.donmai.us/posts/6900673", source: "https://danbooru.donmai.us/posts/6900673",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://twitter.com/ayanagi0319", link: "https://twitter.com/ayanagi0319",
}, },
], ],
@ -45,7 +45,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://danbooru.donmai.us/posts/8743208", source: "https://danbooru.donmai.us/posts/8743208",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/kamepan44231", link: "https://x.com/kamepan44231",
}, },
], ],
@ -57,7 +57,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://danbooru.donmai.us/posts/8074444", source: "https://danbooru.donmai.us/posts/8074444",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/Ten_0123_/", link: "https://x.com/Ten_0123_/",
}, },
], ],
@ -69,8 +69,8 @@ export const backgrounds: BackgroundImage[] = [
source: "https://www.pixiv.net/en/artworks/124222053", source: "https://www.pixiv.net/en/artworks/124222053",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://twitter.com/greatodoggo", link: "https://x.com/greatodoggo",
}, },
], ],
}, },
@ -81,7 +81,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://safebooru.org/index.php?page=post&s=view&id=5418793", source: "https://safebooru.org/index.php?page=post&s=view&id=5418793",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/soaphisoap", link: "https://x.com/soaphisoap",
}, },
], ],
@ -93,7 +93,7 @@ export const backgrounds: BackgroundImage[] = [
source: "https://safebooru.org/index.php?page=post&s=view&id=5531630", source: "https://safebooru.org/index.php?page=post&s=view&id=5531630",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/charlieinoni", link: "https://x.com/charlieinoni",
}, },
], ],
@ -106,11 +106,59 @@ export const backgrounds: BackgroundImage[] = [
"https://i.pximg.net/img-original/img/2025/03/03/15/13/24/127822472_p0.png", "https://i.pximg.net/img-original/img/2025/03/03/15/13/24/127822472_p0.png",
socials: [ socials: [
{ {
name: "Twitter", name: "Artist's Twitter",
link: "https://x.com/", link: "https://x.com/",
}, },
], ],
}, },
{
url: "/cynthia1.webp",
title: "Cynthia (Pokemon)",
artist: "???",
source: "https://safebooru.org/index.php?page=post&s=view&id=5547740",
socials: [
{
name: "Artist's Twitter",
link: "https://x.com/",
},
],
},
{
url: "/darling.webp",
title: "Zero Two",
artist: "AWZ",
source: "https://www.pixiv.net/en/artworks/123110068",
socials: [
{
name: "Pixiv Profile",
link: "https://www.pixiv.net/en/users/67355371",
},
],
},
{
url: "/granny.webp",
title: "Turbo Granny (Dandadan)",
artist: "MajimartBCN",
source: "https://twitter.com/MajimartBCN/status/1864741369383694841",
socials: [
{
name: "Artist's Twitter",
link: "https://x.com/MajimartBCN",
},
],
},
{
url: "/numi_lily.webp",
title: "Nihmune & Shylily",
artist: "Gryever_AW",
source: "https://x.com/gryever_aw/status/1890772686084686118",
socials: [
{
name: "Artist's Twitter",
link: "https://x.com/Gryever_AW",
},
],
},
// Add more backgrounds here! // Add more backgrounds here!
// { // {
// url: 'your-image-url', // url: 'your-image-url',

View file

@ -1,10 +1,12 @@
import type { Comic, Genre, ComicType } from './types'; import type { Comic, Genre, ComicType, ChapterDetail } from './types';
import { getRedisClient } from './db'; import { getRedisClient, CHAPTER_CACHE_TTL } from './db';
import pThrottle from 'p-throttle';
const languageMap = { const languageMap = {
"en": "English", "en": "English",
"fr": "French", "fr": "French",
"es": "Spanish", "es": "Spanish",
"es-419": "Spanish (Latin America)",
"it": "Italian", "it": "Italian",
"pl": "Polish", "pl": "Polish",
"tr": "Turkish", "tr": "Turkish",
@ -13,7 +15,10 @@ const languageMap = {
"sv": "Swedish", "sv": "Swedish",
"ar": "Arabic", "ar": "Arabic",
"de": "German", "de": "German",
"ko": "Korean" "ko": "Korean",
"pt": "Portuguese",
"pt-br": "Portuguese (Brazil)",
"pt-pt": "Portuguese (Portugal)"
} as const; } as const;
const languages = new Set(Object.keys(languageMap)); const languages = new Set(Object.keys(languageMap));
@ -26,6 +31,20 @@ let genreCache: Genre[] = [];
const RETRY_COUNT = 3; const RETRY_COUNT = 3;
const RETRY_DELAY = 1000; const RETRY_DELAY = 1000;
// Rate limiting configuration - adjust these values if needed
const API_REQUESTS_PER_SECOND = 10; // Maximum requests per second
const API_CONCURRENT_REQUESTS = 6; // Maximum concurrent requests
const DELAY_AFTER_429 = 1000; // Wait 1 second after a rate limit hit
// Create throttled fetch function to avoid rate limits
const throttledFetch = pThrottle({
limit: API_REQUESTS_PER_SECOND,
interval: 1000,
concurrency: API_CONCURRENT_REQUESTS
} as any)((url: string, options: RequestInit = {}, retries = RETRY_COUNT) => {
return fetchWithRetry(url, options, retries);
});
async function fetchWithRetry(url: string, options: RequestInit = {}, retries = RETRY_COUNT): Promise<Response> { async function fetchWithRetry(url: string, options: RequestInit = {}, retries = RETRY_COUNT): Promise<Response> {
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@ -37,6 +56,19 @@ async function fetchWithRetry(url: string, options: RequestInit = {}, retries =
}, },
}); });
// Special handling for rate limit errors
if (response.status === 429) {
console.warn(`Rate limited on ${url}. Waiting ${DELAY_AFTER_429}ms before retry.`);
// Wait for the specified delay
await new Promise(resolve => setTimeout(resolve, DELAY_AFTER_429));
// Retry with one less retry count but only if we have retries left
if (retries > 0) {
return fetchWithRetry(url, options, retries - 1);
}
}
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
@ -44,7 +76,10 @@ async function fetchWithRetry(url: string, options: RequestInit = {}, retries =
return response; return response;
} catch (error) { } catch (error) {
if (retries > 0) { if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY)); // For non-429 errors, use exponential backoff
const delay = Math.min(RETRY_DELAY * Math.pow(2, RETRY_COUNT - retries), 10000);
console.log(`Retrying after ${delay}ms. Retries left: ${retries-1}`);
await new Promise(resolve => setTimeout(resolve, delay));
return fetchWithRetry(url, options, retries - 1); return fetchWithRetry(url, options, retries - 1);
} }
throw error; throw error;
@ -55,7 +90,8 @@ export async function fetchGenres(): Promise<Genre[]> {
if (genreCache.length > 0) return genreCache; if (genreCache.length > 0) return genreCache;
try { try {
const response = await fetchWithRetry('https://api.comick.fun/genre'); // No tachiyomi param for genre endpoint
const response = await throttledFetch('https://api.comick.fun/genre');
genreCache = await response.json(); genreCache = await response.json();
return genreCache; return genreCache;
} catch (error) { } catch (error) {
@ -92,7 +128,7 @@ export async function fetchComics(
try { try {
console.log('Fetching comics from:', apiUrl); console.log('Fetching comics from:', apiUrl);
const response = await fetchWithRetry(apiUrl); const response = await throttledFetch(apiUrl);
const data = await response.json(); const data = await response.json();
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
@ -139,20 +175,6 @@ interface ComicDetail {
}; };
} }
interface ChapterDetail {
id: string;
chap: string;
title: string;
updated_at: string;
md_comics: {
title: string;
slug: string;
md_covers?: {
b2key: string;
}[];
};
}
interface ChapterParams { interface ChapterParams {
limit?: number; limit?: number;
lang?: string; lang?: string;
@ -160,7 +182,7 @@ interface ChapterParams {
export async function getComicBySlug(slug: string): Promise<ComicDetail | null> { export async function getComicBySlug(slug: string): Promise<ComicDetail | null> {
try { try {
const response = await fetchWithRetry(`https://api.comick.fun/comic/${slug}`); const response = await throttledFetch(`https://api.comick.fun/comic/${slug}`);
const data = await response.json(); const data = await response.json();
if (!data?.comic?.hid) { if (!data?.comic?.hid) {
@ -188,14 +210,14 @@ export async function getChaptersByHid(
}); });
// Fetch comic details including cover // Fetch comic details including cover
const comicResponse = await fetchWithRetry( const comicResponse = await throttledFetch(
`https://api.comick.fun/comic/${hid}` `https://api.comick.fun/comic/${hid}`
); );
const comicData = await comicResponse.json(); const comicData = await comicResponse.json();
const comicTitle = comicData?.comic?.title || 'Unknown Comic'; const comicTitle = comicData?.comic?.title || 'Unknown Comic';
const covers = comicData?.comic?.md_covers || []; const covers = comicData?.comic?.md_covers || [];
const response = await fetchWithRetry( const response = await throttledFetch(
`https://api.comick.fun/comic/${hid}/chapters?${queryParams}` `https://api.comick.fun/comic/${hid}/chapters?${queryParams}`
); );
const data = await response.json(); const data = await response.json();
@ -270,122 +292,137 @@ export async function getChaptersForSlugs(
): Promise<ChapterDetail[]> { ): Promise<ChapterDetail[]> {
console.log(`Fetching chapters for ${slugs.length} comics`); console.log(`Fetching chapters for ${slugs.length} comics`);
const redis = await getRedisClient(); const redis = await getRedisClient();
const CACHE_TTL = 60 * 60;
const uniqueSlugs = [...new Set(slugs)]; const uniqueSlugs = [...new Set(slugs)];
const pipeline = redis.pipeline(); const pipeline = redis.pipeline();
// Add cache key validation // Check cache for all slugs
uniqueSlugs.forEach(slug => { uniqueSlugs.forEach(slug => {
if (!slug.match(/^[a-z0-9-]+$/)) { if (!slug.match(/^[a-z0-9-]+$/)) {
console.error(`Invalid slug format: ${slug}`); console.error(`Invalid slug format: ${slug}`);
return; // Skip invalid slugs immediately
} }
const cacheKey = `chapters:${slug}:${lang}`; const cacheKey = `chapters:${slug}:${lang}`;
pipeline.get(cacheKey); pipeline.get(cacheKey);
}); });
const [cacheResults, slugsToFetch] = await Promise.all([ const cacheResults = await pipeline.exec();
pipeline.exec(),
validateSlugs(uniqueSlugs) // Add this new function // Process cache results
]);
const cachedChapters: ChapterDetail[] = []; const cachedChapters: ChapterDetail[] = [];
const slugsToFetch: string[] = [];
// Separate cached and uncached slugs in one pass
cacheResults.forEach(([err, result], index) => { cacheResults.forEach(([err, result], index) => {
const slug = uniqueSlugs[index];
if (!err && result) { if (!err && result) {
try { try {
const parsed = JSON.parse(result); const parsed = JSON.parse(result as string);
if (Array.isArray(parsed)) { if (Array.isArray(parsed)) {
cachedChapters.push(...parsed); cachedChapters.push(...parsed);
} }
} catch (e) { } catch (e) {
console.error(`Cache parse error for ${uniqueSlugs[index]}:`, e); console.error(`Cache parse error for ${slug}:`, e);
slugsToFetch.push(slug);
} }
} else {
slugsToFetch.push(slug);
} }
}); });
// Add progress tracking // Skip validation if we have recent cache data
const cachedSlugs = new Set(cachedChapters.map(c => c.md_comics.slug)); const cachedSlugs = new Set(cachedChapters.map(c => c.md_comics.slug));
console.log(`Cache hits: ${cachedChapters.length} chapters from ${cachedSlugs.size} comics`); console.log(`Cache hits: ${cachedChapters.length} chapters from ${cachedSlugs.size} comics`);
console.log(`Fetching ${slugsToFetch.length} comics from API`);
// Only fetch if we have slugs that need fetching
const fetchPromises = slugsToFetch.map(async (slug) => { let liveChapters: ChapterDetail[] = [];
const timer = Date.now(); if (slugsToFetch.length > 0) {
try { console.log(`Fetching ${slugsToFetch.length} comics from API`);
const comicDetail = await getComicBySlug(slug);
// Use Promise.all for concurrent fetching with higher concurrency
if (!comicDetail?.comic?.hid) { const fetchPromises = slugsToFetch.map(async (slug) => {
console.error(`❌ No HID found for ${slug}`); try {
const comicDetail = await getComicBySlug(slug);
if (!comicDetail?.comic?.hid) {
return [];
}
return getChaptersByHid(comicDetail.comic.hid, slug, {
limit: 5,
lang: lang
});
} catch (error) {
console.error(`Error processing ${slug}:`, error);
return []; return [];
} }
});
const chapters = await getChaptersByHid(comicDetail.comic.hid, slug, {
limit: 5, liveChapters = (await Promise.all(fetchPromises)).flat();
lang: lang
// Cache new results in batch if we have any
if (liveChapters.length > 0) {
const cachePipeline = redis.pipeline();
const chaptersBySlug: Record<string, ChapterDetail[]> = {};
liveChapters.forEach(chapter => {
const slug = chapter.md_comics.slug;
if (!chaptersBySlug[slug]) {
chaptersBySlug[slug] = [];
}
chaptersBySlug[slug].push(chapter);
}); });
console.log(`✅ ${slug} (${comicDetail.comic.hid}) returned ${chapters.length} chapters in ${Date.now() - timer}ms`); Object.entries(chaptersBySlug).forEach(([slug, chaps]) => {
return chapters; const cacheKey = `chapters:${slug}:${lang}`;
} catch (error) { cachePipeline.set(
console.error(`🚨 Error processing ${slug}:`, error); cacheKey,
return []; JSON.stringify(chaps),
'EX',
CHAPTER_CACHE_TTL
);
});
await cachePipeline.exec();
} }
}); }
const liveChapters = (await Promise.all(fetchPromises)).flat(); // Combine and deduplicate
return deduplicateChapters([...cachedChapters, ...liveChapters]);
// Cache new results in batch
const cachePipeline = redis.pipeline();
const chaptersBySlug: Record<string, ChapterDetail[]> = {};
liveChapters.forEach(chapter => {
const slug = chapter.md_comics.slug;
if (!chaptersBySlug[slug]) {
chaptersBySlug[slug] = [];
}
chaptersBySlug[slug].push(chapter);
});
Object.entries(chaptersBySlug).forEach(([slug, chaps]) => {
const cacheKey = `chapters:${slug}:${lang}`;
cachePipeline.set(
cacheKey,
JSON.stringify(chaps),
'EX',
CACHE_TTL
);
});
await cachePipeline.exec();
// Combine and sort all chapters
const allChapters = [...cachedChapters, ...liveChapters];
const deduplicatedChapters = deduplicateChapters(allChapters);
return deduplicatedChapters.sort((a, b) =>
parseFloat(b.chap) - parseFloat(a.chap) ||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
} }
// Add export to the validation function // Replace validateSlugs with a more efficient implementation
export async function validateSlugs(slugs: string[]): Promise<string[]> { export async function validateSlugs(slugs: string[]): Promise<string[]> {
// Skip validation in production for cached items
if (process.env.NODE_ENV === 'production') {
return slugs;
}
const validSlugs: string[] = []; const validSlugs: string[] = [];
const invalidSlugs: string[] = []; const invalidSlugs: string[] = [];
await Promise.all(slugs.map(async (slug) => { // Increase batch size for faster processing
try { const batchSize = 5;
const response = await fetchWithRetry(`https://api.comick.fun/comic/${slug}`, {}, 1); for (let i = 0; i < slugs.length; i += batchSize) {
if (response.ok) { const batch = slugs.slice(i, i + batchSize);
validSlugs.push(slug);
} else { await Promise.all(batch.map(async (slug) => {
try {
// Reduced retries (1 instead of 3) and removed tachiyomi parameter
const response = await throttledFetch(`https://api.comick.fun/comic/${slug}`, {}, 1);
if (response.ok) {
validSlugs.push(slug);
} else {
invalidSlugs.push(slug);
}
} catch {
invalidSlugs.push(slug); invalidSlugs.push(slug);
} }
} catch { }));
invalidSlugs.push(slug); }
}
}));
if (invalidSlugs.length > 0) { if (invalidSlugs.length > 0) {
console.error(`Invalid/Unavailable slugs: ${invalidSlugs.join(', ')}`); console.warn(`Skipping invalid slugs: ${invalidSlugs.length} items`);
} }
return validSlugs; return validSlugs;

File diff suppressed because it is too large Load diff

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 };