This commit is contained in:
ld3zian 2025-03-28 20:11:35 -07:00
parent cb7e885eee
commit 2f342807c5
48 changed files with 10171 additions and 1 deletions

27
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View file

@ -1 +1 @@
# new-manga
# oops

20
astro.config.mjs Normal file
View 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)
}
}
});

6438
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"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.0",
"@astrojs/vercel": "^8.1.0",
"@floating-ui/dom": "^1.6.13",
"@types/node": "^22.13.9",
"@vercel/analytics": "^1.5.0",
"@vercel/kv": "^3.0.0",
"astro": "^5.4.1",
"feed": "^4.2.2",
"ioredis": "^5.5.0",
"tailwindcss": "^3.4.1"
}
}

BIN
public/citali.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

1
public/favicon.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

BIN
public/meata.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
public/numi.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
public/original.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

BIN
public/patchouli.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
public/shylily.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

BIN
public/sleepy.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View file

@ -0,0 +1,111 @@
---
import type { Announcement } from '../lib/types';
interface Props {
announcement: Announcement;
}
const { announcement } = Astro.props;
const bgColorClass = {
info: 'bg-[rgb(var(--accent))] text-[rgb(var(--accent-text))]',
warning: 'bg-yellow-500 text-white',
success: 'bg-emerald-500 text-white'
}[announcement.type];
// Create a simple hash of the announcement content
const announcementHash = btoa(JSON.stringify({
message: announcement.message,
type: announcement.type,
link: announcement.link
}));
---
<div class={`announcement-banner hidden w-full ${bgColorClass} px-6 py-4 shadow-lg`}>
<div class="container mx-auto flex items-center justify-between">
<div class="flex items-center gap-3 text-lg">
{announcement.type === 'info' && (
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
{announcement.type === 'warning' && (
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
)}
{announcement.type === 'success' && (
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
<span class="font-medium">{announcement.message}</span>
{announcement.link && (
<a
href={announcement.link.url}
class="underline hover:opacity-80 transition-opacity font-semibold ml-2"
>
{announcement.link.text} →
</a>
)}
</div>
{announcement.dismissible && (
<button
class="dismiss-announcement p-2 hover:bg-black/10 rounded-full transition-colors"
aria-label="Dismiss announcement"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
</div>
<script define:vars={{ dismissible: announcement.dismissible, announcementHash }}>
const banner = document.querySelector('.announcement-banner');
const dismissBtn = document.querySelector('.dismiss-announcement');
const storageKey = `announcement-dismissed-${announcementHash}`;
// Check if announcement should be shown
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
document.addEventListener('DOMContentLoaded', checkVisibility);
dismissBtn?.addEventListener('click', () => {
if (dismissible) {
banner?.classList.add('hidden');
localStorage.setItem(storageKey, 'true');
}
});
</script>
<style>
.announcement-banner {
animation: slideDown 0.5s ease-out;
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>

61
src/components/Card.astro Normal file
View 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>&rarr;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1,586 @@
---
import { getFeedMapping } from '../lib/db';
interface Props {
currentSlugs?: string[];
}
const { currentSlugs = [] } = Astro.props;
import { availableLanguages, languageMap } from '../lib/api';
const languages = [
{ value: 'en', label: 'English' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'zh', label: 'Chinese' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
{ value: 'de', label: 'German' },
{ value: 'tr', label: 'Turkish' },
{ value: 'pl', label: 'Polish' },
];
---
<div class="space-y-4 sm:space-y-6">
<div class="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"/>
</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"/>
</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"></path>
</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>

View 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>

View 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>

145
src/config/backgrounds.ts Normal file
View file

@ -0,0 +1,145 @@
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: "Twitter",
link: "https://twitter.com/ayanagi0319",
},
],
},
{
url: "/original.webp",
title: "読書",
artist: "kamepan44231 - かめぱすた",
source: "https://danbooru.donmai.us/posts/8743208",
socials: [
{
name: "Twitter",
link: "https://x.com/kamepan44231",
},
],
},
{
url: "/numi.webp",
title: "Numi",
artist: "TenIllustrator",
source: "https://danbooru.donmai.us/posts/8074444",
socials: [
{
name: "Twitter",
link: "https://x.com/Ten_0123_/",
},
],
},
{
url: "/shylily.webp",
title: "Shylily",
artist: "greatodoggo",
source: "https://www.pixiv.net/en/artworks/124222053",
socials: [
{
name: "Twitter",
link: "https://twitter.com/greatodoggo",
},
],
},
{
url: "/sleepy.webp",
title: "???",
artist: "soaphisoap",
source: "https://safebooru.org/index.php?page=post&s=view&id=5418793",
socials: [
{
name: "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: "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: "Twitter",
link: "https://x.com/",
},
],
},
// 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
View file

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

290
src/layouts/Layout.astro Normal file
View 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>

392
src/lib/api.ts Normal file
View file

@ -0,0 +1,392 @@
import type { Comic, Genre, ComicType } from './types';
import { getRedisClient } from './db';
const languageMap = {
"en": "English",
"fr": "French",
"es": "Spanish",
"it": "Italian",
"pl": "Polish",
"tr": "Turkish",
"ja": "Japanese",
"zh": "Chinese",
"sv": "Swedish",
"ar": "Arabic",
"de": "German",
"ko": "Korean"
} 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;
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,
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
} catch (error) {
if (retries > 0) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
return fetchWithRetry(url, options, retries - 1);
}
throw error;
}
}
export async function fetchGenres(): Promise<Genre[]> {
if (genreCache.length > 0) return genreCache;
try {
const response = await fetchWithRetry('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 fetchWithRetry(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 ChapterDetail {
id: string;
chap: string;
title: string;
updated_at: string;
md_comics: {
title: string;
slug: string;
md_covers?: {
b2key: string;
}[];
};
}
interface ChapterParams {
limit?: number;
lang?: string;
}
export async function getComicBySlug(slug: string): Promise<ComicDetail | null> {
try {
const response = await fetchWithRetry(`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 fetchWithRetry(
`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 fetchWithRetry(
`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 CACHE_TTL = 60 * 60;
const uniqueSlugs = [...new Set(slugs)];
const pipeline = redis.pipeline();
// Add cache key validation
uniqueSlugs.forEach(slug => {
if (!slug.match(/^[a-z0-9-]+$/)) {
console.error(`Invalid slug format: ${slug}`);
}
const cacheKey = `chapters:${slug}:${lang}`;
pipeline.get(cacheKey);
});
const [cacheResults, slugsToFetch] = await Promise.all([
pipeline.exec(),
validateSlugs(uniqueSlugs) // Add this new function
]);
const cachedChapters: ChapterDetail[] = [];
cacheResults.forEach(([err, result], index) => {
if (!err && result) {
try {
const parsed = JSON.parse(result);
if (Array.isArray(parsed)) {
cachedChapters.push(...parsed);
}
} catch (e) {
console.error(`Cache parse error for ${uniqueSlugs[index]}:`, e);
}
}
});
// Add progress tracking
const cachedSlugs = new Set(cachedChapters.map(c => c.md_comics.slug));
console.log(`Cache hits: ${cachedChapters.length} chapters from ${cachedSlugs.size} comics`);
console.log(`Fetching ${slugsToFetch.length} comics from API`);
const fetchPromises = slugsToFetch.map(async (slug) => {
const timer = Date.now();
try {
const comicDetail = await getComicBySlug(slug);
if (!comicDetail?.comic?.hid) {
console.error(`❌ No HID found for ${slug}`);
return [];
}
const chapters = await getChaptersByHid(comicDetail.comic.hid, slug, {
limit: 5,
lang: lang
});
console.log(`✅ ${slug} (${comicDetail.comic.hid}) returned ${chapters.length} chapters in ${Date.now() - timer}ms`);
return chapters;
} catch (error) {
console.error(`🚨 Error processing ${slug}:`, error);
return [];
}
});
const liveChapters = (await Promise.all(fetchPromises)).flat();
// Cache new results in batch
const cachePipeline = redis.pipeline();
const chaptersBySlug: Record<string, ChapterDetail[]> = {};
liveChapters.forEach(chapter => {
const slug = chapter.md_comics.slug;
if (!chaptersBySlug[slug]) {
chaptersBySlug[slug] = [];
}
chaptersBySlug[slug].push(chapter);
});
Object.entries(chaptersBySlug).forEach(([slug, chaps]) => {
const cacheKey = `chapters:${slug}:${lang}`;
cachePipeline.set(
cacheKey,
JSON.stringify(chaps),
'EX',
CACHE_TTL
);
});
await cachePipeline.exec();
// Combine and sort all chapters
const allChapters = [...cachedChapters, ...liveChapters];
const deduplicatedChapters = deduplicateChapters(allChapters);
return deduplicatedChapters.sort((a, b) =>
parseFloat(b.chap) - parseFloat(a.chap) ||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
// Add export to the validation function
export async function validateSlugs(slugs: string[]): Promise<string[]> {
const validSlugs: string[] = [];
const invalidSlugs: string[] = [];
await Promise.all(slugs.map(async (slug) => {
try {
const response = await fetchWithRetry(`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.error(`Invalid/Unavailable slugs: ${invalidSlugs.join(', ')}`);
}
return validSlugs;
}

458
src/lib/db.ts Normal file
View file

@ -0,0 +1,458 @@
import Redis from 'ioredis';
import type { FeedMapping } from './types';
import { getChaptersForSlugs } from './api';
// Extended Error interface for network errors
interface RedisNetworkError extends Error {
code?: string;
}
// Primary Redis configuration
const PRIMARY_REDIS_URL = import.meta.env.PRIMARY_REDIS_URL || process.env.PRIMARY_REDIS_URL || process.env.REDIS_URL;
// Multiple replicas support
const REPLICA_URLS = [
import.meta.env.REPLICA_REDIS_URL_1 || process.env.REPLICA_REDIS_URL_1 || import.meta.env.REPLICA_REDIS_URL || process.env.REPLICA_REDIS_URL,
import.meta.env.REPLICA_REDIS_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[];
// Create a mapping of URLs to friendly names for logging
const redisInstanceNames = new Map<string, string>();
// Initialize friendly names for each Redis instance
function initializeRedisNames() {
redisInstanceNames.set(PRIMARY_REDIS_URL!, 'Primary');
REPLICA_URLS.forEach((url, index) => {
redisInstanceNames.set(url, `Replica ${index + 1}`);
});
}
// Call this immediately
initializeRedisNames();
// Helper function to get a safe name for a Redis instance
function getRedisName(url: string): string {
return redisInstanceNames.get(url) || 'Unknown Redis instance';
}
// Client instances
let primaryClient: Redis | null = null;
let replicaClients: Map<string, Redis> = new Map();
const DEFAULT_CACHE_TTL = 60 * 60 * 24 * 30; // 30 days
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 100;
const IS_VERCEL = process.env.VERCEL || false;
const VERCEL_CONNECTION_TIMEOUT = 15000; // 15 seconds for Vercel
const LOCAL_CONNECTION_TIMEOUT = 5000; // 5 seconds for local
// Improved connection handling with exponential backoff
async function createRedisClient(url: string): Promise<Redis> {
if (!url) throw new Error('Redis URL is not set');
const redisName = getRedisName(url);
// Parse URL to extract authentication info
const options = {
retryStrategy: (times) => {
if (times >= MAX_RETRIES) return null;
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, times), 5000);
console.log(`${redisName} retry attempt ${times}, next try in ${delay}ms`);
return delay + Math.random() * 200; // Add jitter
},
maxRetriesPerRequest: null, // Allow retries for queued commands
enableOfflineQueue: true, // Enable command queuing
enableReadyCheck: true,
connectTimeout: 15000, // Increase connection timeout to 15 seconds for Vercel's serverless environment
disconnectTimeout: 10000, // Increase disconnect timeout to 10 seconds
keepAlive: 10000, // Send keepalive every 10 seconds
commandTimeout: 10000, // Command timeout
retryMaxDelay: 5000, // Maximum delay between retries
reconnectOnError: (err) => {
const targetErrors = ['READONLY', 'ETIMEDOUT', 'ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'];
return targetErrors.some(e => err.message.includes(e));
}
};
return new Redis(url, options);
}
// Get the best available Redis client
export async function getRedisClient(): Promise<Redis> {
// Special handling for Vercel environment
if (IS_VERCEL) {
console.log('Running in Vercel environment - using optimized Redis connection strategy');
}
// Try primary first
if (primaryClient?.status === 'ready') {
try {
// Add timeout to ping
const pingPromise = primaryClient.ping();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Ping timeout')), 3000)
);
await Promise.race([pingPromise, timeoutPromise]);
return primaryClient;
} catch (error) {
console.warn('Primary Redis connection failed, will try replicas:', error);
// Fall through to try replicas
}
}
// Try replicas in sequence
for (const [url, client] of replicaClients.entries()) {
if (client.status === 'ready') {
try {
// Add timeout to ping
const pingPromise = client.ping();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Ping timeout')), 3000)
);
await Promise.race([pingPromise, timeoutPromise]);
console.log(`Using ${getRedisName(url)}`);
return client;
} catch (error) {
console.warn(`${getRedisName(url)} connection failed:`, error);
// Continue to next replica
}
}
}
// Reconnect to primary
try {
console.log('Initializing new primary Redis connection...');
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
primaryClient
.on('ready', () => console.log('Primary Redis connection ready'))
.on('error', (err: RedisNetworkError) => {
// Log more details for connection errors
if (err.code === 'ETIMEDOUT') {
console.error('Primary Redis connection timeout. This is common in serverless environments like Vercel. Check your network connectivity and Redis server availability.');
} else if (err.code === 'ECONNREFUSED') {
console.error('Primary Redis connection refused. Ensure the Redis server is running and the URL is correct.');
} else if (err.code === 'ENOTFOUND') {
console.error('Primary Redis host not found. Check your PRIMARY_REDIS_URL environment variable.');
} else {
console.error('Primary Redis error:', err);
}
})
.on('reconnecting', () => console.log('Primary Redis reconnecting'))
.on('end', () => console.log('Primary Redis connection closed'));
return primaryClient;
} catch (error) {
console.error('Failed to connect to primary Redis:', error);
// Special handling for Vercel ETIMEDOUT errors
if (IS_VERCEL && error instanceof Error && error.message.includes('ETIMEDOUT')) {
console.log('Encountered ETIMEDOUT in Vercel environment. Retrying with longer timeout...');
try {
// Try one more time with increased timeout
const tempClient = new Redis(PRIMARY_REDIS_URL!, {
connectTimeout: 20000, // Increased timeout for last-ditch effort
retryStrategy: () => null // No retries for this attempt
});
// Wait for ready or error
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
tempClient.disconnect();
reject(new Error('Final connection attempt timed out'));
}, 20000);
tempClient.once('ready', () => {
clearTimeout(timeout);
resolve(true);
});
tempClient.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
});
// If we get here, connection was successful
primaryClient = tempClient;
return tempClient;
} catch (finalError) {
console.error('Final Redis connection attempt failed:', finalError);
}
}
// Try connecting to any replica that might be available
for (const url of REPLICA_URLS) {
try {
const redisName = getRedisName(url);
console.log(`Trying to connect to ${redisName}...`);
const client = await createRedisClient(url);
client
.on('ready', () => console.log(`${redisName} connection ready`))
.on('error', (err: RedisNetworkError) => {
// Log more details for connection errors
if (err.code === 'ETIMEDOUT') {
console.error(`${redisName} connection timeout. This is common in serverless environments like Vercel. Check your network connectivity and Redis server availability.`);
} else if (err.code === 'ECONNREFUSED') {
console.error(`${redisName} connection refused. Ensure the Redis server is running and the URL is correct.`);
} else if (err.code === 'ENOTFOUND') {
console.error(`${redisName} host not found. Check your Redis URL environment variables.`);
} else {
console.error(`${redisName} error:`, err);
}
})
.on('reconnecting', () => console.log(`${redisName} reconnecting`))
.on('end', () => console.log(`${redisName} connection closed`));
replicaClients.set(url, client);
return client;
} catch (replicaError) {
console.error(`Failed to connect to ${getRedisName(url)}:`, replicaError);
// Continue to next replica
}
}
throw new Error('All Redis connections failed');
}
}
// Write to primary and all replicas for redundancy
export async function writeToAllRedis(operation: (redis: Redis) => Promise<any>): Promise<void> {
const errors = [];
let successCount = 0;
// Try primary
try {
if (!primaryClient || primaryClient.status !== 'ready') {
primaryClient = await createRedisClient(PRIMARY_REDIS_URL!);
}
await operation(primaryClient);
successCount++;
} catch (error) {
console.error('Failed to write to Primary Redis:', error);
errors.push(error);
}
// Try all replicas
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);
}
await operation(client);
successCount++;
} catch (error) {
console.error(`Failed to write to ${getRedisName(url)}:`, error);
errors.push(error);
}
}
// If all writes failed, throw an error
if (successCount === 0) {
throw new Error('Failed to write to any Redis instance');
}
// Log warning if some writes failed
if (errors.length > 0) {
console.warn(`${errors.length} Redis writes failed out of ${errors.length + successCount} attempts`);
}
}
// Initialize all connections on startup
(async () => {
try {
// Initialize primary
if (PRIMARY_REDIS_URL) {
primaryClient = await createRedisClient(PRIMARY_REDIS_URL);
await primaryClient.ping();
console.log('Primary Redis pre-connected successfully');
}
// Initialize all replicas
for (const url of REPLICA_URLS) {
try {
const client = await createRedisClient(url);
await client.ping();
replicaClients.set(url, client);
console.log(`${getRedisName(url)} pre-connected successfully`);
} catch (error) {
console.error(`Failed to pre-connect to ${getRedisName(url)}:`, error);
}
}
console.log(`Redis setup complete: 1 primary and ${replicaClients.size} replicas connected`);
} catch (error) {
console.error('Redis pre-connection failed:', error);
}
})();
// Update existing functions to use the new methods
export async function storeFeedMappings(mappings: Array<{ feedId: string, slugs: string[], lang: string }>): Promise<void> {
await writeToAllRedis(async (redis) => {
const pipeline = redis.pipeline();
mappings.forEach(({ feedId, slugs, lang }) => {
const mapping = {
slugs,
lang,
created_at: new Date().toISOString()
};
pipeline.set(feedId, JSON.stringify(mapping), 'EX', DEFAULT_CACHE_TTL);
});
await pipeline.exec();
});
}
export async function warmChapterCache(slugs: string[], lang: string): Promise<void> {
const redis = await getRedisClient();
const needsCache = await checkCacheStatus(slugs, lang);
if (needsCache.length > 0) {
console.log(`Pre-warming cache for ${needsCache.length} comics`);
await writeToAllRedis(async (redis) => {
const pipeline = redis.pipeline();
await Promise.all(needsCache.map(async slug => {
try {
const chapters = await getChaptersForSlugs([slug], lang);
const cacheKey = `chapters:${slug}:${lang}`;
pipeline.set(cacheKey, JSON.stringify(chapters), 'EX', 60 * 60);
} catch (error) {
console.error(`Failed to warm cache for ${slug}:`, error);
}
}));
await pipeline.exec();
});
}
}
export async function storeFeedMapping(feedId: string, slugs: string[], lang: string): Promise<void> {
const mapping = {
slugs,
lang,
created_at: new Date().toISOString()
};
try {
await writeToAllRedis(async (redis) => {
await redis.set(
feedId,
JSON.stringify(mapping),
'EX',
DEFAULT_CACHE_TTL
);
});
} catch (error) {
console.error('Error storing feed mapping:', error);
throw new Error('Failed to store feed mapping');
}
}
export async function checkCacheStatus(slugs: string[], lang: string): Promise<string[]> {
const redis = await getRedisClient();
const pipeline = redis.pipeline();
slugs.forEach(slug => {
const cacheKey = `chapters:${slug}:${lang}`;
pipeline.exists(cacheKey);
});
const results = await pipeline.exec();
return slugs.filter((_, index) => results![index][1] === 0);
}
export async function getFeedMapping(feedId: string): Promise<FeedMapping | null> {
let foundMapping: string | null = null;
let sourceRedisName: string = "";
// First try primary
try {
// Check primary database if available
if (primaryClient?.status === 'ready') {
try {
await primaryClient.ping();
const mapping = await primaryClient.get(feedId);
if (mapping) {
await primaryClient.expire(feedId, DEFAULT_CACHE_TTL);
foundMapping = mapping;
sourceRedisName = "Primary";
}
} catch (error) {
console.warn('Primary Redis connection failed while retrieving mapping:', error);
// Fall through to try replicas
}
}
// If not found in primary, check replicas
if (!foundMapping) {
for (const [url, client] of replicaClients.entries()) {
if (client.status === 'ready') {
try {
await client.ping();
console.log(`Trying to get key from ${getRedisName(url)}`);
const mapping = await client.get(feedId);
if (mapping) {
await client.expire(feedId, DEFAULT_CACHE_TTL);
foundMapping = mapping;
sourceRedisName = getRedisName(url);
break; // Exit the loop once we find the mapping
}
} catch (error) {
console.warn(`${getRedisName(url)} connection failed while retrieving mapping:`, error);
// Continue to next replica
}
}
}
}
// If we found the mapping in any database, re-synchronize to all other databases
if (foundMapping) {
console.log(`Found mapping in ${sourceRedisName}. Re-synchronizing to all Redis instances...`);
// Parse data for returning
const data = JSON.parse(foundMapping);
const result = {
slugs: data.slugs,
lang: data.lang
};
// Fire-and-forget re-sync to all databases
(async () => {
try {
await writeToAllRedis(async (redis) => {
await redis.set(
feedId,
foundMapping!,
'EX',
DEFAULT_CACHE_TTL
);
});
console.log(`Successfully re-synchronized mapping for ${feedId} to all Redis instances`);
} catch (syncError) {
console.error('Error during Redis re-synchronization:', syncError);
}
})();
return result;
}
// If we get here, the key wasn't found in any database
return null;
} catch (error) {
console.error('Error retrieving feed mapping:', error);
throw new Error('Failed to retrieve feed mapping');
}
}
// No need for manual cleanup as we're using TTL (expire)

53
src/lib/types.ts Normal file
View 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[];
};
}

View 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>

View 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>

View 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'
}
});
}
};

View file

@ -0,0 +1,36 @@
import type { APIRoute } from "astro";
import { createHash } from 'crypto';
import { storeFeedMapping } from '../../lib/db';
// In-memory storage (replace with database in production)
const feedMappings = new Map<string, { slugs: string[], lang: string }>();
export const POST: APIRoute = async ({ request }) => {
try {
const { slugs, lang } = await request.json();
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 Vercel KV
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 });
}
};
// Export the mappings for use in other files
export { feedMappings };

View 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
View 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'
}
});
}
};

View 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
View 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
View 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
View 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>

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

View 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;" />`
: ""
}
`,
};
}),
});
};

View 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" } },
];
}

View 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
View 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
View file

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}