mirror of
https://github.com/ld3z/new-manga.git
synced 2026-11-09 19:44:02 -07:00
init
This commit is contained in:
parent
cb7e885eee
commit
2f342807c5
48 changed files with 10171 additions and 1 deletions
111
src/components/AnnouncementBanner.astro
Normal file
111
src/components/AnnouncementBanner.astro
Normal 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
61
src/components/Card.astro
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
body: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { href, title, body } = Astro.props;
|
||||
---
|
||||
|
||||
<li class="link-card">
|
||||
<a href={href}>
|
||||
<h2>
|
||||
{title}
|
||||
<span>→</span>
|
||||
</h2>
|
||||
<p>
|
||||
{body}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
<style>
|
||||
.link-card {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 1px;
|
||||
background-color: #23262d;
|
||||
background-image: none;
|
||||
background-size: 400%;
|
||||
border-radius: 7px;
|
||||
background-position: 100%;
|
||||
transition: background-position 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.link-card > a {
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
line-height: 1.4;
|
||||
padding: calc(1.5rem - 1px);
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
background-color: #23262d;
|
||||
opacity: 0.8;
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
transition: color 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
p {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.link-card:is(:hover, :focus-within) {
|
||||
background-position: 0;
|
||||
background-image: var(--accent-gradient);
|
||||
}
|
||||
.link-card:is(:hover, :focus-within) h2 {
|
||||
color: rgb(var(--accent-light));
|
||||
}
|
||||
</style>
|
||||
267
src/components/ComicCard.astro
Normal file
267
src/components/ComicCard.astro
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
---
|
||||
import type { Comic, Genre } from '../lib/types';
|
||||
import { Image } from 'astro:assets';
|
||||
import { getGenreNames } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
comic: Comic;
|
||||
genres: Genre[];
|
||||
}
|
||||
|
||||
const { comic, genres } = Astro.props;
|
||||
const genreNames = getGenreNames(comic.md_comics.genres, genres);
|
||||
const isAvif = comic.md_comics.md_covers[0]?.b2key.endsWith('.avif');
|
||||
const coverUrl = comic.md_comics.md_covers[0]
|
||||
? `https://meo.comick.pictures/${comic.md_comics.md_covers[0].b2key}`
|
||||
: '/placeholder.png';
|
||||
const isNSFW = comic.md_comics.content_rating === 'erotica';
|
||||
---
|
||||
|
||||
<div
|
||||
class="bg-secondary rounded-xl overflow-hidden shadow-lg h-full flex flex-col max-w-[280px] mx-auto border border-hover/30 hover:border-accent/50 transition-all duration-300"
|
||||
data-genres={JSON.stringify(comic.md_comics.genres)}
|
||||
role="article"
|
||||
aria-labelledby={`comic-title-${comic.id}`}
|
||||
>
|
||||
<a href={`https://comick.io/comic/${comic.md_comics.slug}`} class="flex flex-col h-full group relative">
|
||||
<div class="relative overflow-hidden aspect-[2/3] rounded-t-xl">
|
||||
{!isAvif ? (
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={comic.md_comics.title}
|
||||
inferSize={true}
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={comic.md_comics.title}
|
||||
width="280"
|
||||
height="420"
|
||||
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
)}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
{isNSFW && (
|
||||
<div class="absolute top-2 right-2 bg-red-500/90 text-white px-2 py-1 rounded text-xs font-bold shadow-lg backdrop-blur-[2px]">
|
||||
NSFW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="p-3 sm:p-4 md:p-5 flex-grow flex flex-col bg-secondary relative rounded-b-xl">
|
||||
<div class="absolute -top-6 sm:-top-8 left-2 right-2 sm:left-3 sm:right-3 bg-secondary/95 backdrop-blur-[12px] p-2 sm:p-3 rounded-lg shadow-lg border border-hover/30">
|
||||
<h3 id={`comic-title-${comic.id}`}
|
||||
class="text-base sm:text-lg font-bold line-clamp-1 text-center text-primary drop-shadow-[0_1px_1px_rgba(0,0,0,0.5)]"
|
||||
aria-label={comic.md_comics.title}
|
||||
>
|
||||
{comic.md_comics.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 sm:mt-6">
|
||||
<p class="inline-block bg-accent/20 text-accent px-3 sm:px-4 py-1 rounded-full text-xs sm:text-sm font-semibold">
|
||||
Chapter {comic.chap}
|
||||
</p>
|
||||
|
||||
{genreNames.length > 0 && (
|
||||
<div class="relative mt-3" aria-label="Comic genres">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{genreNames.slice(0, 3).map(genre => (
|
||||
<span class="bg-hover text-primary/80 px-3 py-1 rounded-full text-xs font-medium truncate">
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
{genreNames.length > 3 && (
|
||||
<button
|
||||
type="button"
|
||||
class="expand-tags-btn bg-hover text-primary/80 px-3 py-1 rounded-full text-xs font-medium hover:bg-hover/80 hover:scale-105 cursor-pointer transition-all duration-200 ease-in-out"
|
||||
aria-expanded="false"
|
||||
data-comic-id={comic.id}
|
||||
>
|
||||
+{genreNames.length - 3} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genreNames.length > 3 && (
|
||||
<div
|
||||
id={`tags-${comic.id}`}
|
||||
class="expanded-tags bg-secondary/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border border-hover/30 hidden"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{genreNames.slice(3).map(genre => (
|
||||
<span class="bg-hover text-primary/80 px-3 py-1 rounded-full text-xs font-medium truncate">
|
||||
{genre}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-3 sm:pt-4 flex items-center justify-between text-xs sm:text-sm text-primary/60 border-t border-hover/30">
|
||||
<div class="flex flex-col gap-1">
|
||||
<time datetime={comic.updated_at}>
|
||||
{new Date(comic.updated_at).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</time>
|
||||
{comic.publish_at && (
|
||||
<div class="countdown-timer text-xs" data-publish-at={comic.publish_at}>
|
||||
Publishing in: <span class="font-medium text-accent">calculating...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span class="group-hover:text-accent transition-colors duration-300">Read Now →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { computePosition, flip, shift, offset } from '@floating-ui/dom';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const buttons = document.querySelectorAll('.expand-tags-btn');
|
||||
|
||||
buttons.forEach(button => {
|
||||
const comicId = button.getAttribute('data-comic-id');
|
||||
const tooltip = document.getElementById(`tags-${comicId}`);
|
||||
|
||||
if (!tooltip) return;
|
||||
|
||||
function updatePosition() {
|
||||
computePosition(button, tooltip, {
|
||||
placement: 'bottom',
|
||||
middleware: [
|
||||
offset(8),
|
||||
flip(),
|
||||
shift({ padding: 8 })
|
||||
],
|
||||
}).then(({x, y}) => {
|
||||
Object.assign(tooltip.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function showTooltip() {
|
||||
tooltip.classList.remove('hidden');
|
||||
updatePosition();
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
tooltip.classList.add('hidden');
|
||||
}
|
||||
|
||||
button.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const isHidden = tooltip.classList.contains('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
showTooltip();
|
||||
button.textContent = 'Show less';
|
||||
|
||||
// Close when clicking outside
|
||||
const closeHandler = (event: MouseEvent) => {
|
||||
if (!tooltip.contains(event.target as Node) && !button.contains(event.target as Node)) {
|
||||
hideTooltip();
|
||||
button.textContent = `+${tooltip.querySelectorAll('span').length} more`;
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('click', closeHandler);
|
||||
}, 0);
|
||||
} else {
|
||||
hideTooltip();
|
||||
button.textContent = `+${tooltip.querySelectorAll('span').length} more`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add countdown timer functionality
|
||||
const countdownTimers = document.querySelectorAll('.countdown-timer');
|
||||
|
||||
function updateTimer(element: Element) {
|
||||
const publishAt = new Date(element.getAttribute('data-publish-at') || '').getTime();
|
||||
const now = new Date().getTime();
|
||||
const distance = publishAt - now;
|
||||
|
||||
const timerSpan = element.querySelector('span');
|
||||
if (!timerSpan) return;
|
||||
|
||||
if (distance < 0) {
|
||||
timerSpan.textContent = 'Published';
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(distance / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
|
||||
let timeString = '';
|
||||
if (days > 0) timeString += `${days}d `;
|
||||
if (hours > 0 || days > 0) timeString += `${hours}h `;
|
||||
if (minutes > 0 || hours > 0 || days > 0) timeString += `${minutes}m `;
|
||||
timeString += `${seconds}s`;
|
||||
|
||||
timerSpan.textContent = timeString;
|
||||
}
|
||||
|
||||
// Add intersection observer for lazy loading
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const timer = entry.target.querySelector('.countdown-timer');
|
||||
if (timer) updateTimer(timer);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update countdown timer initialization
|
||||
countdownTimers.forEach(timer => {
|
||||
observer.observe(timer);
|
||||
updateTimer(timer);
|
||||
setInterval(() => {
|
||||
if (timer.isConnected) updateTimer(timer);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
|
||||
function filterComics() {
|
||||
const excludedGenres = new Set(JSON.parse(localStorage.getItem('excluded-genres') || '[]'));
|
||||
|
||||
// Use requestAnimationFrame for smoother updates
|
||||
requestAnimationFrame(() => {
|
||||
document.querySelectorAll('[data-genres]').forEach(card => {
|
||||
const cardGenres = JSON.parse((card as HTMLElement).dataset.genres || '[]');
|
||||
const shouldHide = cardGenres.some((genreId: number) => excludedGenres.has(genreId));
|
||||
card.classList.toggle('hidden', shouldHide);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for genre updates
|
||||
window.addEventListener('genresUpdated', () => {
|
||||
filterComics();
|
||||
});
|
||||
|
||||
// Initial filter
|
||||
filterComics();
|
||||
</script>
|
||||
157
src/components/GenreFilter.astro
Normal file
157
src/components/GenreFilter.astro
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
---
|
||||
import { fetchGenres } from '../lib/api';
|
||||
|
||||
const genres = await fetchGenres();
|
||||
---
|
||||
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
id="genre-filter-btn"
|
||||
class="bg-secondary text-primary px-4 py-2 rounded-lg hover:bg-hover transition-colors flex items-center gap-2"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M3 6h18M6 12h12m-9 6h6"/>
|
||||
</svg>
|
||||
<span>Filter Genres</span>
|
||||
<span id="excluded-count" class="bg-accent/20 text-accent px-2 rounded-full text-sm hidden">
|
||||
0
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="genre-dropdown"
|
||||
class="absolute z-50 mt-2 w-64 bg-secondary rounded-lg shadow-lg border border-hover/30 hidden"
|
||||
>
|
||||
<div class="p-3">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<h3 class="font-medium">Exclude Genres</h3>
|
||||
<button
|
||||
id="clear-filters"
|
||||
class="text-sm text-accent hover:text-accent-light transition-colors"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search genres..."
|
||||
class="w-full px-3 py-2 bg-primary rounded border border-hover/30 text-sm mb-2"
|
||||
id="genre-search"
|
||||
/>
|
||||
<div class="max-h-64 overflow-y-auto space-y-1" id="genre-list">
|
||||
{genres.map(genre => (
|
||||
<label class="flex items-center gap-2 px-2 py-1.5 hover:bg-hover/50 rounded cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
value={genre.id}
|
||||
class="genre-checkbox w-4 h-4 rounded border border-hover/30 bg-primary checked:bg-accent transition-all duration-200 ease-in-out"
|
||||
/>
|
||||
<span class="text-sm">{genre.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const button = document.getElementById('genre-filter-btn');
|
||||
const dropdown = document.getElementById('genre-dropdown');
|
||||
const genreList = document.getElementById('genre-list');
|
||||
const clearButton = document.getElementById('clear-filters');
|
||||
const searchInput = document.getElementById('genre-search') as HTMLInputElement;
|
||||
const excludedCount = document.getElementById('excluded-count');
|
||||
|
||||
// Load excluded genres from localStorage
|
||||
let excludedGenres = new Set(JSON.parse(localStorage.getItem('excluded-genres') || '[]'));
|
||||
|
||||
// Initialize checkboxes
|
||||
function initCheckboxes() {
|
||||
document.querySelectorAll('.genre-checkbox').forEach((checkbox: HTMLInputElement) => {
|
||||
checkbox.checked = excludedGenres.has(Number(checkbox.value));
|
||||
});
|
||||
updateExcludedCount();
|
||||
}
|
||||
|
||||
function updateExcludedCount() {
|
||||
if (excludedCount) {
|
||||
if (excludedGenres.size > 0) {
|
||||
excludedCount.textContent = excludedGenres.size.toString();
|
||||
excludedCount.classList.remove('hidden');
|
||||
} else {
|
||||
excludedCount.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle checkbox changes
|
||||
function handleCheckboxChange(e: Event) {
|
||||
const checkbox = e.target as HTMLInputElement;
|
||||
const genreId = Number(checkbox.value);
|
||||
|
||||
if (checkbox.checked) {
|
||||
excludedGenres.add(genreId);
|
||||
} else {
|
||||
excludedGenres.delete(genreId);
|
||||
}
|
||||
|
||||
localStorage.setItem('excluded-genres', JSON.stringify([...excludedGenres]));
|
||||
updateExcludedCount();
|
||||
|
||||
// Dispatch event for comic filtering
|
||||
window.dispatchEvent(new CustomEvent('genresUpdated', {
|
||||
detail: { excludedGenres: [...excludedGenres] }
|
||||
}));
|
||||
}
|
||||
|
||||
// Search functionality
|
||||
function handleSearch() {
|
||||
const query = searchInput.value.toLowerCase();
|
||||
const labels = genreList?.querySelectorAll('label');
|
||||
|
||||
labels?.forEach(label => {
|
||||
const text = label.textContent?.toLowerCase() || '';
|
||||
label.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
button?.addEventListener('click', () => {
|
||||
dropdown?.classList.toggle('hidden');
|
||||
button.setAttribute('aria-expanded', dropdown?.classList.contains('hidden') ? 'false' : 'true');
|
||||
});
|
||||
|
||||
clearButton?.addEventListener('click', () => {
|
||||
excludedGenres.clear();
|
||||
localStorage.setItem('excluded-genres', '[]');
|
||||
initCheckboxes();
|
||||
window.dispatchEvent(new CustomEvent('genresUpdated', {
|
||||
detail: { excludedGenres: [] }
|
||||
}));
|
||||
});
|
||||
|
||||
document.querySelectorAll('.genre-checkbox').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', handleCheckboxChange);
|
||||
});
|
||||
|
||||
searchInput?.addEventListener('input', handleSearch);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!button?.contains(e.target as Node) && !dropdown?.contains(e.target as Node)) {
|
||||
dropdown?.classList.add('hidden');
|
||||
button?.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
initCheckboxes();
|
||||
</script>
|
||||
34
src/components/LanguageSelector.astro
Normal file
34
src/components/LanguageSelector.astro
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
import { availableLanguages, languageMap } from '../lib/api';
|
||||
|
||||
interface Props {
|
||||
currentLang: string;
|
||||
currentType: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const { currentLang, currentType, contentType } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="relative inline-block">
|
||||
<select
|
||||
class="appearance-none bg-secondary text-primary px-4 py-2 pr-8 rounded-lg cursor-pointer hover:bg-hover transition-colors"
|
||||
onchange="window.location.href=this.value"
|
||||
>
|
||||
{
|
||||
availableLanguages.map((lang) => (
|
||||
<option
|
||||
value={`/${lang}/${contentType}/${currentType}`}
|
||||
selected={lang === currentLang}
|
||||
>
|
||||
{languageMap[lang as keyof typeof languageMap]}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-primary">
|
||||
<svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||
<path d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
26
src/components/LoadingCard.astro
Normal file
26
src/components/LoadingCard.astro
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
---
|
||||
<div class="bg-secondary rounded-xl overflow-hidden shadow-lg h-full flex flex-col max-w-[280px] mx-auto border border-hover/30">
|
||||
<div class="relative overflow-hidden aspect-[2/3] rounded-t-xl bg-hover animate-pulse"></div>
|
||||
|
||||
<div class="p-4 md:p-5 flex-grow flex flex-col bg-secondary relative rounded-b-xl">
|
||||
<div class="absolute -top-8 left-3 right-3 bg-hover animate-pulse h-12 rounded-lg"></div>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="bg-hover animate-pulse h-8 w-24 rounded-full"></div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{[1, 2, 3].map(() => (
|
||||
<div class="bg-hover animate-pulse h-6 w-16 rounded-full"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4 flex items-center justify-between">
|
||||
<div class="bg-hover animate-pulse h-4 w-32 rounded"></div>
|
||||
<div class="bg-hover animate-pulse h-4 w-20 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
31
src/components/RSSLink.astro
Normal file
31
src/components/RSSLink.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
interface Props {
|
||||
lang: string;
|
||||
contentType: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
const { lang, contentType, type } = Astro.props;
|
||||
---
|
||||
|
||||
<a
|
||||
href={`/rss/${lang}/${contentType}/${type}.xml`}
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
target="_blank"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 11a9 9 0 0 1 9 9" />
|
||||
<path d="M4 4a16 16 0 0 1 16 16" />
|
||||
<circle cx="5" cy="19" r="1" />
|
||||
</svg>
|
||||
RSS Feed
|
||||
</a>
|
||||
586
src/components/SlugInput.astro
Normal file
586
src/components/SlugInput.astro
Normal 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>
|
||||
76
src/components/ThemeToggle.astro
Normal file
76
src/components/ThemeToggle.astro
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
---
|
||||
---
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="p-2 w-10 h-10 rounded-full bg-secondary hover:bg-hover flex items-center justify-center transition-colors"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 absolute transition-transform duration-500 opacity-0"
|
||||
data-theme-icon="light"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<svg
|
||||
class="w-5 h-5 absolute transition-transform duration-500 opacity-0"
|
||||
data-theme-icon="dark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<script>
|
||||
const setTheme = (theme: 'light' | 'dark') => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
updateIcons(theme);
|
||||
};
|
||||
|
||||
const updateIcons = (theme: string) => {
|
||||
const lightIcon = document.querySelector('[data-theme-icon="light"]');
|
||||
const darkIcon = document.querySelector('[data-theme-icon="dark"]');
|
||||
|
||||
if (theme === 'light') {
|
||||
lightIcon?.classList.remove('opacity-0');
|
||||
darkIcon?.classList.add('opacity-0');
|
||||
} else {
|
||||
lightIcon?.classList.add('opacity-0');
|
||||
darkIcon?.classList.remove('opacity-0');
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize theme
|
||||
const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | null;
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||
setTheme(initialTheme);
|
||||
|
||||
// Handle toggle click
|
||||
document.getElementById('theme-toggle')?.addEventListener('click', () => {
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
</script>
|
||||
31
src/components/TypeFilter.astro
Normal file
31
src/components/TypeFilter.astro
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
interface Props {
|
||||
currentType: string;
|
||||
lang: string;
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
const { currentType, lang, contentType } = Astro.props;
|
||||
|
||||
const types = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'manga', label: 'Manga' },
|
||||
{ id: 'manhwa', label: 'Manhwa' },
|
||||
{ id: 'manhua', label: 'Manhua' },
|
||||
];
|
||||
---
|
||||
|
||||
<div class="flex gap-2 mb-6">
|
||||
{types.map(type => (
|
||||
<a
|
||||
href={`/${lang}/${contentType}/${type.id}`}
|
||||
class={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentType === type.id
|
||||
? 'bg-[rgb(var(--accent-bg))] text-[rgb(var(--accent-text))]'
|
||||
: 'bg-secondary text-primary hover:bg-hover'
|
||||
}`}
|
||||
>
|
||||
{type.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
Loading…
Add table
Add a link
Reference in a new issue