again
27
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# build output
|
||||
dist/
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# DB file
|
||||
feed-mappings.db
|
||||
4
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
# new-manga
|
||||
# oops
|
||||
20
astro.config.mjs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineConfig } from "astro/config";
|
||||
import vercel from "@astrojs/vercel";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: vercel(),
|
||||
image: {
|
||||
remotePatterns: [{ protocol: "https" }],
|
||||
},
|
||||
integrations: [tailwind()],
|
||||
vite: {
|
||||
define: {
|
||||
'process.env.PRIMARY_REDIS_URL': JSON.stringify(process.env.PRIMARY_REDIS_URL || process.env.REDIS_URL),
|
||||
'process.env.REPLICA_REDIS_URL_1': JSON.stringify(process.env.REPLICA_REDIS_URL_1 || process.env.REPLICA_REDIS_URL),
|
||||
'process.env.REPLICA_REDIS_URL_2': JSON.stringify(process.env.REPLICA_REDIS_URL_2),
|
||||
'process.env.REPLICA_REDIS_URL_3': JSON.stringify(process.env.REPLICA_REDIS_URL_3)
|
||||
}
|
||||
}
|
||||
});
|
||||
6168
package-lock.json
generated
Normal file
27
package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@ldez/manga",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/tailwind": "^6.0.1",
|
||||
"@astrojs/vercel": "^8.1.3",
|
||||
"@floating-ui/dom": "^1.6.13",
|
||||
"@types/node": "^22.13.11",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"astro": "^5.5.4",
|
||||
"feed": "^4.2.2",
|
||||
"ioredis": "^5.6.0",
|
||||
"p-throttle": "^7.0.0",
|
||||
"redis-modules-sdk": "^1.4.0",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
BIN
public/citali.webp
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
public/cynthia1.webp
Normal file
|
After Width: | Height: | Size: 629 KiB |
BIN
public/darling.webp
Normal file
|
After Width: | Height: | Size: 147 KiB |
1
public/favicon.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 36 36"><path fill="#99AAB5" d="M33 36H4c4 0 3-9 3-9c0-2.209 1.791-15 4-15h21s4 0 4 4v17s0 3-3 3"/><path fill="#CCD6DD" d="M30 33c0 3 3 3 3 3H3s-3 0-3-4V4a4 4 0 0 1 4-4h22a4 4 0 0 1 4 4z"/><path fill="#99AAB5" d="M27 20a1 1 0 0 1-1 1h-8a1 1 0 1 1 0-2h8a1 1 0 0 1 1 1m0-4a1 1 0 0 1-1 1h-8a1 1 0 1 1 0-2h8a1 1 0 0 1 1 1m0-4a1 1 0 0 1-1 1h-8a1 1 0 1 1 0-2h8a1 1 0 0 1 1 1m0 12a1 1 0 0 1-1 1H4a1 1 0 1 1 0-2h22a1 1 0 0 1 1 1m0 4a1 1 0 0 1-1 1H4a1 1 0 1 1 0-2h22a1 1 0 0 1 1 1m0 4a1 1 0 0 1-1 1H4a1 1 0 1 1 0-2h22a1 1 0 0 1 1 1M25 9s2 0 2-2V5s0-2-2-2H5S3 3 3 5v2s0 2 2 2z"/><path fill="#55ACEE" d="M13 21s2 0 2-2v-6s0-2-2-2H5s-2 0-2 2v6s0 2 2 2z"/></svg>
|
||||
|
After Width: | Height: | Size: 724 B |
BIN
public/genshin.webp
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
public/granny.webp
Normal file
|
After Width: | Height: | Size: 817 KiB |
BIN
public/meata.webp
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
public/numi.webp
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
public/numi_lily.webp
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
public/original.webp
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
public/patchouli.webp
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/shylily.webp
Normal file
|
After Width: | Height: | Size: 178 KiB |
BIN
public/sleepy.webp
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
166
src/components/AnnouncementBanner.astro
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
---
|
||||
import type { Announcement } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
announcement: Announcement;
|
||||
}
|
||||
|
||||
const { announcement } = Astro.props;
|
||||
|
||||
// Define colors based on announcement type
|
||||
const typeStyles = {
|
||||
info: {
|
||||
bgColor: 'bg-[rgb(var(--accent))]',
|
||||
textColor: 'text-[rgb(var(--accent-text))]',
|
||||
icon: 'info-icon'
|
||||
},
|
||||
warning: {
|
||||
bgColor: 'bg-yellow-500',
|
||||
textColor: 'text-white',
|
||||
icon: 'warning-icon'
|
||||
},
|
||||
success: {
|
||||
bgColor: 'bg-emerald-500',
|
||||
textColor: 'text-white',
|
||||
icon: 'success-icon'
|
||||
}
|
||||
};
|
||||
|
||||
const { bgColor, textColor, icon } = typeStyles[announcement.type];
|
||||
|
||||
// Create a simple hash of the announcement content for localStorage key
|
||||
const announcementHash = btoa(JSON.stringify({
|
||||
message: announcement.message,
|
||||
type: announcement.type,
|
||||
link: announcement.link
|
||||
}));
|
||||
---
|
||||
|
||||
<div
|
||||
class={`announcement-banner hidden w-full ${bgColor} ${textColor} px-6 py-4 shadow-lg`}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="container mx-auto flex items-center justify-between">
|
||||
<div class="flex items-center gap-3 text-lg">
|
||||
{/* Info Icon */}
|
||||
{announcement.type === 'info' && (
|
||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Warning Icon */}
|
||||
{announcement.type === 'warning' && (
|
||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Success Icon */}
|
||||
{announcement.type === 'success' && (
|
||||
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Message */}
|
||||
<span class="font-medium">{announcement.message}</span>
|
||||
|
||||
{/* Link if provided */}
|
||||
{announcement.link && (
|
||||
<a
|
||||
href={announcement.link.url}
|
||||
class="underline hover:opacity-80 transition-opacity font-semibold ml-2"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{announcement.link.text} →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dismiss button if announcement is dismissible */}
|
||||
{announcement.dismissible && (
|
||||
<button
|
||||
class="dismiss-announcement p-2 hover:bg-black/10 rounded-full transition-colors"
|
||||
aria-label="Dismiss announcement"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script define:vars={{ dismissible: announcement.dismissible, announcementHash }}>
|
||||
// DOM elements
|
||||
const banner = document.querySelector('.announcement-banner');
|
||||
const dismissBtn = document.querySelector('.dismiss-announcement');
|
||||
const storageKey = `announcement-dismissed-${announcementHash}`;
|
||||
|
||||
/**
|
||||
* Check if the announcement should be shown based on localStorage
|
||||
*/
|
||||
function checkVisibility() {
|
||||
// Only check localStorage if the banner is dismissible
|
||||
const isDismissed = dismissible ?
|
||||
localStorage.getItem(storageKey) === 'true' :
|
||||
false;
|
||||
|
||||
if (!isDismissed && banner) {
|
||||
banner.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Run after DOM is loaded to prevent flash of hidden content
|
||||
document.addEventListener('DOMContentLoaded', checkVisibility);
|
||||
|
||||
// Add click handler for dismiss button if present
|
||||
if (dismissBtn && dismissible) {
|
||||
dismissBtn.addEventListener('click', () => {
|
||||
banner?.classList.add('hidden');
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.announcement-banner {
|
||||
animation: slideDown 0.5s ease-out;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
src/components/Card.astro
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
body: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { href, title, body } = Astro.props;
|
||||
---
|
||||
|
||||
<li class="link-card">
|
||||
<a href={href}>
|
||||
<h2>
|
||||
{title}
|
||||
<span>→</span>
|
||||
</h2>
|
||||
<p>
|
||||
{body}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
<style>
|
||||
.link-card {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 1px;
|
||||
background-color: #23262d;
|
||||
background-image: none;
|
||||
background-size: 400%;
|
||||
border-radius: 7px;
|
||||
background-position: 100%;
|
||||
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.link-card > a {
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
padding: calc(1.5rem - 1px);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
background-color: #23262d;
|
||||
opacity: 0.8;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
p {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.link-card:is(:hover, :focus-within) {
|
||||
background-position: 0;
|
||||
background-image: var(--accent-gradient);
|
||||
}
|
||||
.link-card:is(:hover, :focus-within) h2 {
|
||||
color: rgb(var(--accent-light));
|
||||
}
|
||||
</style>
|
||||
267
src/components/ComicCard.astro
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
---
|
||||
import type { Comic, Genre } from '../lib/types';
|
||||
import { Image } from 'astro:assets';
|
||||
import { getGenreNames } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
comic: Comic;
|
||||
genres: Genre[];
|
||||
}
|
||||
|
||||
const { comic, genres } = Astro.props;
|
||||
const genreNames = getGenreNames(comic.md_comics.genres, genres);
|
||||
const isAvif = comic.md_comics.md_covers[0]?.b2key.endsWith('.avif');
|
||||
const coverUrl = comic.md_comics.md_covers[0]
|
||||
? `https://meo.comick.pictures/${comic.md_comics.md_covers[0].b2key}`
|
||||
: '/placeholder.png';
|
||||
const isNSFW = comic.md_comics.content_rating === 'erotica';
|
||||
---
|
||||
|
||||
<div
|
||||
class="bg-secondary rounded-xl overflow-hidden shadow-lg h-full flex flex-col max-w-[280px] mx-auto border border-hover/30 hover:border-accent/50 transition-all duration-300"
|
||||
data-genres={JSON.stringify(comic.md_comics.genres)}
|
||||
role="article"
|
||||
aria-labelledby={`comic-title-${comic.id}`}
|
||||
>
|
||||
<a href={`https://comick.io/comic/${comic.md_comics.slug}`} class="flex flex-col h-full group relative">
|
||||
<div class="relative overflow-hidden aspect-[2/3] rounded-t-xl">
|
||||
{!isAvif ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={comic.md_comics.title}
|
||||
inferSize={true}
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={comic.md_comics.title}
|
||||
width="280"
|
||||
height="420"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
)}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
{isNSFW && (
|
||||
<div class="absolute top-2 right-2 bg-red-500/90 text-white px-2 py-1 rounded text-xs font-bold shadow-lg backdrop-blur-[2px]">
|
||||
NSFW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="p-3 sm:p-4 md:p-5 flex-grow flex flex-col bg-secondary relative rounded-b-xl">
|
||||
<div class="absolute -top-6 sm:-top-8 left-2 right-2 sm:left-3 sm:right-3 bg-secondary/95 backdrop-blur-[12px] p-2 sm:p-3 rounded-lg shadow-lg border border-hover/30">
|
||||
<h3 id={`comic-title-${comic.id}`}
|
||||
class="text-base sm:text-lg font-bold line-clamp-1 text-center text-primary drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]"
|
||||
aria-label={comic.md_comics.title}
|
||||
>
|
||||
{comic.md_comics.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-6">
|
||||
<p class="inline-block bg-accent/20 text-accent px-3 sm:px-4 py-1 rounded-full text-xs sm:text-sm font-semibold">
|
||||
Chapter {comic.chap}
|
||||
</p>
|
||||
|
||||
{genreNames.length > 0 && (
|
||||
<div class="relative mt-3" aria-label="Comic genres">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{genreNames.slice(0, 3).map(genre => (
|
||||
<span class="bg-hover text-primary/80 px-3 py-1 rounded-full text-xs font-medium truncate">
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
{genreNames.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
class="expand-tags-btn bg-hover text-primary/80 px-3 py-1 rounded-full text-xs font-medium hover:bg-hover/80 hover:scale-105 cursor-pointer transition-all duration-200 ease-in-out"
|
||||
aria-expanded="false"
|
||||
data-comic-id={comic.id}
|
||||
>
|
||||
+{genreNames.length - 3} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genreNames.length > 3 && (
|
||||
<div
|
||||
id={`tags-${comic.id}`}
|
||||
class="expanded-tags bg-secondary/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border border-hover/30 hidden"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{genreNames.slice(3).map(genre => (
|
||||
<span class="bg-hover text-primary/80 px-3 py-1 rounded-full text-xs font-medium truncate">
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 sm:pt-4 flex items-center justify-between text-xs sm:text-sm text-primary/60 border-t border-hover/30">
|
||||
<div class="flex flex-col gap-1">
|
||||
<time datetime={comic.updated_at}>
|
||||
{new Date(comic.updated_at).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</time>
|
||||
{comic.publish_at && (
|
||||
<div class="countdown-timer text-xs" data-publish-at={comic.publish_at}>
|
||||
Publishing in: <span class="font-medium text-accent">calculating...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span class="group-hover:text-accent transition-colors duration-300">Read Now →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const buttons = document.querySelectorAll('.expand-tags-btn');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const comicId = button.getAttribute('data-comic-id');
|
||||
const tooltip = document.getElementById(`tags-${comicId}`);
|
||||
|
||||
if (!tooltip) return;
|
||||
|
||||
function updatePosition() {
|
||||
computePosition(button, tooltip, {
|
||||
placement: 'bottom',
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip(),
|
||||
shift({ padding: 8 })
|
||||
],
|
||||
}).then(({x, y}) => {
|
||||
Object.assign(tooltip.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showTooltip() {
|
||||
tooltip.classList.remove('hidden');
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltip.classList.add('hidden');
|
||||
}
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isHidden = tooltip.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
showTooltip();
|
||||
button.textContent = 'Show less';
|
||||
|
||||
// Close when clicking outside
|
||||
const closeHandler = (event: MouseEvent) => {
|
||||
if (!tooltip.contains(event.target as Node) && !button.contains(event.target as Node)) {
|
||||
hideTooltip();
|
||||
button.textContent = `+${tooltip.querySelectorAll('span').length} more`;
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeHandler);
|
||||
}, 0);
|
||||
} else {
|
||||
hideTooltip();
|
||||
button.textContent = `+${tooltip.querySelectorAll('span').length} more`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add countdown timer functionality
|
||||
const countdownTimers = document.querySelectorAll('.countdown-timer');
|
||||
|
||||
function updateTimer(element: Element) {
|
||||
const publishAt = new Date(element.getAttribute('data-publish-at') || '').getTime();
|
||||
const now = new Date().getTime();
|
||||
const distance = publishAt - now;
|
||||
|
||||
const timerSpan = element.querySelector('span');
|
||||
if (!timerSpan) return;
|
||||
|
||||
if (distance < 0) {
|
||||
timerSpan.textContent = 'Published';
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
|
||||
let timeString = '';
|
||||
if (days > 0) timeString += `${days}d `;
|
||||
if (hours > 0 || days > 0) timeString += `${hours}h `;
|
||||
if (minutes > 0 || hours > 0 || days > 0) timeString += `${minutes}m `;
|
||||
timeString += `${seconds}s`;
|
||||
|
||||
timerSpan.textContent = timeString;
|
||||
}
|
||||
|
||||
// Add intersection observer for lazy loading
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const timer = entry.target.querySelector('.countdown-timer');
|
||||
if (timer) updateTimer(timer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update countdown timer initialization
|
||||
countdownTimers.forEach(timer => {
|
||||
observer.observe(timer);
|
||||
updateTimer(timer);
|
||||
setInterval(() => {
|
||||
if (timer.isConnected) updateTimer(timer);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
function filterComics() {
|
||||
const excludedGenres = new Set(JSON.parse(localStorage.getItem('excluded-genres') || '[]'));
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('[data-genres]').forEach(card => {
|
||||
const cardGenres = JSON.parse((card as HTMLElement).dataset.genres || '[]');
|
||||
const shouldHide = cardGenres.some((genreId: number) => excludedGenres.has(genreId));
|
||||
card.classList.toggle('hidden', shouldHide);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for genre updates
|
||||
window.addEventListener('genresUpdated', () => {
|
||||
filterComics();
|
||||
});
|
||||
|
||||
// Initial filter
|
||||
filterComics();
|
||||
</script>
|
||||
157
src/components/GenreFilter.astro
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
import { fetchGenres } from '../lib/api';
|
||||
|
||||
const genres = await fetchGenres();
|
||||
---
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
id="genre-filter-btn"
|
||||
class="bg-secondary text-primary px-4 py-2 rounded-lg hover:bg-hover transition-colors flex items-center gap-2"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18M6 12h12m-9 6h6"/>
|
||||
</svg>
|
||||
<span>Filter Genres</span>
|
||||
<span id="excluded-count" class="bg-accent/20 text-accent px-2 rounded-full text-sm hidden">
|
||||
0
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="genre-dropdown"
|
||||
class="absolute z-50 mt-2 w-64 bg-secondary rounded-lg shadow-lg border border-hover/30 hidden"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-medium">Exclude Genres</h3>
|
||||
<button
|
||||
id="clear-filters"
|
||||
class="text-sm text-accent hover:text-accent-light transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search genres..."
|
||||
class="w-full px-3 py-2 bg-primary rounded border border-hover/30 text-sm mb-2"
|
||||
id="genre-search"
|
||||
/>
|
||||
<div class="max-h-64 overflow-y-auto space-y-1" id="genre-list">
|
||||
{genres.map(genre => (
|
||||
<label class="flex items-center gap-2 px-2 py-1.5 hover:bg-hover/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={genre.id}
|
||||
class="genre-checkbox w-4 h-4 rounded border border-hover/30 bg-primary checked:bg-accent transition-all duration-200 ease-in-out"
|
||||
/>
|
||||
<span class="text-sm">{genre.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const button = document.getElementById('genre-filter-btn');
|
||||
const dropdown = document.getElementById('genre-dropdown');
|
||||
const genreList = document.getElementById('genre-list');
|
||||
const clearButton = document.getElementById('clear-filters');
|
||||
const searchInput = document.getElementById('genre-search') as HTMLInputElement;
|
||||
const excludedCount = document.getElementById('excluded-count');
|
||||
|
||||
// Load excluded genres from localStorage
|
||||
let excludedGenres = new Set(JSON.parse(localStorage.getItem('excluded-genres') || '[]'));
|
||||
|
||||
// Initialize checkboxes
|
||||
function initCheckboxes() {
|
||||
document.querySelectorAll('.genre-checkbox').forEach((checkbox: HTMLInputElement) => {
|
||||
checkbox.checked = excludedGenres.has(Number(checkbox.value));
|
||||
});
|
||||
updateExcludedCount();
|
||||
}
|
||||
|
||||
function updateExcludedCount() {
|
||||
if (excludedCount) {
|
||||
if (excludedGenres.size > 0) {
|
||||
excludedCount.textContent = excludedGenres.size.toString();
|
||||
excludedCount.classList.remove('hidden');
|
||||
} else {
|
||||
excludedCount.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle checkbox changes
|
||||
function handleCheckboxChange(e: Event) {
|
||||
const checkbox = e.target as HTMLInputElement;
|
||||
const genreId = Number(checkbox.value);
|
||||
|
||||
if (checkbox.checked) {
|
||||
excludedGenres.add(genreId);
|
||||
} else {
|
||||
excludedGenres.delete(genreId);
|
||||
}
|
||||
|
||||
localStorage.setItem('excluded-genres', JSON.stringify([...excludedGenres]));
|
||||
updateExcludedCount();
|
||||
|
||||
// Dispatch event for comic filtering
|
||||
window.dispatchEvent(new CustomEvent('genresUpdated', {
|
||||
detail: { excludedGenres: [...excludedGenres] }
|
||||
}));
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function handleSearch() {
|
||||
const query = searchInput.value.toLowerCase();
|
||||
const labels = genreList?.querySelectorAll('label');
|
||||
|
||||
labels?.forEach(label => {
|
||||
const text = label.textContent?.toLowerCase() || '';
|
||||
label.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
button?.addEventListener('click', () => {
|
||||
dropdown?.classList.toggle('hidden');
|
||||
button.setAttribute('aria-expanded', dropdown?.classList.contains('hidden') ? 'false' : 'true');
|
||||
});
|
||||
|
||||
clearButton?.addEventListener('click', () => {
|
||||
excludedGenres.clear();
|
||||
localStorage.setItem('excluded-genres', '[]');
|
||||
initCheckboxes();
|
||||
window.dispatchEvent(new CustomEvent('genresUpdated', {
|
||||
detail: { excludedGenres: [] }
|
||||
}));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.genre-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', handleCheckboxChange);
|
||||
});
|
||||
|
||||
searchInput?.addEventListener('input', handleSearch);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!button?.contains(e.target as Node) && !dropdown?.contains(e.target as Node)) {
|
||||
dropdown?.classList.add('hidden');
|
||||
button?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
initCheckboxes();
|
||||
</script>
|
||||
34
src/components/LanguageSelector.astro
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
import { availableLanguages, languageMap } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
currentLang: string;
|
||||
currentType: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const { currentLang, currentType, contentType } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative inline-block">
|
||||
<select
|
||||
class="appearance-none bg-secondary text-primary px-4 py-2 pr-8 rounded-lg cursor-pointer hover:bg-hover transition-colors"
|
||||
onchange="window.location.href=this.value"
|
||||
>
|
||||
{
|
||||
availableLanguages.map((lang) => (
|
||||
<option
|
||||
value={`/${lang}/${contentType}/${currentType}`}
|
||||
selected={lang === currentLang}
|
||||
>
|
||||
{languageMap[lang as keyof typeof languageMap]}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-primary">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
26
src/components/LoadingCard.astro
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
---
|
||||
<div class="bg-secondary rounded-xl overflow-hidden shadow-lg h-full flex flex-col max-w-[280px] mx-auto border border-hover/30">
|
||||
<div class="relative overflow-hidden aspect-[2/3] rounded-t-xl bg-hover animate-pulse"></div>
|
||||
|
||||
<div class="p-4 md:p-5 flex-grow flex flex-col bg-secondary relative rounded-b-xl">
|
||||
<div class="absolute -top-8 left-3 right-3 bg-hover animate-pulse h-12 rounded-lg"></div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="bg-hover animate-pulse h-8 w-24 rounded-full"></div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{[1, 2, 3].map(() => (
|
||||
<div class="bg-hover animate-pulse h-6 w-16 rounded-full"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4 flex items-center justify-between">
|
||||
<div class="bg-hover animate-pulse h-4 w-32 rounded"></div>
|
||||
<div class="bg-hover animate-pulse h-4 w-20 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
31
src/components/RSSLink.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
interface Props {
|
||||
lang: string;
|
||||
contentType: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const { lang, contentType, type } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={`/rss/${lang}/${contentType}/${type}.xml`}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
target="_blank"
|
||||
>
|
||||
<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"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
</svg>
|
||||
RSS Feed
|
||||
</a>
|
||||
647
src/components/SlugInput.astro
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
---
|
||||
import { getFeedMapping } from "../lib/db";
|
||||
interface Props {
|
||||
currentSlugs?: string[];
|
||||
}
|
||||
|
||||
const { currentSlugs = [] } = Astro.props;
|
||||
|
||||
import { availableLanguages, languageMap } from "../lib/api";
|
||||
|
||||
const languages = Object.entries(languageMap).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
---
|
||||
|
||||
<div class="space-y-4 sm:space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:gap-4">
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="Search for comics..."
|
||||
class="w-full 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"
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{
|
||||
languages.map((lang) => (
|
||||
<option value={lang.value}>{lang.label}</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="feed-id-input"
|
||||
placeholder="Enter feed ID to import..."
|
||||
class="flex-1 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"
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
Import Feed
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="search-results" class="hidden space-y-2">
|
||||
<!-- Search results will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-lg font-medium">Selected Comics</h2>
|
||||
<button
|
||||
id="clear-all"
|
||||
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;"
|
||||
>
|
||||
<svg
|
||||
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>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
>
|
||||
<span class="text-primary truncate pr-2">{slug}</span>
|
||||
<button
|
||||
class="text-red-500 hover:text-red-600 transition-colors flex-shrink-0 pl-2"
|
||||
data-remove={slug}
|
||||
>
|
||||
<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 class="flex justify-center">
|
||||
<a
|
||||
id="generate-rss"
|
||||
href="/rss/custom/"
|
||||
class="inline-flex items-center gap-2 px-6 py-3 bg-[rgb(var(--accent-bg))] text-[rgb(var(--accent-text))] rounded-lg hover:bg-[rgb(var(--accent-light))] transition-colors"
|
||||
onclick="return !!this.href.includes(',')"
|
||||
>
|
||||
<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"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 11a9 9 0 0 1 9 9"></path>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"></path>
|
||||
<circle cx="5" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
Generate RSS Feed
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const searchInput = document.getElementById(
|
||||
"search-input"
|
||||
) as HTMLInputElement;
|
||||
const searchResults = document.getElementById("search-results");
|
||||
const slugList = document.getElementById("slug-list");
|
||||
const generateRSSLink = document.getElementById("generate-rss");
|
||||
const langSelect = document.getElementById(
|
||||
"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
|
||||
let slugs = new Set(JSON.parse(localStorage.getItem("comic-slugs") || "[]"));
|
||||
|
||||
function updateSlugs() {
|
||||
localStorage.setItem("comic-slugs", JSON.stringify([...slugs]));
|
||||
updateRSSLink();
|
||||
renderSlugs();
|
||||
updateClearAllButton();
|
||||
}
|
||||
|
||||
function updateClearAllButton() {
|
||||
if (clearAllButton) {
|
||||
clearAllButton.style.display = slugs.size > 0 ? "flex" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all functionality
|
||||
clearAllButton?.addEventListener("click", () => {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear all comics? This cannot be undone."
|
||||
)
|
||||
) {
|
||||
slugs.clear();
|
||||
updateSlugs();
|
||||
}
|
||||
});
|
||||
|
||||
async function renderSlugs() {
|
||||
if (!slugList) return;
|
||||
|
||||
// Show loading state
|
||||
slugList.innerHTML = `
|
||||
<div class="animate-pulse space-y-2">
|
||||
${[...slugs]
|
||||
.map(
|
||||
() => `
|
||||
<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="flex-1">
|
||||
<div class="h-5 bg-hover/30 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-hover/30 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const promises = [...slugs].map(async (slug) => {
|
||||
try {
|
||||
// Add timeout for comic info fetch
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
const response = await fetch(`/api/comic/${encodeURIComponent(slug)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) throw new Error("Comic not found");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
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";
|
||||
|
||||
// Add a link to the comic
|
||||
const link = document.createElement("a");
|
||||
link.href = `https://comick.io/comic/${slug}`;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
link.className = "flex items-center gap-4 flex-1 min-w-0";
|
||||
|
||||
// Handle cover image
|
||||
if (data.comic?.md_covers?.[0]?.b2key) {
|
||||
const img = document.createElement("img");
|
||||
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.alt = data.comic.title || slug;
|
||||
img.onerror = () => {
|
||||
img.src = "/placeholder.png"; // Fallback image
|
||||
img.onerror = null;
|
||||
};
|
||||
link.appendChild(img);
|
||||
} else {
|
||||
// Placeholder if no image
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.className =
|
||||
"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);
|
||||
}
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.className = "flex-1 min-w-0 truncate";
|
||||
info.innerHTML = `
|
||||
<div class="font-medium text-lg truncate group-hover:text-accent transition-colors">
|
||||
${data.comic?.title || slug}
|
||||
</div>
|
||||
<div class="text-sm text-primary/60 truncate">${slug}</div>
|
||||
${
|
||||
data.comic?.last_chapter
|
||||
? `
|
||||
<div class="text-xs text-accent mt-1">
|
||||
Latest: Chapter ${data.comic.last_chapter}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
link.appendChild(info);
|
||||
div.appendChild(link);
|
||||
|
||||
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.innerHTML = `
|
||||
<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>
|
||||
</svg>
|
||||
`;
|
||||
removeBtn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
div.classList.add("scale-95", "opacity-0");
|
||||
setTimeout(() => {
|
||||
slugs.delete(slug);
|
||||
updateSlugs();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
div.appendChild(removeBtn);
|
||||
return div;
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch comic info for ${slug}:`, error);
|
||||
// Improved error state
|
||||
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.innerHTML = `
|
||||
<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">
|
||||
<svg class="w-8 h-8" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||
<path 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"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-red-500 font-medium">Failed to load details</div>
|
||||
<div class="text-sm text-primary/60">${slug}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="text-red-500 hover:text-red-600 transition-colors p-2 hover:bg-red-500/10 rounded-full"
|
||||
>
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
|
||||
const removeButton = div.querySelector("button");
|
||||
if (removeButton) {
|
||||
removeButton.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
div.classList.add("scale-95", "opacity-0");
|
||||
setTimeout(() => {
|
||||
slugs.delete(slug);
|
||||
updateSlugs();
|
||||
}, 200);
|
||||
};
|
||||
}
|
||||
return div;
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(promises)
|
||||
.then((elements) => {
|
||||
slugList.innerHTML = "";
|
||||
elements.forEach((element) => slugList.appendChild(element));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to render slugs:", error);
|
||||
});
|
||||
}
|
||||
|
||||
let searchTimeout: NodeJS.Timeout;
|
||||
searchInput?.addEventListener("input", () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(async () => {
|
||||
const query = searchInput.value.trim();
|
||||
if (query.length < 2) {
|
||||
searchResults!.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
searchResults!.innerHTML = `
|
||||
<div class="p-3 bg-primary rounded-lg border border-hover/30">
|
||||
Searching...
|
||||
</div>
|
||||
`;
|
||||
searchResults!.classList.remove("hidden");
|
||||
|
||||
const response = await fetch("/api/search", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
||||
searchResults!.innerHTML = `
|
||||
<div class="p-3 bg-primary rounded-lg border border-hover/30">
|
||||
No comics found
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
searchResults!.innerHTML = "";
|
||||
|
||||
data.forEach((comic: any) => {
|
||||
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.onclick = () => {
|
||||
slugs.add(comic.slug);
|
||||
updateSlugs();
|
||||
searchInput.value = "";
|
||||
searchResults!.classList.add("hidden");
|
||||
};
|
||||
|
||||
if (comic.md_covers && comic.md_covers[0]) {
|
||||
const img = document.createElement("img");
|
||||
img.src = `https://meo.comick.pictures/${comic.md_covers[0].b2key}`;
|
||||
img.className = "w-12 h-16 object-cover rounded";
|
||||
img.alt = comic.title;
|
||||
div.appendChild(img);
|
||||
}
|
||||
|
||||
const info = document.createElement("div");
|
||||
info.innerHTML = `
|
||||
<div class="font-medium">${comic.title}</div>
|
||||
<div class="text-sm text-primary/60">${comic.slug}</div>
|
||||
`;
|
||||
div.appendChild(info);
|
||||
|
||||
searchResults!.appendChild(div);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to search comics:", error);
|
||||
searchResults!.innerHTML = `
|
||||
<div class="p-3 bg-primary rounded-lg border border-red-500/30 text-red-500">
|
||||
Failed to search comics. Please try again.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
async function updateRSSLink() {
|
||||
const slugString = [...slugs].join(",");
|
||||
const lang = langSelect?.value || "en";
|
||||
|
||||
if (slugString) {
|
||||
try {
|
||||
// Add loading state
|
||||
generateRSSLink!.innerHTML = `
|
||||
<span class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Generating...
|
||||
</span>
|
||||
`;
|
||||
generateRSSLink!.onclick = null;
|
||||
|
||||
const response = await fetch("/api/create-feed", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slugs: [...slugs],
|
||||
lang,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error("Failed to create feed");
|
||||
|
||||
const { feedId } = await response.json();
|
||||
generateRSSLink!.href = `/rss/${feedId}.xml`;
|
||||
|
||||
// Clear loading state after successful generation
|
||||
generateRSSLink!.innerHTML = `
|
||||
<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">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"></path>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"></path>
|
||||
<circle cx="5" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
RSS Feed Ready
|
||||
</span>
|
||||
`;
|
||||
generateRSSLink!.onclick = null; // Remove any existing click handlers
|
||||
} catch (error) {
|
||||
console.error("Failed to generate feed:", error);
|
||||
generateRSSLink!.innerHTML = `
|
||||
<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">
|
||||
<path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
Failed to generate
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
// Reset to default state when no slugs
|
||||
generateRSSLink!.href = "#";
|
||||
generateRSSLink!.innerHTML = `
|
||||
<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">
|
||||
<path d="M4 11a9 9 0 0 1 9 9"></path>
|
||||
<path d="M4 4a16 16 0 0 1 16 16"></path>
|
||||
<circle cx="5" cy="19" r="1"></circle>
|
||||
</svg>
|
||||
Generate RSS Feed
|
||||
</span>
|
||||
`;
|
||||
generateRSSLink!.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Import feed functionality
|
||||
importFeedButton?.addEventListener("click", async () => {
|
||||
const feedId = feedIdInput.value.trim();
|
||||
if (!feedId) return;
|
||||
|
||||
try {
|
||||
importFeedButton.disabled = true;
|
||||
importFeedButton.innerHTML = `
|
||||
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Importing...
|
||||
`;
|
||||
|
||||
const response = await fetch(`/api/feed/${feedId}`);
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(
|
||||
`Feed import failed with status ${response.status}: ${errorText}`
|
||||
);
|
||||
|
||||
// Display a more user-friendly error based on the status code
|
||||
let userFriendlyError = "Failed to import feed.";
|
||||
|
||||
if (response.status === 404) {
|
||||
userFriendlyError = `Feed ID "${feedId}" not found. Please check the ID and try again.`;
|
||||
} else if (response.status === 500) {
|
||||
if (
|
||||
errorText.includes("ETIMEDOUT") ||
|
||||
errorText.includes("timeout")
|
||||
) {
|
||||
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 {
|
||||
userFriendlyError =
|
||||
"Server error while importing feed. Please try again later.";
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(userFriendlyError);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Feed import data:", data); // Diagnostic log
|
||||
|
||||
// Validate response structure
|
||||
if (!data || typeof data !== "object") {
|
||||
throw new Error("Invalid feed data format: not an object");
|
||||
}
|
||||
|
||||
const feedSlugs = data.slugs;
|
||||
const lang = data.lang;
|
||||
|
||||
// Validate slugs array
|
||||
if (!feedSlugs || !Array.isArray(feedSlugs)) {
|
||||
throw new Error("Invalid feed data: missing or invalid slugs array");
|
||||
}
|
||||
|
||||
// Validate language
|
||||
if (!lang || typeof lang !== "string") {
|
||||
console.warn("Feed has missing or invalid language, using default");
|
||||
} else if (langSelect) {
|
||||
// Only update language if it's valid
|
||||
const validLanguages = Array.from(langSelect.options).map(
|
||||
(opt) => opt.value
|
||||
);
|
||||
if (validLanguages.includes(lang)) {
|
||||
langSelect.value = lang;
|
||||
} else {
|
||||
console.warn(`Feed has unsupported language: ${lang}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new slugs to existing collection
|
||||
let importCount = 0;
|
||||
feedSlugs.forEach((slug: string) => {
|
||||
if (typeof slug === "string" && slug.trim()) {
|
||||
slugs.add(slug.trim());
|
||||
importCount++;
|
||||
} else {
|
||||
console.warn("Skipping invalid slug in feed:", slug);
|
||||
}
|
||||
});
|
||||
|
||||
// Update UI
|
||||
updateSlugs();
|
||||
feedIdInput.value = "";
|
||||
|
||||
// Show success message
|
||||
const successDiv = document.createElement("div");
|
||||
successDiv.className = "text-sm text-green-500 mt-1";
|
||||
successDiv.textContent = `Feed imported successfully! Added ${importCount} comics.`;
|
||||
feedIdInput.parentNode?.appendChild(successDiv);
|
||||
setTimeout(() => successDiv.remove(), 3000);
|
||||
} catch (error) {
|
||||
console.error("Failed to import feed:", error);
|
||||
const errorDiv = document.createElement("div");
|
||||
errorDiv.className = "text-sm text-red-500 mt-1";
|
||||
errorDiv.textContent =
|
||||
error instanceof Error
|
||||
? `Import failed: ${error.message}`
|
||||
: "Failed to import feed. Please check the feed ID and try again.";
|
||||
feedIdInput.parentNode?.appendChild(errorDiv);
|
||||
setTimeout(() => errorDiv.remove(), 5000);
|
||||
} finally {
|
||||
importFeedButton.disabled = false;
|
||||
importFeedButton.innerHTML = `
|
||||
<svg 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"/>
|
||||
</svg>
|
||||
Import Feed
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial render
|
||||
renderSlugs();
|
||||
updateRSSLink();
|
||||
updateClearAllButton();
|
||||
|
||||
// Update RSS link when language changes
|
||||
langSelect?.addEventListener("change", () => updateRSSLink());
|
||||
|
||||
// Close search results when clicking outside
|
||||
document.addEventListener("click", (e) => {
|
||||
if (
|
||||
!searchResults?.contains(e.target as Node) &&
|
||||
!searchInput?.contains(e.target as Node)
|
||||
) {
|
||||
searchResults?.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
76
src/components/ThemeToggle.astro
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
---
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="p-2 w-10 h-10 rounded-full bg-secondary hover:bg-hover flex items-center justify-center transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 absolute transition-transform duration-500 opacity-0"
|
||||
data-theme-icon="light"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg
|
||||
class="w-5 h-5 absolute transition-transform duration-500 opacity-0"
|
||||
data-theme-icon="dark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const setTheme = (theme: 'light' | 'dark') => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
updateIcons(theme);
|
||||
};
|
||||
|
||||
const updateIcons = (theme: string) => {
|
||||
const lightIcon = document.querySelector('[data-theme-icon="light"]');
|
||||
const darkIcon = document.querySelector('[data-theme-icon="dark"]');
|
||||
|
||||
if (theme === 'light') {
|
||||
lightIcon?.classList.remove('opacity-0');
|
||||
darkIcon?.classList.add('opacity-0');
|
||||
} else {
|
||||
lightIcon?.classList.add('opacity-0');
|
||||
darkIcon?.classList.remove('opacity-0');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
setTheme(initialTheme);
|
||||
|
||||
// Handle toggle click
|
||||
document.getElementById('theme-toggle')?.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
</script>
|
||||
31
src/components/TypeFilter.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
interface Props {
|
||||
currentType: string;
|
||||
lang: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const { currentType, lang, contentType } = Astro.props;
|
||||
|
||||
const types = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'manga', label: 'Manga' },
|
||||
{ id: 'manhwa', label: 'Manhwa' },
|
||||
{ id: 'manhua', label: 'Manhua' },
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex gap-2 mb-6">
|
||||
{types.map(type => (
|
||||
<a
|
||||
href={`/${lang}/${contentType}/${type.id}`}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentType === type.id
|
||||
? 'bg-[rgb(var(--accent-bg))] text-[rgb(var(--accent-text))]'
|
||||
: 'bg-secondary text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
193
src/config/backgrounds.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
interface BackgroundImage {
|
||||
url: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
source: string;
|
||||
socials?: {
|
||||
name: string;
|
||||
link: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const backgrounds: BackgroundImage[] = [
|
||||
{
|
||||
url: "/citali.webp",
|
||||
title: "Citlali (Genshin Impact)",
|
||||
artist: "ryrmcher",
|
||||
source: "https://danbooru.donmai.us/posts/6148600",
|
||||
socials: [
|
||||
{
|
||||
name: "Twitter Post",
|
||||
link: "https://x.com/ryrmcher/status/1878812100866560258",
|
||||
},
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://x.com/ryrmcher",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/patchouli.webp",
|
||||
title: "ひとりぼっちのあさ",
|
||||
artist: "ayanagi0319",
|
||||
source: "https://danbooru.donmai.us/posts/6900673",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://twitter.com/ayanagi0319",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/original.webp",
|
||||
title: "読書",
|
||||
artist: "kamepan44231 - かめぱすた",
|
||||
source: "https://danbooru.donmai.us/posts/8743208",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://x.com/kamepan44231",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/numi.webp",
|
||||
title: "Numi",
|
||||
artist: "TenIllustrator",
|
||||
source: "https://danbooru.donmai.us/posts/8074444",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://x.com/Ten_0123_/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/shylily.webp",
|
||||
title: "Shylily",
|
||||
artist: "greatodoggo",
|
||||
source: "https://www.pixiv.net/en/artworks/124222053",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://x.com/greatodoggo",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/sleepy.webp",
|
||||
title: "???",
|
||||
artist: "soaphisoap",
|
||||
source: "https://safebooru.org/index.php?page=post&s=view&id=5418793",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://x.com/soaphisoap",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/genshin.webp",
|
||||
title: "???",
|
||||
artist: "charlieinoni",
|
||||
source: "https://safebooru.org/index.php?page=post&s=view&id=5531630",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
link: "https://x.com/charlieinoni",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
url: "/meata.webp",
|
||||
title: "???",
|
||||
artist: "???",
|
||||
source:
|
||||
"https://i.pximg.net/img-original/img/2025/03/03/15/13/24/127822472_p0.png",
|
||||
socials: [
|
||||
{
|
||||
name: "Artist's Twitter",
|
||||
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!
|
||||
// {
|
||||
// url: 'your-image-url',
|
||||
// title: 'Image Title',
|
||||
// artist: 'Artist Name',
|
||||
// source: 'source-url',
|
||||
// socials: [
|
||||
// { name: 'Twitter', link: 'twitter-url' }
|
||||
// ]
|
||||
// }
|
||||
];
|
||||
|
||||
export function getRandomBackground(): BackgroundImage {
|
||||
if (backgrounds.length === 0) {
|
||||
// Fallback background if array is empty
|
||||
return {
|
||||
url: "/citali.webp",
|
||||
title: "Citlali (Genshin Impact)",
|
||||
artist: "ryrmcher",
|
||||
source: "https://danbooru.donmai.us/posts/6148600",
|
||||
socials: [],
|
||||
};
|
||||
}
|
||||
return backgrounds[Math.floor(Math.random() * backgrounds.length)];
|
||||
}
|
||||
|
||||
export function preloadBackgrounds() {
|
||||
backgrounds.forEach((bg) => {
|
||||
const img = new Image();
|
||||
img.src = bg.url;
|
||||
});
|
||||
}
|
||||
1
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
290
src/layouts/Layout.astro
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
---
|
||||
import ThemeToggle from '../components/ThemeToggle.astro';
|
||||
import Analytics from '@vercel/analytics/astro';
|
||||
import { getRandomBackground } from '../config/backgrounds';
|
||||
import AnnouncementBanner from '../components/AnnouncementBanner.astro';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
description = "Discover and track your favorite comics with ComicK-RSS"
|
||||
} = Astro.props;
|
||||
|
||||
const background = getRandomBackground();
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site ?? 'https://comick-rss.vercel.app').toString();
|
||||
const isCreditsPage = Astro.url.pathname === '/credits';
|
||||
|
||||
// Add the announcement configuration
|
||||
const announcement = {
|
||||
message: "Your custom feed may have been affected.",
|
||||
type: "info" as const,
|
||||
dismissible: true,
|
||||
link: {
|
||||
text: "Find out what happened here",
|
||||
url: "https://status-6cv.pages.dev/issues/2025_02_28_database-failure-2-20/"
|
||||
}
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={canonicalURL} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
</head>
|
||||
<body
|
||||
class={`min-h-screen bg-primary text-primary before:fixed before:inset-0 before:bg-[url('${background.url}')] before:bg-cover before:bg-center before:bg-no-repeat before:opacity-30 before:-z-10 before:transition-all before:duration-300`}
|
||||
id="body-element"
|
||||
style={`--bg-url: url('${background.url}')`}
|
||||
>
|
||||
<!-- Add the banner right after body opening tag -->
|
||||
<AnnouncementBanner announcement={announcement} />
|
||||
|
||||
{!isCreditsPage && (
|
||||
<div class="fixed bottom-4 sm:bottom-6 right-4 sm:right-6 z-50">
|
||||
<div class="relative" id="settings-dropdown">
|
||||
<button
|
||||
id="settings-button"
|
||||
class="p-2.5 sm:p-3 bg-secondary rounded-lg hover:bg-hover transition-all duration-200 shadow-lg border border-hover/30 flex items-center gap-2 hover:-translate-y-0.5"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<svg class="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 15a3 3 0 100-6 3 3 0 000 6z" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z" />
|
||||
</svg>
|
||||
<span class="text-xs sm:text-sm">Settings</span>
|
||||
</button>
|
||||
|
||||
<div id="dropdown-menu" class="hidden absolute bottom-full right-0 mb-2 w-56 sm:w-64 bg-secondary rounded-lg shadow-lg border border-hover/30">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Theme</span>
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="relative w-12 h-6 rounded-full bg-hover transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<span class="absolute left-1 top-1 w-4 h-4 rounded-full bg-primary transition-transform duration-200"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Card Visibility</span>
|
||||
<button
|
||||
id="toggle-content"
|
||||
class="relative w-12 h-6 rounded-full bg-hover transition-colors"
|
||||
aria-label="Toggle card visibility"
|
||||
>
|
||||
<span class="absolute left-1 top-1 w-4 h-4 rounded-full bg-primary transition-transform duration-200"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-hover/30 pt-4">
|
||||
<a
|
||||
href="/credits"
|
||||
class="flex items-center gap-2 text-sm hover:text-accent transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
Credits
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<main id="main-content" class="transition-opacity duration-300">
|
||||
<slot />
|
||||
</main>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
const settingsButton = document.getElementById('settings-button');
|
||||
const dropdownMenu = document.getElementById('dropdown-menu');
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const toggleContent = document.getElementById('toggle-content');
|
||||
const mainContent = document.getElementById('main-content');
|
||||
const bodyElement = document.getElementById('body-element');
|
||||
|
||||
// Theme toggle functionality
|
||||
const updateThemeToggle = (theme: string) => {
|
||||
const toggleButton = themeToggle?.querySelector('span');
|
||||
if (toggleButton) {
|
||||
toggleButton.style.transform = theme === 'dark' ? 'translateX(24px)' : 'translateX(0)';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
};
|
||||
|
||||
// Content visibility functionality
|
||||
let isVisible = true;
|
||||
const updateContentVisibility = () => {
|
||||
const toggleButton = toggleContent?.querySelector('span');
|
||||
if (toggleButton) {
|
||||
toggleButton.style.transform = isVisible ? 'translateX(0)' : 'translateX(24px)';
|
||||
}
|
||||
if (mainContent) {
|
||||
mainContent.style.opacity = isVisible ? '1' : '0';
|
||||
mainContent.style.pointerEvents = isVisible ? 'auto' : 'none';
|
||||
}
|
||||
if (bodyElement) {
|
||||
bodyElement.style.setProperty('--tw-blur', isVisible ? 'blur(2px)' : 'blur(0)');
|
||||
bodyElement.style.setProperty('--tw-bg-opacity', isVisible ? '0.3' : '1');
|
||||
}
|
||||
};
|
||||
|
||||
// Add theme initialization to prevent flash of wrong theme
|
||||
const themeScript = `
|
||||
const theme = localStorage.getItem('theme') ??
|
||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
settingsButton?.addEventListener('click', () => {
|
||||
dropdownMenu?.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
themeToggle?.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
updateThemeToggle(newTheme);
|
||||
});
|
||||
|
||||
toggleContent?.addEventListener('click', () => {
|
||||
isVisible = !isVisible;
|
||||
updateContentVisibility();
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!settingsButton?.contains(e.target as Node) && !dropdownMenu?.contains(e.target as Node)) {
|
||||
dropdownMenu?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style is:global>
|
||||
:root {
|
||||
--accent: 136, 58, 234;
|
||||
--accent-light: 224, 204, 250;
|
||||
--accent-dark: 49, 10, 101;
|
||||
--accent-gradient: linear-gradient(
|
||||
45deg,
|
||||
rgb(var(--accent)),
|
||||
rgb(var(--accent-light)) 30%,
|
||||
white 60%
|
||||
);
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:root[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f0f0f0;
|
||||
--bg-hover: #e0e0e0;
|
||||
--text-color: #13151a;
|
||||
--accent: 89, 0, 255; /* Brighter, more saturated purple */
|
||||
--accent-light: 124, 58, 237;
|
||||
--accent-dark: 76, 29, 149;
|
||||
--accent-bg: 89, 0, 255; /* Same as accent for bg */
|
||||
--accent-text: 255, 255, 255; /* White text for contrast */
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
:root[data-theme="dark"] {
|
||||
--bg-primary: #13151a;
|
||||
--bg-secondary: #23262d;
|
||||
--bg-hover: #2a2d35;
|
||||
--text-color: #ffffff;
|
||||
--accent: 136, 58, 234;
|
||||
--accent-light: 224, 204, 250;
|
||||
--accent-dark: 49, 10, 101;
|
||||
--accent-bg: 136, 58, 234; /* Same as accent for bg */
|
||||
--accent-text: 255, 255, 255; /* White text for contrast */
|
||||
}
|
||||
|
||||
/* Default to dark theme if no data-theme is set */
|
||||
:root:not([data-theme]) {
|
||||
--bg-primary: #13151a;
|
||||
--bg-secondary: #23262d;
|
||||
--bg-hover: #2a2d35;
|
||||
--text-color: #ffffff;
|
||||
--accent: 136, 58, 234;
|
||||
--accent-light: 224, 204, 250;
|
||||
--accent-dark: 49, 10, 101;
|
||||
--accent-bg: 136, 58, 234;
|
||||
--accent-text: 255, 255, 255;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: system-ui, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family:
|
||||
Menlo,
|
||||
Monaco,
|
||||
Lucida Console,
|
||||
Liberation Mono,
|
||||
DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono,
|
||||
Courier New,
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: .5; }
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style define:vars={{ bgUrl: background.url }}>
|
||||
body::before {
|
||||
background-image: var(--bg-url);
|
||||
transition: background-image 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
429
src/lib/api.ts
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
import type { Comic, Genre, ComicType, ChapterDetail } from './types';
|
||||
import { getRedisClient, CHAPTER_CACHE_TTL } from './db';
|
||||
import pThrottle from 'p-throttle';
|
||||
|
||||
const languageMap = {
|
||||
"en": "English",
|
||||
"fr": "French",
|
||||
"es": "Spanish",
|
||||
"es-419": "Spanish (Latin America)",
|
||||
"it": "Italian",
|
||||
"pl": "Polish",
|
||||
"tr": "Turkish",
|
||||
"ja": "Japanese",
|
||||
"zh": "Chinese",
|
||||
"sv": "Swedish",
|
||||
"ar": "Arabic",
|
||||
"de": "German",
|
||||
"ko": "Korean",
|
||||
"pt": "Portuguese",
|
||||
"pt-br": "Portuguese (Brazil)",
|
||||
"pt-pt": "Portuguese (Portugal)"
|
||||
} as const;
|
||||
|
||||
const languages = new Set(Object.keys(languageMap));
|
||||
const contentTypes = new Set(["sfw", "nsfw"]);
|
||||
const comicTypes = new Set(['manga', 'manhwa', 'manhua', 'all']);
|
||||
const urlBase = "https://api.comick.fun/chapter";
|
||||
|
||||
let genreCache: Genre[] = [];
|
||||
|
||||
const RETRY_COUNT = 3;
|
||||
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> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; ComicReader/1.0)',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// 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) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (retries > 0) {
|
||||
// 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);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGenres(): Promise<Genre[]> {
|
||||
if (genreCache.length > 0) return genreCache;
|
||||
|
||||
try {
|
||||
// No tachiyomi param for genre endpoint
|
||||
const response = await throttledFetch('https://api.comick.fun/genre');
|
||||
genreCache = await response.json();
|
||||
return genreCache;
|
||||
} catch (error) {
|
||||
console.error('Error fetching genres:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getGenreNames(genreIds: number[], genres: Genre[]): string[] {
|
||||
return genreIds
|
||||
.map(id => genres.find(g => g.id === id)?.name ?? '')
|
||||
.filter(name => name !== '');
|
||||
}
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public endpoint?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'APIError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchComics(
|
||||
language: string = 'en',
|
||||
contentType: string = 'sfw',
|
||||
comicType: ComicType = 'all',
|
||||
page: number = 1
|
||||
): Promise<Comic[]> {
|
||||
const apiUrl = `${urlBase}?lang=${language}&page=${page}&order=new&accept_erotic_content=${contentType === 'nsfw'}${comicType === 'all' ? '' : `&type=${comicType}`}`;
|
||||
|
||||
try {
|
||||
console.log('Fetching comics from:', apiUrl);
|
||||
|
||||
const response = await throttledFetch(apiUrl);
|
||||
const data = await response.json();
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
throw new APIError('Invalid API response format', undefined, apiUrl);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof APIError) throw error;
|
||||
throw new APIError(
|
||||
'Failed to fetch comics',
|
||||
error instanceof Response ? error.status : undefined,
|
||||
apiUrl
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidLanguage(lang: string): boolean {
|
||||
return languages.has(lang);
|
||||
}
|
||||
|
||||
export function isValidContentType(type: string): boolean {
|
||||
return contentTypes.has(type);
|
||||
}
|
||||
|
||||
export function isValidComicType(type: string): boolean {
|
||||
return comicTypes.has(type);
|
||||
}
|
||||
|
||||
export function getLanguageName(code: string): string {
|
||||
return languageMap[code as keyof typeof languageMap] || code.toUpperCase();
|
||||
}
|
||||
|
||||
export const availableLanguages = Array.from(languages);
|
||||
export const availableContentTypes = Array.from(contentTypes);
|
||||
export const availableComicTypes = Array.from(comicTypes);
|
||||
export { languageMap };
|
||||
|
||||
interface ComicDetail {
|
||||
comic: {
|
||||
hid: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChapterParams {
|
||||
limit?: number;
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
export async function getComicBySlug(slug: string): Promise<ComicDetail | null> {
|
||||
try {
|
||||
const response = await throttledFetch(`https://api.comick.fun/comic/${slug}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.comic?.hid) {
|
||||
console.error(`Invalid comic data for slug ${slug}:`, data);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching comic ${slug}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getChaptersByHid(
|
||||
hid: string,
|
||||
slug: string,
|
||||
params: ChapterParams = { limit: 5, lang: 'en' }
|
||||
): Promise<ChapterDetail[]> {
|
||||
try {
|
||||
const queryParams = new URLSearchParams({
|
||||
limit: params.limit?.toString() || '5',
|
||||
lang: params.lang || 'en',
|
||||
ordering: '-created_at'
|
||||
});
|
||||
|
||||
// Fetch comic details including cover
|
||||
const comicResponse = await throttledFetch(
|
||||
`https://api.comick.fun/comic/${hid}`
|
||||
);
|
||||
const comicData = await comicResponse.json();
|
||||
const comicTitle = comicData?.comic?.title || 'Unknown Comic';
|
||||
const covers = comicData?.comic?.md_covers || [];
|
||||
|
||||
const response = await throttledFetch(
|
||||
`https://api.comick.fun/comic/${hid}/chapters?${queryParams}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data?.chapters || !Array.isArray(data.chapters)) {
|
||||
console.error(`Invalid chapters data for hid ${hid}:`, data);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('Processing chapters for comic:', comicTitle);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const chapters: ChapterDetail[] = [];
|
||||
const rawChapters = data.chapters || [];
|
||||
|
||||
for (const chap of rawChapters) {
|
||||
try {
|
||||
// Add created_at check
|
||||
if (!chap.id || !chap.chap || !chap.created_at) {
|
||||
console.warn(`Skipping chapter with missing fields in ${hid}:`, chap);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate both dates
|
||||
const isValidCreated = !isNaN(Date.parse(chap.created_at));
|
||||
const isValidUpdated = !isNaN(Date.parse(chap.updated_at));
|
||||
|
||||
if (!isValidCreated || !isValidUpdated) {
|
||||
console.warn(`Invalid dates for chapter ${chap.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
chapters.push({
|
||||
id: chap.id,
|
||||
chap: chap.chap.toString(),
|
||||
title: chap.title || `Chapter ${chap.chap}`,
|
||||
created_at: new Date(chap.created_at).toISOString(), // Use created_at
|
||||
md_comics: {
|
||||
title: comicTitle,
|
||||
slug: slug,
|
||||
md_covers: covers
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Error processing chapter ${chap.id}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Successfully processed ${chapters.length} chapters for ${comicTitle}`);
|
||||
return chapters;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching chapters for hid ${hid}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Add deduplication function
|
||||
function deduplicateChapters(chapters: ChapterDetail[]): ChapterDetail[] {
|
||||
const seen = new Set<string>();
|
||||
return chapters.filter(chapter => {
|
||||
const key = `${chapter.md_comics.slug}-${chapter.chap}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChaptersForSlugs(
|
||||
slugs: string[],
|
||||
lang: string = 'en'
|
||||
): Promise<ChapterDetail[]> {
|
||||
console.log(`Fetching chapters for ${slugs.length} comics`);
|
||||
const redis = await getRedisClient();
|
||||
|
||||
const uniqueSlugs = [...new Set(slugs)];
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
// Check cache for all slugs
|
||||
uniqueSlugs.forEach(slug => {
|
||||
if (!slug.match(/^[a-z0-9-]+$/)) {
|
||||
console.error(`Invalid slug format: ${slug}`);
|
||||
return; // Skip invalid slugs immediately
|
||||
}
|
||||
const cacheKey = `chapters:${slug}:${lang}`;
|
||||
pipeline.get(cacheKey);
|
||||
});
|
||||
|
||||
const cacheResults = await pipeline.exec();
|
||||
|
||||
// Process cache results
|
||||
const cachedChapters: ChapterDetail[] = [];
|
||||
const slugsToFetch: string[] = [];
|
||||
|
||||
// Separate cached and uncached slugs in one pass
|
||||
cacheResults.forEach(([err, result], index) => {
|
||||
const slug = uniqueSlugs[index];
|
||||
if (!err && result) {
|
||||
try {
|
||||
const parsed = JSON.parse(result as string);
|
||||
if (Array.isArray(parsed)) {
|
||||
cachedChapters.push(...parsed);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Cache parse error for ${slug}:`, e);
|
||||
slugsToFetch.push(slug);
|
||||
}
|
||||
} else {
|
||||
slugsToFetch.push(slug);
|
||||
}
|
||||
});
|
||||
|
||||
// Skip validation if we have recent cache data
|
||||
const cachedSlugs = new Set(cachedChapters.map(c => c.md_comics.slug));
|
||||
console.log(`Cache hits: ${cachedChapters.length} chapters from ${cachedSlugs.size} comics`);
|
||||
|
||||
// Only fetch if we have slugs that need fetching
|
||||
let liveChapters: ChapterDetail[] = [];
|
||||
if (slugsToFetch.length > 0) {
|
||||
console.log(`Fetching ${slugsToFetch.length} comics from API`);
|
||||
|
||||
// Use Promise.all for concurrent fetching with higher concurrency
|
||||
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 [];
|
||||
}
|
||||
});
|
||||
|
||||
liveChapters = (await Promise.all(fetchPromises)).flat();
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
Object.entries(chaptersBySlug).forEach(([slug, chaps]) => {
|
||||
const cacheKey = `chapters:${slug}:${lang}`;
|
||||
cachePipeline.set(
|
||||
cacheKey,
|
||||
JSON.stringify(chaps),
|
||||
'EX',
|
||||
CHAPTER_CACHE_TTL
|
||||
);
|
||||
});
|
||||
|
||||
await cachePipeline.exec();
|
||||
}
|
||||
}
|
||||
|
||||
// Combine and deduplicate
|
||||
return deduplicateChapters([...cachedChapters, ...liveChapters]);
|
||||
}
|
||||
|
||||
// Replace validateSlugs with a more efficient implementation
|
||||
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 invalidSlugs: string[] = [];
|
||||
|
||||
// Increase batch size for faster processing
|
||||
const batchSize = 5;
|
||||
for (let i = 0; i < slugs.length; i += batchSize) {
|
||||
const batch = slugs.slice(i, i + batchSize);
|
||||
|
||||
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);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
if (invalidSlugs.length > 0) {
|
||||
console.warn(`Skipping invalid slugs: ${invalidSlugs.length} items`);
|
||||
}
|
||||
|
||||
return validSlugs;
|
||||
}
|
||||
823
src/lib/db.ts
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
import Redis from "ioredis";
|
||||
import type { FeedMapping } from "./types";
|
||||
import { ReJSON } from 'redis-modules-sdk';
|
||||
import * as zlib from 'zlib';
|
||||
import { promisify } from 'util';
|
||||
|
||||
// Configuration constants
|
||||
const DEFAULT_CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
|
||||
export const CHAPTER_CACHE_TTL = 60 * 60; // 1 hour
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 50;
|
||||
const IS_VERCEL = process.env.VERCEL || false;
|
||||
const HEALTH_CHECK_INTERVAL = 60 * 1000; // Check primary health every minute
|
||||
const REPLICA_PROMOTION_THRESHOLD = 5; // After 5 failed primary health checks, promote a replica to primary
|
||||
const SYNC_BUFFER_SIZE = 100; // Maximum number of operations to buffer before syncing
|
||||
const SYNC_INTERVAL = 10 * 1000; // Sync replicas every 10 seconds
|
||||
|
||||
// Redis URLs from environment variables
|
||||
const PRIMARY_REDIS_URL =
|
||||
import.meta.env.PRIMARY_REDIS_URL ||
|
||||
process.env.PRIMARY_REDIS_URL ||
|
||||
process.env.REDIS_URL;
|
||||
|
||||
const REPLICA_URLS = [
|
||||
import.meta.env.REPLICA_REDIS_URL_1 ||
|
||||
process.env.REPLICA_REDIS_URL_1 ||
|
||||
import.meta.env.REPLICA_REDIS_URL,
|
||||
import.meta.env.REPLICA_REDIS_URL_2 || process.env.REPLICA_REPLICA_URL_2 || process.env.REPLICA_REDIS_URL_2,
|
||||
import.meta.env.REPLICA_REDIS_URL_3 || process.env.REPLICA_REDIS_URL_3,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// Log available Redis instances
|
||||
console.log(`Primary Redis URL: ${PRIMARY_REDIS_URL ? 'Available' : 'Not configured'}`);
|
||||
console.log(`Replica URLs available: ${REPLICA_URLS.length}`);
|
||||
|
||||
// Singleton clients with health status tracking
|
||||
let primaryClient: Redis | null = null;
|
||||
let primaryHealthy: boolean = true;
|
||||
let primaryFailedChecks = 0;
|
||||
let replicaClients: Map<string, Redis> = new Map();
|
||||
let activeReplicaUrl: string | null = null;
|
||||
|
||||
// Track which replica was used when primary was down
|
||||
let lastUsedReplica: string | null = null;
|
||||
|
||||
// Operation buffer for sync
|
||||
interface RedisOperation {
|
||||
key: string;
|
||||
value: string;
|
||||
ttl?: number;
|
||||
type: 'set' | 'del';
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Buffer to store operations that need to be synced
|
||||
const operationBuffer: RedisOperation[] = [];
|
||||
|
||||
// Redis instance naming for logging
|
||||
const redisInstanceNames = new Map<string, string>();
|
||||
|
||||
// Add zlib for compression
|
||||
const gzip = promisify(zlib.gzip);
|
||||
const gunzip = promisify(zlib.gunzip);
|
||||
|
||||
// Compression threshold (in bytes)
|
||||
const COMPRESSION_THRESHOLD = 1024; // 1KB
|
||||
|
||||
// Helper function to determine if compression should be used
|
||||
function shouldCompress(data: string): boolean {
|
||||
return Buffer.byteLength(data, 'utf8') > COMPRESSION_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Redis instance names for better logging
|
||||
*/
|
||||
function initializeRedisNames() {
|
||||
if (PRIMARY_REDIS_URL) {
|
||||
redisInstanceNames.set(PRIMARY_REDIS_URL, "Primary");
|
||||
}
|
||||
|
||||
REPLICA_URLS.forEach((url, index) =>
|
||||
redisInstanceNames.set(url, `Replica ${index + 1}`)
|
||||
);
|
||||
}
|
||||
|
||||
initializeRedisNames();
|
||||
|
||||
/**
|
||||
* Get the friendly name of a Redis instance
|
||||
*/
|
||||
const getRedisName = (url: string) => redisInstanceNames.get(url) || "Unknown";
|
||||
|
||||
// Add at the top of your file, after the imports
|
||||
declare module "ioredis" {
|
||||
interface Redis {
|
||||
json_set(key: string, path: string, json: any): Promise<string>;
|
||||
json_get(key: string, ...paths: string[]): Promise<string>;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Redis client with optimized settings
|
||||
*/
|
||||
async function createRedisClient(url: string): Promise<Redis> {
|
||||
if (!url) {
|
||||
throw new Error("Redis URL not provided");
|
||||
}
|
||||
|
||||
const redisName = getRedisName(url);
|
||||
|
||||
const client = new Redis(url, {
|
||||
retryStrategy: (times) => {
|
||||
if (times >= MAX_RETRIES) return null;
|
||||
return Math.min(BASE_DELAY_MS * Math.pow(1.5, times), 1000);
|
||||
},
|
||||
maxRetriesPerRequest: 2,
|
||||
enableOfflineQueue: true,
|
||||
enableReadyCheck: true,
|
||||
connectTimeout: 3000,
|
||||
keepAlive: 5000,
|
||||
commandTimeout: 3000,
|
||||
reconnectOnError: (err) =>
|
||||
["READONLY", "ETIMEDOUT", "ECONNRESET"].some((e) =>
|
||||
err.message.includes(e)
|
||||
),
|
||||
});
|
||||
|
||||
// Setup event listeners
|
||||
client
|
||||
.on("ready", () => {
|
||||
console.log(`${redisName} ready`);
|
||||
|
||||
// If primary recovers and we had used a replica, initiate sync from replica to primary
|
||||
if (url === PRIMARY_REDIS_URL && lastUsedReplica) {
|
||||
syncFromReplicaToPrimary(lastUsedReplica).catch(err =>
|
||||
console.error(`Failed to sync from replica to primary: ${err}`));
|
||||
}
|
||||
})
|
||||
.on("error", (err) => {
|
||||
console.error(`${redisName} error:`, err);
|
||||
if (url === PRIMARY_REDIS_URL) {
|
||||
primaryHealthy = false;
|
||||
}
|
||||
})
|
||||
.on("reconnecting", () => console.log(`${redisName} reconnecting`))
|
||||
.on("end", () => {
|
||||
console.log(`${redisName} closed`);
|
||||
if (url === PRIMARY_REDIS_URL) {
|
||||
primaryHealthy = false;
|
||||
}
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all replicas to establish connections early
|
||||
*/
|
||||
async function initializeAllClients(): Promise<void> {
|
||||
try {
|
||||
// Initialize primary
|
||||
if (PRIMARY_REDIS_URL && (!primaryClient || primaryClient.status !== "ready")) {
|
||||
primaryClient = await createRedisClient(PRIMARY_REDIS_URL);
|
||||
primaryHealthy = await checkConnection(primaryClient, "Primary");
|
||||
console.log(`Primary Redis initialized, healthy: ${primaryHealthy}`);
|
||||
}
|
||||
|
||||
// Initialize all replicas
|
||||
for (const url of REPLICA_URLS) {
|
||||
if (!replicaClients.has(url)) {
|
||||
try {
|
||||
const client = await createRedisClient(url);
|
||||
const isHealthy = await checkConnection(client, getRedisName(url));
|
||||
if (isHealthy) {
|
||||
replicaClients.set(url, client);
|
||||
console.log(`Replica ${getRedisName(url)} initialized successfully`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to initialize replica ${getRedisName(url)}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Initialized ${replicaClients.size} replica connections`);
|
||||
} catch (error) {
|
||||
console.error("Error during client initialization:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Redis client is connected and responsive
|
||||
*/
|
||||
async function checkConnection(client: Redis, name: string): Promise<boolean> {
|
||||
try {
|
||||
await Promise.race([
|
||||
client.ping(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Ping timeout")), 2000)
|
||||
),
|
||||
]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn(`${name} ping failed:`, err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an active Redis client from the pool
|
||||
* Prioritizes primary, falls back to replicas
|
||||
*/
|
||||
export async function getRedisClient(): Promise<Redis> {
|
||||
// Try primary client first if it's considered healthy
|
||||
if (primaryHealthy && (primaryClient === null || primaryClient.status !== "ready")) {
|
||||
try {
|
||||
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
|
||||
const isPrimaryConnected = await checkConnection(primaryClient, "Primary");
|
||||
primaryHealthy = isPrimaryConnected;
|
||||
if (isPrimaryConnected) {
|
||||
primaryFailedChecks = 0;
|
||||
console.log("Initialized primary Redis connection");
|
||||
return primaryClient;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize primary Redis:", err);
|
||||
primaryHealthy = false;
|
||||
primaryFailedChecks++;
|
||||
}
|
||||
} else if (primaryHealthy && primaryClient && primaryClient.status === "ready") {
|
||||
return primaryClient;
|
||||
}
|
||||
|
||||
// Primary is unhealthy, try to use the active replica if we have one
|
||||
if (activeReplicaUrl && replicaClients.has(activeReplicaUrl)) {
|
||||
const activeReplica = replicaClients.get(activeReplicaUrl)!;
|
||||
if (activeReplica.status === "ready" && await checkConnection(activeReplica, getRedisName(activeReplicaUrl))) {
|
||||
console.log(`Using active replica: ${getRedisName(activeReplicaUrl)}`);
|
||||
lastUsedReplica = activeReplicaUrl; // Track that we used this replica
|
||||
return activeReplica;
|
||||
}
|
||||
}
|
||||
|
||||
// No active replica, try replicas in order
|
||||
for (const url of REPLICA_URLS) {
|
||||
try {
|
||||
let client = replicaClients.get(url);
|
||||
if (!client || client.status !== "ready") {
|
||||
client = await createRedisClient(url);
|
||||
replicaClients.set(url, client);
|
||||
}
|
||||
|
||||
if (await checkConnection(client, getRedisName(url))) {
|
||||
console.log(`Switching to replica: ${getRedisName(url)}`);
|
||||
activeReplicaUrl = url;
|
||||
lastUsedReplica = url; // Track that we used this replica
|
||||
|
||||
// If primary has been down for a while, promote this replica to be the new primary
|
||||
if (primaryFailedChecks >= REPLICA_PROMOTION_THRESHOLD) {
|
||||
console.log(`Promoting ${getRedisName(url)} to primary after ${primaryFailedChecks} failed checks`);
|
||||
primaryClient = client;
|
||||
primaryHealthy = true;
|
||||
primaryFailedChecks = 0;
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to connect to replica ${getRedisName(url)}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// If all else fails, try one more time with primary
|
||||
console.warn("All Redis connections failed, making last attempt with primary");
|
||||
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
|
||||
return primaryClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available replicas that are not the active client
|
||||
*/
|
||||
async function getAvailableReplicas(activeClientUrl: string): Promise<Redis[]> {
|
||||
const availableReplicas: Redis[] = [];
|
||||
|
||||
// Initialize clients if not already done
|
||||
await initializeAllClients();
|
||||
|
||||
// Check each replica
|
||||
for (const [url, client] of replicaClients.entries()) {
|
||||
if (url !== activeClientUrl && client.status === "ready") {
|
||||
try {
|
||||
const isHealthy = await checkConnection(client, getRedisName(url));
|
||||
if (isHealthy) {
|
||||
availableReplicas.push(client);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking replica ${getRedisName(url)}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${availableReplicas.length} available replicas for replication`);
|
||||
return availableReplicas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately replicate a Redis operation to all available replicas
|
||||
*/
|
||||
async function replicateOperation(
|
||||
activeClientUrl: string,
|
||||
key: string,
|
||||
value: string,
|
||||
ttl?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get available replicas that are not the active client
|
||||
const replicas = await getAvailableReplicas(activeClientUrl);
|
||||
|
||||
if (replicas.length === 0) {
|
||||
console.log('No available replicas for replication');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Replicating operation for key ${key} to ${replicas.length} replicas`);
|
||||
|
||||
// Replicate to each available replica
|
||||
const replicationPromises = replicas.map(async (replica) => {
|
||||
try {
|
||||
if (ttl) {
|
||||
await replica.set(key, value, 'EX', ttl);
|
||||
} else {
|
||||
await replica.set(key, value);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Replication failed for key ${key}:`, error);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(replicationPromises);
|
||||
const successful = results.filter(r => r.status === 'fulfilled' && r.value === true).length;
|
||||
|
||||
console.log(`Successfully replicated to ${successful}/${replicas.length} replicas`);
|
||||
} catch (error) {
|
||||
console.error('Error during replication:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize data from a replica to the primary
|
||||
* Used when primary recovers after being down
|
||||
*/
|
||||
async function syncFromReplicaToPrimary(replicaUrl: string): Promise<void> {
|
||||
if (!primaryClient || primaryClient.status !== "ready" || !replicaClients.has(replicaUrl)) {
|
||||
console.log("Cannot sync: primary or replica not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const replicaClient = replicaClients.get(replicaUrl)!;
|
||||
console.log(`Starting sync from ${getRedisName(replicaUrl)} to Primary...`);
|
||||
|
||||
try {
|
||||
// Get all feed keys using scan to prevent blocking
|
||||
const feedKeys: string[] = [];
|
||||
let cursor = '0';
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await replicaClient.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'[0-9a-f]*', // Match feed IDs (hex format)
|
||||
'COUNT',
|
||||
'100'
|
||||
);
|
||||
|
||||
cursor = nextCursor;
|
||||
feedKeys.push(...keys);
|
||||
} while (cursor !== '0');
|
||||
|
||||
// Also try scanning for any key pattern that might be a feed ID
|
||||
cursor = '0';
|
||||
const additionalKeys: string[] = [];
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await replicaClient.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'feed:*', // Try another pattern
|
||||
'COUNT',
|
||||
'100'
|
||||
);
|
||||
|
||||
cursor = nextCursor;
|
||||
additionalKeys.push(...keys);
|
||||
} while (cursor !== '0');
|
||||
|
||||
feedKeys.push(...additionalKeys);
|
||||
|
||||
// Get chapter cache keys
|
||||
const chapterKeys: string[] = [];
|
||||
cursor = '0';
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await replicaClient.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'chapters:*',
|
||||
'COUNT',
|
||||
'100'
|
||||
);
|
||||
|
||||
cursor = nextCursor;
|
||||
chapterKeys.push(...keys);
|
||||
} while (cursor !== '0');
|
||||
|
||||
console.log(`Found ${feedKeys.length} feed keys and ${chapterKeys.length} chapter keys to sync`);
|
||||
|
||||
// Sync feed mappings
|
||||
if (feedKeys.length > 0) {
|
||||
const feedPipeline = primaryClient.pipeline();
|
||||
|
||||
for (const key of feedKeys) {
|
||||
const value = await replicaClient.get(key);
|
||||
const ttl = await replicaClient.ttl(key);
|
||||
|
||||
if (value && ttl > 0) {
|
||||
feedPipeline.set(key, value, 'EX', ttl);
|
||||
}
|
||||
}
|
||||
|
||||
await feedPipeline.exec();
|
||||
console.log(`Synced ${feedKeys.length} feed mappings from replica to primary`);
|
||||
}
|
||||
|
||||
// Sync chapter cache
|
||||
if (chapterKeys.length > 0) {
|
||||
const chapterPipeline = primaryClient.pipeline();
|
||||
|
||||
for (const key of chapterKeys) {
|
||||
const value = await replicaClient.get(key);
|
||||
const ttl = await replicaClient.ttl(key);
|
||||
|
||||
if (value && ttl > 0) {
|
||||
chapterPipeline.set(key, value, 'EX', ttl);
|
||||
}
|
||||
}
|
||||
|
||||
await chapterPipeline.exec();
|
||||
console.log(`Synced ${chapterKeys.length} chapter caches from replica to primary`);
|
||||
}
|
||||
|
||||
lastUsedReplica = null; // Reset after successful sync
|
||||
console.log("Replica to primary sync completed successfully");
|
||||
} catch (error) {
|
||||
console.error("Error during replica to primary sync:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a feed mapping in Redis with immediate replication
|
||||
*/
|
||||
export async function storeFeedMapping(
|
||||
feedId: string,
|
||||
slugs: string[],
|
||||
lang: string
|
||||
): Promise<void> {
|
||||
const mapping = {
|
||||
slugs,
|
||||
lang,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const stringValue = JSON.stringify(mapping);
|
||||
|
||||
try {
|
||||
// Get Redis client
|
||||
const redis = await getRedisClient();
|
||||
const activeUrl = redis.options.host || '';
|
||||
|
||||
// Check if compression should be used
|
||||
if (shouldCompress(stringValue)) {
|
||||
console.log(`Compressing large feed mapping for ${feedId} (${Buffer.byteLength(stringValue, 'utf8')} bytes)`);
|
||||
const compressed = await gzip(stringValue);
|
||||
const base64Compressed = compressed.toString('base64');
|
||||
|
||||
// Store with compression flag
|
||||
await redis.set(`${feedId}:compressed`, base64Compressed, "EX", DEFAULT_CACHE_TTL);
|
||||
console.log(`Stored compressed mapping (${base64Compressed.length} bytes)`);
|
||||
|
||||
// Replicate to other instances
|
||||
const replicas = await getAvailableReplicas(activeUrl);
|
||||
for (const replica of replicas) {
|
||||
await replica.set(`${feedId}:compressed`, base64Compressed, "EX", DEFAULT_CACHE_TTL);
|
||||
}
|
||||
} else {
|
||||
// Use standard set operations without compression
|
||||
await redis.set(feedId, stringValue, "EX", DEFAULT_CACHE_TTL);
|
||||
|
||||
// Replicate to other instances
|
||||
const replicas = await getAvailableReplicas(activeUrl);
|
||||
for (const replica of replicas) {
|
||||
await replica.set(feedId, stringValue, "EX", DEFAULT_CACHE_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Successfully stored mapping for feed ${feedId}`);
|
||||
} catch (error) {
|
||||
console.error(`Error storing feed mapping for ${feedId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a feed mapping from Redis with automatic failover
|
||||
*/
|
||||
export async function getFeedMapping(
|
||||
feedId: string
|
||||
): Promise<FeedMapping | null> {
|
||||
try {
|
||||
// Try primary first
|
||||
const redis = await getRedisClient();
|
||||
|
||||
// Check for compressed version first
|
||||
const compressedKey = `${feedId}:compressed`;
|
||||
const compressedData = await redis.get(compressedKey);
|
||||
|
||||
if (compressedData) {
|
||||
console.log(`Found compressed data for ${feedId}`);
|
||||
try {
|
||||
// Decompress and parse
|
||||
const buffer = Buffer.from(compressedData, 'base64');
|
||||
const decompressed = await gunzip(buffer);
|
||||
const data = JSON.parse(decompressed.toString());
|
||||
|
||||
// Reset expiration
|
||||
await redis.expire(compressedKey, DEFAULT_CACHE_TTL);
|
||||
|
||||
return {
|
||||
slugs: data.slugs,
|
||||
lang: data.lang
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error decompressing data for ${feedId}:`, error);
|
||||
// Fall through to try uncompressed version
|
||||
}
|
||||
}
|
||||
|
||||
// If no compressed version, try regular key
|
||||
let jsonString = await redis.get(feedId);
|
||||
|
||||
// If not found in primary but we have replicas, check them
|
||||
if (!jsonString && REPLICA_URLS.length > 0 && redis === primaryClient) {
|
||||
console.log(`Key ${feedId} not found in primary, checking replicas...`);
|
||||
|
||||
// Try each replica
|
||||
for (const [url, replicaClient] of replicaClients.entries()) {
|
||||
if (replicaClient.status === "ready") {
|
||||
try {
|
||||
jsonString = await replicaClient.get(feedId);
|
||||
if (jsonString) {
|
||||
console.log(`Found key ${feedId} in replica ${getRedisName(url)}`);
|
||||
|
||||
// Sync this key back to primary for future requests
|
||||
if (primaryClient && primaryClient.status === "ready") {
|
||||
primaryClient.set(feedId, jsonString, "EX", DEFAULT_CACHE_TTL)
|
||||
.catch(err => console.error(`Failed to sync key ${feedId} to primary:`, err));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking replica ${getRedisName(url)} for ${feedId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!jsonString) return null;
|
||||
|
||||
// Reset expiration on access
|
||||
await redis.expire(feedId, DEFAULT_CACHE_TTL);
|
||||
|
||||
// Parse the result
|
||||
const data = JSON.parse(jsonString as string);
|
||||
return {
|
||||
slugs: data.slugs,
|
||||
lang: data.lang
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving feed mapping for ${feedId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the chapter cache for better performance
|
||||
* This function checks cache status and uses the getChaptersForSlugs function from api.ts
|
||||
* to fetch and cache data in the background
|
||||
*/
|
||||
export async function warmChapterCache(
|
||||
slugs: string[],
|
||||
lang: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`Warming chapter cache for ${slugs.length} comics in ${lang}`);
|
||||
const redis = await getRedisClient(); // This already handles failover
|
||||
|
||||
// Validate slugs
|
||||
const validSlugs = slugs.filter(slug =>
|
||||
slug && typeof slug === 'string' && slug.match(/^[a-z0-9-]+$/));
|
||||
|
||||
if (validSlugs.length !== slugs.length) {
|
||||
console.warn(`Filtered out ${slugs.length - validSlugs.length} invalid slugs`);
|
||||
}
|
||||
|
||||
// Check which slugs are already in cache
|
||||
const pipeline = redis.pipeline();
|
||||
const cacheKeys = validSlugs.map(slug => `chapters:${slug}:${lang}`);
|
||||
|
||||
cacheKeys.forEach(key => {
|
||||
pipeline.exists(key);
|
||||
});
|
||||
|
||||
const results = await pipeline.exec();
|
||||
const slugsToFetch: string[] = [];
|
||||
|
||||
results?.forEach(([err, exists], index) => {
|
||||
if (err) {
|
||||
console.error(`Error checking cache for ${cacheKeys[index]}:`, err);
|
||||
slugsToFetch.push(validSlugs[index]);
|
||||
} else if (!exists) {
|
||||
slugsToFetch.push(validSlugs[index]);
|
||||
}
|
||||
});
|
||||
|
||||
if (slugsToFetch.length === 0) {
|
||||
console.log('All chapters already in cache, no warming needed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Cache miss for ${slugsToFetch.length} comics - starting background fetch`);
|
||||
|
||||
// To avoid circular dependencies, we'll import getChaptersForSlugs dynamically
|
||||
const { getChaptersForSlugs } = await import('./api');
|
||||
|
||||
// Start fetching in the background without waiting for the result
|
||||
getChaptersForSlugs(slugsToFetch, lang)
|
||||
.then(chapters => {
|
||||
console.log(`Successfully warmed cache with ${chapters.length} chapters`);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error warming chapter cache:', error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in warmChapterCache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a new function for handling large feed mappings with compression
|
||||
export async function storeLargeFeedMapping(
|
||||
feedId: string,
|
||||
slugs: string[],
|
||||
lang: string
|
||||
): Promise<void> {
|
||||
const mapping = {
|
||||
slugs,
|
||||
lang,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const jsonString = JSON.stringify(mapping);
|
||||
|
||||
try {
|
||||
const redis = await getRedisClient();
|
||||
const activeUrl = redis.options.host || '';
|
||||
|
||||
// Use compression for large mappings
|
||||
if (shouldCompress(jsonString)) {
|
||||
const compressed = await gzip(jsonString);
|
||||
const base64Compressed = compressed.toString('base64');
|
||||
|
||||
// Store with compression flag
|
||||
await redis.set(`${feedId}:compressed`, base64Compressed, "EX", DEFAULT_CACHE_TTL);
|
||||
|
||||
// Replicate to other instances
|
||||
await replicateOperation(activeUrl, `${feedId}:compressed`, base64Compressed, DEFAULT_CACHE_TTL);
|
||||
|
||||
console.log(`Successfully stored compressed mapping for feed ${feedId} (${jsonString.length} → ${base64Compressed.length} bytes)`);
|
||||
} else {
|
||||
// Use normal storage for small mappings
|
||||
await redis.set(feedId, jsonString, "EX", DEFAULT_CACHE_TTL);
|
||||
await replicateOperation(activeUrl, feedId, jsonString, DEFAULT_CACHE_TTL);
|
||||
console.log(`Successfully stored mapping for feed ${feedId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error storing feed mapping for ${feedId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Batch get multiple feed mappings at once
|
||||
export async function getBatchFeedMappings(
|
||||
feedIds: string[]
|
||||
): Promise<Record<string, FeedMapping | null>> {
|
||||
if (!feedIds.length) return {};
|
||||
|
||||
try {
|
||||
const redis = await getRedisClient();
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
// Check regular keys first
|
||||
feedIds.forEach(id => {
|
||||
pipeline.get(id);
|
||||
});
|
||||
|
||||
const results = await pipeline.exec();
|
||||
if (!results) return {};
|
||||
|
||||
// Process results
|
||||
const mappings: Record<string, FeedMapping | null> = {};
|
||||
const ttlUpdates = redis.pipeline();
|
||||
const compressedChecks = redis.pipeline();
|
||||
const compressedIds: string[] = [];
|
||||
|
||||
for (let i = 0; i < feedIds.length; i++) {
|
||||
const id = feedIds[i];
|
||||
const result = results[i];
|
||||
|
||||
if (result[0]) {
|
||||
mappings[id] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result[1]) {
|
||||
// Process regular mapping
|
||||
try {
|
||||
const data = JSON.parse(result[1] as string);
|
||||
mappings[id] = {
|
||||
slugs: data.slugs,
|
||||
lang: data.lang
|
||||
};
|
||||
|
||||
// Reset TTL
|
||||
ttlUpdates.expire(id, DEFAULT_CACHE_TTL);
|
||||
} catch (e) {
|
||||
mappings[id] = null;
|
||||
}
|
||||
} else {
|
||||
// Check for compressed version
|
||||
compressedChecks.exists(`${id}:compressed`);
|
||||
compressedIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute TTL updates in background
|
||||
ttlUpdates.exec().catch(err => console.error('TTL update error:', err));
|
||||
|
||||
// Check compressed versions if needed
|
||||
if (compressedIds.length > 0) {
|
||||
const compressedResults = await compressedChecks.exec();
|
||||
|
||||
if (compressedResults) {
|
||||
const compressedFetches = redis.pipeline();
|
||||
const compressedToFetch: string[] = [];
|
||||
|
||||
compressedResults.forEach((result, index) => {
|
||||
if (!result[0] && result[1]) {
|
||||
const id = compressedIds[index];
|
||||
compressedFetches.get(`${id}:compressed`);
|
||||
compressedToFetch.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
if (compressedToFetch.length > 0) {
|
||||
const fetchResults = await compressedFetches.exec();
|
||||
|
||||
if (fetchResults) {
|
||||
for (let i = 0; i < compressedToFetch.length; i++) {
|
||||
const id = compressedToFetch[i];
|
||||
const [err, compressedData] = fetchResults[i];
|
||||
|
||||
if (!err && compressedData) {
|
||||
try {
|
||||
const buffer = Buffer.from(compressedData as string, 'base64');
|
||||
const decompressed = await gunzip(buffer);
|
||||
const data = JSON.parse(decompressed.toString());
|
||||
|
||||
mappings[id] = {
|
||||
slugs: data.slugs,
|
||||
lang: data.lang
|
||||
};
|
||||
|
||||
// Reset TTL in background
|
||||
redis.expire(`${id}:compressed`, DEFAULT_CACHE_TTL).catch(() => {});
|
||||
} catch (e) {
|
||||
mappings[id] = null;
|
||||
}
|
||||
} else {
|
||||
mappings[id] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mappings;
|
||||
} catch (error) {
|
||||
console.error(`Error retrieving batch feed mappings:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize connections eagerly to establish connections as soon as possible
|
||||
if (!import.meta.env.SSR) {
|
||||
initializeAllClients()
|
||||
.then(() => console.log("Redis clients initialized"))
|
||||
.catch(err => console.error("Failed to initialize Redis clients:", err));
|
||||
}
|
||||
|
||||
// Initialize connections lazily as backup
|
||||
console.log("Redis will connect on first use");
|
||||
53
src/lib/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
export interface Comic {
|
||||
id: string;
|
||||
chap: string;
|
||||
updated_at: string;
|
||||
md_comics: {
|
||||
title: string;
|
||||
slug: string;
|
||||
genres: number[];
|
||||
md_covers: ComicCover[];
|
||||
content_rating: 'safe' | 'suggestive' | 'erotica';
|
||||
comic_type: ComicType;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FeedMapping {
|
||||
slugs: string[];
|
||||
lang: string;
|
||||
}
|
||||
|
||||
export type ComicType = 'manga' | 'manhwa' | 'manhua' | 'all';
|
||||
|
||||
export interface ComicCover {
|
||||
b2key: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface Announcement {
|
||||
message: string;
|
||||
type: 'info' | 'warning' | 'success';
|
||||
dismissible?: boolean;
|
||||
link?: {
|
||||
text: string;
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ChapterDetail {
|
||||
id: string;
|
||||
chap: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
md_comics: {
|
||||
title: string;
|
||||
slug: string;
|
||||
md_covers?: ComicCover[];
|
||||
};
|
||||
}
|
||||
119
src/pages/[lang]/[contentType]/[type].astro
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
import Layout from '../../../layouts/Layout.astro';
|
||||
import ComicCard from '../../../components/ComicCard.astro';
|
||||
import LoadingCard from '../../../components/LoadingCard.astro';
|
||||
import RSSLink from '../../../components/RSSLink.astro';
|
||||
import TypeFilter from '../../../components/TypeFilter.astro';
|
||||
import LanguageSelector from '../../../components/LanguageSelector.astro';
|
||||
import GenreFilter from '../../../components/GenreFilter.astro';
|
||||
import { fetchComics, fetchGenres, isValidLanguage, isValidContentType, isValidComicType, getLanguageName } from '../../../lib/api';
|
||||
import type { Comic, Genre } from '../../../lib/types';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { lang, contentType, type } = Astro.params;
|
||||
|
||||
if (!isValidLanguage(lang!) || !isValidContentType(contentType!) || !isValidComicType(type!)) {
|
||||
return Astro.redirect('/en/sfw/all');
|
||||
}
|
||||
|
||||
let comics: Comic[] = [];
|
||||
let genres: Genre[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
[comics, genres] = await Promise.all([
|
||||
fetchComics(lang, contentType, type as any),
|
||||
fetchGenres(),
|
||||
]);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch comics';
|
||||
}
|
||||
|
||||
const title = `ComicK - ${getLanguageName(lang!)} ${contentType?.toUpperCase()} ${type?.toUpperCase()}`;
|
||||
---
|
||||
|
||||
<Layout title={title}>
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
|
||||
<h1 class="text-3xl font-bold text-primary">Latest Updates</h1>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<LanguageSelector currentLang={lang!} currentType={type!} contentType={contentType!} />
|
||||
<GenreFilter />
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href={`/${lang}/sfw/${type}`}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
contentType === 'sfw'
|
||||
? 'bg-[rgb(var(--accent-bg))] text-[rgb(var(--accent-text))]'
|
||||
: 'bg-secondary text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
SFW
|
||||
</a>
|
||||
<a
|
||||
href={`/${lang}/nsfw/${type}`}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
contentType === 'nsfw'
|
||||
? 'bg-[rgb(var(--accent-bg))] text-[rgb(var(--accent-text))]'
|
||||
: 'bg-secondary text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
NSFW
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="/custom"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-secondary rounded-lg hover:bg-hover transition-colors"
|
||||
>
|
||||
<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"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Custom Feed/Filter
|
||||
</a>
|
||||
<RSSLink lang={lang!} contentType={contentType!} type={type!} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypeFilter currentType={type!} lang={lang!} contentType={contentType!} />
|
||||
|
||||
{error && (
|
||||
<div class="bg-red-500 text-white p-4 rounded-lg mb-8">
|
||||
<p>Error: {error}</p>
|
||||
<button
|
||||
class="mt-2 px-4 py-2 bg-white text-red-500 rounded hover:bg-red-50 transition-colors"
|
||||
onclick="window.location.reload()"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
{comics.length > 0 ? (
|
||||
comics.map(comic => (
|
||||
<ComicCard comic={comic} genres={genres} />
|
||||
))
|
||||
) : !error ? (
|
||||
Array(10).fill(null).map(() => (
|
||||
<LoadingCard />
|
||||
))
|
||||
) : (
|
||||
<div class="col-span-full text-center bg-secondary p-6 rounded-lg">
|
||||
<p class="text-primary/60">No comics found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
108
src/pages/[lang]/[type].astro
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
import Layout from '../../layouts/Layout.astro';
|
||||
import ComicCard from '../../components/ComicCard.astro';
|
||||
import LoadingCard from '../../components/LoadingCard.astro';
|
||||
import LanguageSelector from '../../components/LanguageSelector.astro';
|
||||
import RSSLink from '../../components/RSSLink.astro';
|
||||
import { fetchComics, isValidLanguage, isValidContentType, fetchGenres, getLanguageName } from '../../lib/api';
|
||||
import type { Comic } from '../../lib/types';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
// Redirect old URLs to new structure
|
||||
return Astro.redirect(`/${Astro.params.lang}/${Astro.params.type}/all`);
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const languages = ['en', 'fr', 'es', 'it', 'pl', 'tr', 'ja', 'zh', 'sv'];
|
||||
const types = ['sfw', 'nsfw'];
|
||||
|
||||
return languages.flatMap(lang =>
|
||||
types.map(type => ({
|
||||
params: { lang, type },
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const { lang, type } = Astro.params;
|
||||
|
||||
if (!isValidLanguage(lang!) || !isValidContentType(type!)) {
|
||||
return Astro.redirect('/en/sfw');
|
||||
}
|
||||
|
||||
let comics: Comic[] = [];
|
||||
let error: string | null = null;
|
||||
const genres = await fetchGenres();
|
||||
|
||||
try {
|
||||
comics = await fetchComics(lang, type);
|
||||
console.log(`Fetched ${comics.length} comics`);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch comics:', e);
|
||||
error = e instanceof Error ? e.message : 'Failed to fetch comics';
|
||||
}
|
||||
|
||||
const title = `ComicK - ${getLanguageName(lang!)} ${type!.toUpperCase()}`;
|
||||
const description = `Latest ${type!.toUpperCase()} comics in ${getLanguageName(lang!)}. Updated regularly with new chapters.`;
|
||||
---
|
||||
|
||||
<Layout title={title} description={description}>
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] text-primary">
|
||||
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-center mb-8 sm:mb-12 bg-gradient-to-r from-accent via-accent-light to-white bg-clip-text text-transparent bg-[length:400%] bg-[0%]">
|
||||
ComicK-RSS
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-8">
|
||||
<div class="flex items-center gap-4">
|
||||
<LanguageSelector currentLang={lang!} currentType={type!} />
|
||||
<a
|
||||
href="/custom"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-accent text-secondary hover:bg-accent-light rounded-md transition-colors font-medium"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
Custom Feed/Filter
|
||||
</a>
|
||||
</div>
|
||||
<RSSLink lang={lang!} type={type!} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="bg-red-500 text-white p-4 rounded-lg mb-8">
|
||||
<p>Error: {error}</p>
|
||||
<button
|
||||
class="mt-2 px-4 py-2 bg-white text-red-500 rounded hover:bg-red-50 transition-colors"
|
||||
onclick="window.location.reload()"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-6">
|
||||
{comics.length > 0 ? (
|
||||
comics.map(comic => (
|
||||
<ComicCard comic={comic} genres={genres} />
|
||||
))
|
||||
) : !error ? (
|
||||
Array(10).fill(null).map(() => (
|
||||
<LoadingCard />
|
||||
))
|
||||
) : (
|
||||
<div class="col-span-full text-center bg-secondary p-6 rounded-lg">
|
||||
<p class="mb-4">No comics found. Please try a different language or content type.</p>
|
||||
<p class="text-sm opacity-70">Language: {getLanguageName(lang!)}, Type: {type!.toUpperCase()}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
31
src/pages/api/comic/[slug].ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { APIRoute } from "astro";
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const { slug } = params;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.comick.fun/comic/${encodeURIComponent(slug!)}/`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Comic fetch error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Failed to fetch comic details' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
30
src/pages/api/create-feed.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { createHash } from 'crypto';
|
||||
import { storeFeedMapping } from '../../lib/db';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { slugs, lang } = await request.json();
|
||||
|
||||
if (!Array.isArray(slugs) || slugs.length === 0) {
|
||||
return new Response("Invalid slugs array", { status: 400 });
|
||||
}
|
||||
|
||||
// Create a unique hash based on slugs and language
|
||||
const dataString = `${slugs.sort().join(',')}:${lang}`;
|
||||
const hash = createHash('md5').update(dataString).digest('hex').slice(0, 8);
|
||||
|
||||
// Store the mapping in Redis
|
||||
await storeFeedMapping(hash, slugs, lang);
|
||||
|
||||
return new Response(JSON.stringify({ feedId: hash }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating feed:', error);
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
};
|
||||
65
src/pages/api/feed/[feedId].ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { getFeedMapping } from '../../../lib/db';
|
||||
|
||||
export const GET: APIRoute = async ({ params }) => {
|
||||
const { feedId } = params;
|
||||
|
||||
if (!feedId) {
|
||||
return new Response("Feed ID not provided", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Fetching feed mapping for ID: ${feedId}`);
|
||||
const mapping = await getFeedMapping(feedId);
|
||||
|
||||
if (!mapping) {
|
||||
console.error(`Feed not found for ID: ${feedId}`);
|
||||
return new Response(`Feed with ID "${feedId}" not found`, { status: 404 });
|
||||
}
|
||||
|
||||
// Validate mapping structure before returning
|
||||
if (!Array.isArray(mapping.slugs)) {
|
||||
console.error(`Invalid slugs in feed mapping for ID: ${feedId}`, mapping);
|
||||
return new Response(`Feed data corruption: invalid slugs format`, {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure consistent response format
|
||||
const response = {
|
||||
slugs: mapping.slugs.filter(slug => typeof slug === 'string' && slug.trim()),
|
||||
lang: typeof mapping.lang === 'string' ? mapping.lang : 'en'
|
||||
};
|
||||
|
||||
console.log(`Successfully retrieved feed mapping for ID: ${feedId}`, response);
|
||||
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error fetching feed ${feedId}:`, error);
|
||||
|
||||
const errorMessage = error instanceof Error
|
||||
? `Internal server error: ${error.message}`
|
||||
: "Internal server error";
|
||||
|
||||
// Add specific error codes for better client-side handling
|
||||
let statusCode = 500;
|
||||
let friendlyMessage = errorMessage;
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('ETIMEDOUT')) {
|
||||
friendlyMessage = `Database connection timeout. This is often temporary, especially after server idle periods. Please try again in a few seconds.`;
|
||||
} else if (error.message.includes('ECONNREFUSED')) {
|
||||
friendlyMessage = `Database connection refused. The service may be temporarily unavailable.`;
|
||||
} else if (error.message.includes('ENOTFOUND')) {
|
||||
friendlyMessage = `Database host not found. There may be a configuration issue.`;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(friendlyMessage, { status: statusCode });
|
||||
}
|
||||
};
|
||||
31
src/pages/api/search.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { APIRoute } from "astro";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
const { query } = await request.json();
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.comick.io/v1.0/search/?page=1&limit=5&q=${encodeURIComponent(query)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return new Response(JSON.stringify(data), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
return new Response(JSON.stringify({ error: 'Failed to search comics' }), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
17
src/pages/api/validate-slugs.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { APIRoute } from "astro";
|
||||
import { validateSlugs } from "../../lib/api";
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { slugs } = await request.json();
|
||||
const validSlugs = await validateSlugs(slugs);
|
||||
|
||||
return new Response(JSON.stringify({ validSlugs }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Validation error:', error);
|
||||
return new Response("Validation failed", { status: 500 });
|
||||
}
|
||||
};
|
||||
142
src/pages/credits.astro
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import { backgrounds } from '../config/backgrounds';
|
||||
|
||||
const title = "ComicK-RSS - Credits";
|
||||
const description = "Credits and attributions for ComicK-RSS";
|
||||
|
||||
const credits = [
|
||||
{
|
||||
title: "Background Art",
|
||||
items: backgrounds.map(bg => ({
|
||||
name: bg.title,
|
||||
artist: bg.artist,
|
||||
link: bg.source,
|
||||
description: "Beautiful artwork used as site background",
|
||||
socials: bg.socials
|
||||
}))
|
||||
},
|
||||
{
|
||||
title: "Technologies",
|
||||
items: [
|
||||
{
|
||||
name: "Astro",
|
||||
description: "The modern web framework powering this site",
|
||||
link: "https://astro.build"
|
||||
},
|
||||
{
|
||||
name: "Tailwind CSS",
|
||||
description: "Utility-first CSS framework for styling",
|
||||
link: "https://tailwindcss.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Data",
|
||||
items: [
|
||||
{
|
||||
name: "ComicK",
|
||||
description: "For providing the comic data and images through their API",
|
||||
link: "https://comick.io"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title={title} description={description}>
|
||||
<main class="mx-auto px-4 sm:px-6 lg:px-8 max-w-[800px] py-12">
|
||||
<div class="mb-8">
|
||||
<a
|
||||
href="/en/sfw/all"
|
||||
class="inline-flex items-center gap-2 text-primary hover:text-accent transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span>Back to Home</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl font-bold text-center mb-12 text-primary">
|
||||
Credits
|
||||
</h1>
|
||||
|
||||
<div class="space-y-12">
|
||||
{credits.map((section) => (
|
||||
<section class="bg-secondary/50 p-8 rounded-xl shadow-lg backdrop-blur-sm border border-hover/20">
|
||||
<h2 class="text-3xl font-bold mb-6 text-accent">{section.title}</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
{section.items.map((item) => (
|
||||
<div class="border-l-4 border-accent/30 pl-6 py-2 hover:border-accent transition-colors">
|
||||
<h3 class="font-medium text-xl mb-2">
|
||||
<a
|
||||
href={item.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-accent transition-colors inline-flex items-center gap-2"
|
||||
>
|
||||
{item.name}
|
||||
<svg
|
||||
class="w-4 h-4 opacity-50"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<polyline points="15 3 21 3 21 9" />
|
||||
<line x1="10" y1="14" x2="21" y2="3" />
|
||||
</svg>
|
||||
</a>
|
||||
</h3>
|
||||
{item.artist && (
|
||||
<p class="text-primary/70 mb-1">
|
||||
Artist: <span class="text-accent">{item.artist}</span>
|
||||
</p>
|
||||
)}
|
||||
{item.description && (
|
||||
<p class="text-primary/60 leading-relaxed">{item.description}</p>
|
||||
)}
|
||||
{item.socials && (
|
||||
<div class="flex gap-4 mt-2">
|
||||
{item.socials.map((social) => (
|
||||
<a
|
||||
href={social.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-accent/70 hover:text-accent transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
{social.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="mt-12 text-center text-primary/60">
|
||||
<p class="text-lg">
|
||||
Thank you to all the creators, artists, and contributors to other projects who made this project possible! ✨
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
44
src/pages/custom.astro
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import SlugInput from '../components/SlugInput.astro';
|
||||
|
||||
const title = "ComicK - Custom RSS Feed";
|
||||
const description = "Create a custom RSS feed for your favorite comics";
|
||||
---
|
||||
|
||||
<Layout title={title} description={description}>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Add back button -->
|
||||
<a
|
||||
href="/en/sfw/all"
|
||||
class="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-secondary hover:bg-hover rounded-md transition-colors font-medium"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back to Comics
|
||||
</a>
|
||||
|
||||
<main class="mx-auto px-3 sm:px-4 lg:px-8 max-w-[800px] py-4 sm:py-8">
|
||||
<h1 class="text-2xl sm:text-3xl lg:text-4xl font-bold text-center mb-4 sm:mb-8">Custom RSS Feed</h1>
|
||||
|
||||
<div class="bg-secondary/50 p-3 sm:p-6 rounded-lg shadow-lg">
|
||||
<SlugInput />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-8 text-center text-primary/60 text-sm sm:text-base">
|
||||
<p>Search for your favorite comics using the search box above, or add their slugs manually.</p>
|
||||
<p class="mt-2">Manual example: For <a href="https://comick.io/comic/01-one-piece" class="text-accent hover:underline">https://comick.io/comic/01-one-piece</a>, the slug is "01-one-piece"</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Layout>
|
||||
15
src/pages/index.astro
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
|
||||
export const prerender = false;
|
||||
---
|
||||
|
||||
<Layout title="ComicK-RSS">
|
||||
<main class="min-h-screen flex items-center justify-center">
|
||||
<h1 class="text-6xl font-bold text-center bg-gradient-to-r from-accent via-accent-light to-white bg-clip-text text-transparent">
|
||||
ComicK-RSS
|
||||
</h1>
|
||||
</main>
|
||||
|
||||
<meta http-equiv="refresh" content="0;url=/en/sfw/all">
|
||||
</Layout>
|
||||
92
src/pages/rss/[feedId].xml.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { Feed } from 'feed';
|
||||
import { getChaptersForSlugs, isValidLanguage } from "../../lib/api";
|
||||
import { getFeedMapping, warmChapterCache } from "../../lib/db";
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const feedId = params.feedId?.replace('.xml', '');
|
||||
|
||||
if (!feedId) {
|
||||
return new Response("Feed ID not provided", { status: 400 });
|
||||
}
|
||||
|
||||
const mapping = await getFeedMapping(feedId);
|
||||
|
||||
if (!mapping) {
|
||||
return new Response("Feed not found", { status: 404 });
|
||||
}
|
||||
|
||||
const { slugs, lang } = mapping;
|
||||
|
||||
if (!isValidLanguage(lang)) {
|
||||
return new Response("Invalid language", { status: 400 });
|
||||
}
|
||||
|
||||
await warmChapterCache(slugs, lang);
|
||||
|
||||
const chapters = await getChaptersForSlugs(slugs, lang);
|
||||
|
||||
if (chapters.length === 0) {
|
||||
return new Response("No chapters found for the provided comics", { status: 404 });
|
||||
}
|
||||
|
||||
const siteUrl = new URL(request.url).origin;
|
||||
const feedUrl = new URL(request.url).href;
|
||||
|
||||
const feed = new Feed({
|
||||
title: `ComicK - Custom Feed (${lang.toUpperCase()})`,
|
||||
description: `Custom RSS feed for selected comics: ${slugs.join(', ')}`,
|
||||
id: feedUrl,
|
||||
link: siteUrl,
|
||||
feedLinks: {
|
||||
rss2: feedUrl
|
||||
},
|
||||
language: lang,
|
||||
copyright: "All rights reserved",
|
||||
updated: new Date(),
|
||||
generator: 'ComicK RSS Generator',
|
||||
});
|
||||
|
||||
chapters.forEach((chapter) => {
|
||||
if (!chapter?.created_at) {
|
||||
console.error('Chapter missing created_at:', chapter);
|
||||
return;
|
||||
}
|
||||
|
||||
const chapterUrl = `https://comick.io/comic/${chapter.md_comics.slug}`;
|
||||
|
||||
const rawDate = chapter.created_at.endsWith('Z')
|
||||
? chapter.created_at
|
||||
: `${chapter.created_at}Z`;
|
||||
|
||||
const pubDate = isValidDate(rawDate) ? new Date(rawDate) : new Date();
|
||||
|
||||
feed.addItem({
|
||||
title: `${chapter.md_comics.title} - Chapter ${chapter.chap}`,
|
||||
id: `${chapter.md_comics.slug}-${chapter.chap}`,
|
||||
link: chapterUrl,
|
||||
description: `
|
||||
<div>
|
||||
${
|
||||
chapter.md_comics.md_covers?.[0]
|
||||
? `<img src="https://meo.comick.pictures/${chapter.md_comics.md_covers[0].b2key}"
|
||||
alt="Cover" style="max-width: 300px; margin-bottom: 1rem;" />`
|
||||
: ""
|
||||
}
|
||||
<p>New chapter available: Chapter ${chapter.chap}</p>
|
||||
</div>
|
||||
`,
|
||||
date: pubDate,
|
||||
});
|
||||
});
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function isValidDate(dateString: string): boolean {
|
||||
return !isNaN(Date.parse(dateString));
|
||||
}
|
||||
53
src/pages/rss/[lang]/[contentType]/[type].xml.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import rss from "@astrojs/rss";
|
||||
import {
|
||||
fetchComics,
|
||||
isValidLanguage,
|
||||
isValidContentType,
|
||||
isValidComicType,
|
||||
fetchGenres,
|
||||
getGenreNames,
|
||||
} from "../../../../lib/api";
|
||||
import type { APIRoute } from "astro";
|
||||
import type { ComicType } from "../../../../lib/types";
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const { lang = "en", contentType = "sfw", type = "all" } = params;
|
||||
|
||||
if (!isValidLanguage(lang) || !isValidContentType(contentType) || !isValidComicType(type)) {
|
||||
return new Response("Invalid parameters", { status: 400 });
|
||||
}
|
||||
|
||||
const [comics, genres] = await Promise.all([
|
||||
fetchComics(lang, contentType, type as ComicType),
|
||||
fetchGenres(),
|
||||
]);
|
||||
|
||||
const siteURL = new URL(request.url).origin;
|
||||
|
||||
return rss({
|
||||
title: `ComicK - ${lang.toUpperCase()} ${contentType.toUpperCase()} ${type.toUpperCase()}`,
|
||||
description: `A simple RSS feed for ComicK!`,
|
||||
site: "https://github.com/ld3z/manga-rss",
|
||||
items: comics.map((comic) => {
|
||||
const genreNames = getGenreNames(comic.md_comics.genres, genres);
|
||||
const genresHtml = genreNames.length > 0
|
||||
? `<p>Genres: ${genreNames.join(', ')}</p>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: `${comic.md_comics.title} - Chapter ${comic.chap}`,
|
||||
link: `https://comick.io/comic/${comic.md_comics.slug}`,
|
||||
pubDate: new Date(comic.updated_at),
|
||||
description: `Chapter ${comic.chap} of ${comic.md_comics.title} is now available on ComicK!
|
||||
${genresHtml}
|
||||
${
|
||||
comic.md_comics.md_covers[0]
|
||||
? `<img src="https://meo.comick.pictures/${comic.md_comics.md_covers[0].b2key}"
|
||||
alt="Cover" style="max-width: 300px;" />`
|
||||
: ""
|
||||
}
|
||||
`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
78
src/pages/rss/[lang]/[type].xml.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import rss from "@astrojs/rss";
|
||||
import {
|
||||
fetchComics,
|
||||
isValidLanguage,
|
||||
isValidContentType,
|
||||
fetchGenres,
|
||||
getGenreNames,
|
||||
} from "../../../lib/api";
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const GET: APIRoute = async ({ params, request }) => {
|
||||
const { lang = "en", type = "sfw" } = params;
|
||||
|
||||
if (!isValidLanguage(lang) || !isValidContentType(type)) {
|
||||
return new Response("Invalid language or content type", { status: 400 });
|
||||
}
|
||||
|
||||
const [comics, genres] = await Promise.all([
|
||||
fetchComics(lang, type),
|
||||
fetchGenres(),
|
||||
]);
|
||||
|
||||
const siteURL = new URL(request.url).origin;
|
||||
|
||||
return rss({
|
||||
title: `ComicK - ${lang.toUpperCase()} ${type.toUpperCase()}`,
|
||||
description: `A simple RSS feed for ComicK!`,
|
||||
site: "https://github.com/ld3z/manga-rss",
|
||||
items: comics.map((comic) => {
|
||||
const genreNames = getGenreNames(comic.md_comics.genres, genres);
|
||||
const genresHtml = genreNames.length > 0
|
||||
? `<p>Genres: ${genreNames.join(', ')}</p>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
title: `${comic.md_comics.title} - Chapter ${comic.chap}`,
|
||||
link: `https://comick.io/comic/${comic.md_comics.slug}`,
|
||||
pubDate: new Date(comic.updated_at),
|
||||
description: `Chapter ${comic.chap} of ${comic.md_comics.title} is now available on ComicK!
|
||||
${genresHtml}
|
||||
${
|
||||
comic.md_comics.md_covers[0]
|
||||
? `<img src="https://meo.comick.pictures/${comic.md_comics.md_covers[0].b2key}"
|
||||
alt="Cover" style="max-width: 300px;" />`
|
||||
: ""
|
||||
}
|
||||
`,
|
||||
};
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: "en", type: "sfw" } },
|
||||
{ params: { lang: "en", type: "nsfw" } },
|
||||
{ params: { lang: "fr", type: "sfw" } },
|
||||
{ params: { lang: "fr", type: "nsfw" } },
|
||||
{ params: { lang: "es", type: "nsfw" } },
|
||||
{ params: { lang: "es", type: "sfw" } },
|
||||
{ params: { lang: "it", type: "sfw" } },
|
||||
{ params: { lang: "it", type: "nsfw" } },
|
||||
{ params: { lang: "pl", type: "sfw" } },
|
||||
{ params: { lang: "pl", type: "nsfw" } },
|
||||
{ params: { lang: "tr", type: "sfw" } },
|
||||
{ params: { lang: "tr", type: "nsfw" } },
|
||||
{ params: { lang: "ja", type: "sfw" } },
|
||||
{ params: { lang: "ja", type: "nsfw" } },
|
||||
{ params: { lang: "zh", type: "sfw" } },
|
||||
{ params: { lang: "zh", type: "nsfw" } },
|
||||
{ params: { lang: "sv", type: "sfw" } },
|
||||
{ params: { lang: "sv", type: "nsfw" } },
|
||||
{ params: { lang: "ar", type: "sfw" } },
|
||||
{ params: { lang: "ar", type: "nsfw" } },
|
||||
{ params: { lang: "de", type: "sfw" } },
|
||||
{ params: { lang: "de", type: "nsfw" } },
|
||||
];
|
||||
}
|
||||
58
src/pages/rss/custom/[slugs].xml.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Feed } from 'feed';
|
||||
import { getChaptersForSlugs, isValidLanguage } from "../../../lib/api";
|
||||
import type { APIRoute } from "astro";
|
||||
|
||||
export const GET: APIRoute = async ({ params, url }) => {
|
||||
const slugs = params.slugs?.split(',') || [];
|
||||
const lang = url.searchParams.get('lang') || 'en';
|
||||
|
||||
if (slugs.length === 0) {
|
||||
return new Response("No slugs provided", { status: 400 });
|
||||
}
|
||||
|
||||
if (!isValidLanguage(lang)) {
|
||||
return new Response("Invalid language", { status: 400 });
|
||||
}
|
||||
|
||||
console.log('Processing RSS request for slugs:', slugs, 'in language:', lang);
|
||||
const chapters = await getChaptersForSlugs(slugs, lang);
|
||||
|
||||
if (chapters.length === 0) {
|
||||
console.log('No chapters found for any of the provided slugs');
|
||||
return new Response("No chapters found for the provided comics", { status: 404 });
|
||||
}
|
||||
|
||||
const siteUrl = new URL(url).origin;
|
||||
const feedUrl = new URL(url).href;
|
||||
|
||||
const feed = new Feed({
|
||||
title: `ComicK - Custom Feed (${lang.toUpperCase()})`,
|
||||
description: `Custom RSS feed for selected comics: ${slugs.join(', ')}`,
|
||||
id: feedUrl,
|
||||
link: siteUrl,
|
||||
feedLinks: {
|
||||
rss2: feedUrl
|
||||
},
|
||||
language: lang,
|
||||
copyright: "All rights reserved",
|
||||
updated: new Date(),
|
||||
generator: 'ComicK RSS Generator',
|
||||
});
|
||||
|
||||
chapters.forEach((chapter) => {
|
||||
const chapterUrl = `https://comick.io/comic/${chapter.md_comics.slug}`;
|
||||
feed.addItem({
|
||||
title: `${chapter.md_comics.title} - Chapter ${chapter.chap}`,
|
||||
id: `${chapter.md_comics.slug}-${chapter.chap}`,
|
||||
link: chapterUrl,
|
||||
description: `New chapter available: Chapter ${chapter.chap}`,
|
||||
date: new Date(chapter.updated_at),
|
||||
});
|
||||
});
|
||||
|
||||
return new Response(feed.rss2(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
});
|
||||
};
|
||||
32
tailwind.config.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: 'rgb(var(--accent) / <alpha-value>)',
|
||||
'accent-light': 'rgb(var(--accent-light) / <alpha-value>)',
|
||||
'accent-dark': 'rgb(var(--accent-dark) / <alpha-value>)',
|
||||
},
|
||||
backgroundColor: {
|
||||
primary: 'var(--bg-primary)',
|
||||
secondary: 'var(--bg-secondary)',
|
||||
hover: 'var(--bg-hover)',
|
||||
},
|
||||
textColor: {
|
||||
primary: 'var(--text-color)',
|
||||
},
|
||||
spacing: {
|
||||
'safe-top': 'env(safe-area-inset-top)',
|
||||
'safe-bottom': 'env(safe-area-inset-bottom)',
|
||||
'safe-left': 'env(safe-area-inset-left)',
|
||||
'safe-right': 'env(safe-area-inset-right)',
|
||||
},
|
||||
screens: {
|
||||
'xs': '475px',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
3
tsconfig.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/base"
|
||||
}
|
||||