mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 20:53:45 -07:00
oh no
This commit is contained in:
parent
2f342807c5
commit
69b9e24d19
12 changed files with 1569 additions and 1278 deletions
878
package-lock.json
generated
878
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
|
@ -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
BIN
public/cynthia1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 629 KiB |
BIN
public/darling.webp
Normal file
BIN
public/darling.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
BIN
public/granny.webp
Normal file
BIN
public/granny.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 817 KiB |
BIN
public/numi_lily.webp
Normal file
BIN
public/numi_lily.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +193,9 @@ const languages = [
|
||||||
// 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>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -181,62 +216,71 @@ const languages = [
|
||||||
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,7 +289,7 @@ 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();
|
||||||
|
|
@ -257,8 +301,9 @@ const languages = [
|
||||||
} 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">
|
||||||
|
|
@ -280,12 +325,12 @@ const languages = [
|
||||||
</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();
|
||||||
|
|
@ -296,21 +341,23 @@ const languages = [
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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,14 +367,14 @@ 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) {
|
||||||
|
|
@ -345,27 +392,28 @@ 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>
|
||||||
|
|
@ -375,7 +423,7 @@ const languages = [
|
||||||
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,8 +434,8 @@ 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 {
|
||||||
|
|
@ -402,18 +450,18 @@ 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`;
|
||||||
|
|
@ -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,20 +526,28 @@ 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.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -499,11 +555,11 @@ const languages = [
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -511,15 +567,17 @@ const languages = [
|
||||||
|
|
||||||
// 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>
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
223
src/lib/api.ts
223
src/lib/api.ts
|
|
@ -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`);
|
|
||||||
|
|
||||||
const fetchPromises = slugsToFetch.map(async (slug) => {
|
// Only fetch if we have slugs that need fetching
|
||||||
const timer = Date.now();
|
let liveChapters: ChapterDetail[] = [];
|
||||||
try {
|
if (slugsToFetch.length > 0) {
|
||||||
const comicDetail = await getComicBySlug(slug);
|
console.log(`Fetching ${slugsToFetch.length} comics from API`);
|
||||||
|
|
||||||
if (!comicDetail?.comic?.hid) {
|
// Use Promise.all for concurrent fetching with higher concurrency
|
||||||
console.error(`❌ No HID found for ${slug}`);
|
const fetchPromises = slugsToFetch.map(async (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, {
|
liveChapters = (await Promise.all(fetchPromises)).flat();
|
||||||
limit: 5,
|
|
||||||
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;
|
||||||
|
|
|
||||||
1125
src/lib/db.ts
1125
src/lib/db.ts
File diff suppressed because it is too large
Load diff
|
|
@ -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 }), {
|
||||||
|
|
@ -31,6 +28,3 @@ export const POST: APIRoute = async ({ request }) => {
|
||||||
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 };
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue