continue watching/reading
This commit is contained in:
@@ -145,4 +145,32 @@ export async function deleteEntry(req: UserRequest, reply: FastifyReply) {
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
return reply.code(500).send({ error: "Failed to delete list entry" });
|
return reply.code(500).send({ error: "Failed to delete list entry" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getListByFilter(req: UserRequest, reply: FastifyReply) {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const { status, entry_type } = req.query as any;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "Unauthorized" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status && !entry_type) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
error: "At least one filter is required (status or entry_type)."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await listService.getUserListByFilter(
|
||||||
|
userId,
|
||||||
|
status,
|
||||||
|
entry_type
|
||||||
|
);
|
||||||
|
|
||||||
|
return { results };
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return reply.code(500).send({ error: "Failed to retrieve filtered list" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ async function listRoutes(fastify: FastifyInstance) {
|
|||||||
fastify.get('/list/entry/:entryId', controller.getSingleEntry);
|
fastify.get('/list/entry/:entryId', controller.getSingleEntry);
|
||||||
fastify.post('/list/entry', controller.upsertEntry);
|
fastify.post('/list/entry', controller.upsertEntry);
|
||||||
fastify.delete('/list/entry/:entryId', controller.deleteEntry);
|
fastify.delete('/list/entry/:entryId', controller.deleteEntry);
|
||||||
|
fastify.get('/list/filter', controller.getListByFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default listRoutes;
|
export default listRoutes;
|
||||||
@@ -385,4 +385,156 @@ export async function getActiveAccessToken(userId: number): Promise<string | nul
|
|||||||
export async function isConnected(userId: number): Promise<boolean> {
|
export async function isConnected(userId: number): Promise<boolean> {
|
||||||
const token = await getActiveAccessToken(userId);
|
const token = await getActiveAccessToken(userId);
|
||||||
return !!token;
|
return !!token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserListByFilter(
|
||||||
|
userId: number,
|
||||||
|
status?: string,
|
||||||
|
entryType?: string
|
||||||
|
): Promise<any> {
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT * FROM ListEntry
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY updated_at DESC;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const params: any[] = [userId];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbList = await queryAll(sql, params, USER_DB) as ListEntryData[];
|
||||||
|
const connected = await isConnected(userId);
|
||||||
|
|
||||||
|
const statusMap: any = {
|
||||||
|
watching: 'CURRENT',
|
||||||
|
reading: 'CURRENT',
|
||||||
|
completed: 'COMPLETED',
|
||||||
|
paused: 'PAUSED',
|
||||||
|
dropped: 'DROPPED',
|
||||||
|
planning: 'PLANNING'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mappedStatus = status ? statusMap[status.toLowerCase()] : null;
|
||||||
|
|
||||||
|
let finalList: any[] = [];
|
||||||
|
|
||||||
|
// ✅ FILTRADO LOCAL (MANGA + NOVEL)
|
||||||
|
const filteredLocal = dbList.filter((entry) => {
|
||||||
|
if (mappedStatus && entry.status !== mappedStatus) return false;
|
||||||
|
|
||||||
|
if (entryType) {
|
||||||
|
if (entryType === 'MANGA') {
|
||||||
|
// ✅ AHORA ACEPTA MANGA Y NOVEL
|
||||||
|
if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false;
|
||||||
|
} else {
|
||||||
|
if (entry.entry_type !== entryType) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ FILTRADO ANILIST (MANGA + NOVEL TAMBIÉN)
|
||||||
|
let filteredAniList: any[] = [];
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
const anilistEntries = await aniListService.getUserAniList(userId);
|
||||||
|
|
||||||
|
filteredAniList = anilistEntries.filter((entry: any) => {
|
||||||
|
if (mappedStatus && entry.status !== mappedStatus) return false;
|
||||||
|
|
||||||
|
if (entryType) {
|
||||||
|
if (entryType === 'MANGA') {
|
||||||
|
if (!['MANGA', 'NOVEL'].includes(entry.entry_type)) return false;
|
||||||
|
} else {
|
||||||
|
if (entry.entry_type !== entryType) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finalList = [...filteredAniList, ...filteredLocal];
|
||||||
|
|
||||||
|
const enrichedListPromises = finalList.map(async (entry) => {
|
||||||
|
|
||||||
|
// ✅ AniList directo
|
||||||
|
if (entry.source === 'anilist') {
|
||||||
|
let finalTitle = entry.title;
|
||||||
|
|
||||||
|
if (typeof finalTitle === 'object' && finalTitle !== null) {
|
||||||
|
finalTitle =
|
||||||
|
finalTitle.userPreferred ||
|
||||||
|
finalTitle.english ||
|
||||||
|
finalTitle.romaji ||
|
||||||
|
'Unknown Title';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
title: finalTitle,
|
||||||
|
poster: entry.poster || 'https://placehold.co/400x600?text=No+Cover',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ LOCAL → FETCH EXTERNO
|
||||||
|
let contentDetails: any | null = null;
|
||||||
|
const id = entry.entry_id;
|
||||||
|
const type = entry.entry_type;
|
||||||
|
const ext = getExtension(entry.source);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === 'ANIME') {
|
||||||
|
const anime: any = await animeService.getAnimeInfoExtension(ext, id.toString());
|
||||||
|
|
||||||
|
contentDetails = {
|
||||||
|
title: anime?.title || 'Unknown Anime Title',
|
||||||
|
poster: anime?.image || '',
|
||||||
|
total_episodes: anime?.episodes || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
} else if (type === 'MANGA' || type === 'NOVEL') {
|
||||||
|
const book: any = await booksService.getBookInfoExtension(ext, id.toString());
|
||||||
|
|
||||||
|
contentDetails = {
|
||||||
|
title: book?.title || 'Unknown Book Title',
|
||||||
|
poster: book?.image || '',
|
||||||
|
total_chapters: book?.chapters || book?.volumes * 10 || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
contentDetails = {
|
||||||
|
title: 'Error Loading Details',
|
||||||
|
poster: 'https://placehold.co/400x600?text=No+Cover',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalTitle = contentDetails?.title || 'Unknown Title';
|
||||||
|
let finalPoster = contentDetails?.poster || 'https://placehold.co/400x600?text=No+Cover';
|
||||||
|
|
||||||
|
if (typeof finalTitle === 'object' && finalTitle !== null) {
|
||||||
|
finalTitle =
|
||||||
|
finalTitle.userPreferred ||
|
||||||
|
finalTitle.english ||
|
||||||
|
finalTitle.romaji ||
|
||||||
|
'Unknown Title';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
title: finalTitle,
|
||||||
|
poster: finalPoster,
|
||||||
|
total_episodes: contentDetails?.total_episodes,
|
||||||
|
total_chapters: contentDetails?.total_chapters,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.all(enrichedListPromises);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al filtrar la lista del usuario:", error);
|
||||||
|
throw new Error("Error en la base de datos al obtener la lista filtrada.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +71,70 @@ async function fetchSearh(query) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadContinueWatching() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/list/filter?status=watching&entry_type=ANIME', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const list = data.results || [];
|
||||||
|
|
||||||
|
renderContinueWatching('my-status', list);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Continue Watching Error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContinueWatching(id, list) {
|
||||||
|
const container = document.getElementById(id);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
container.innerHTML = `<div style="padding:1rem; color:#888">No watching anime</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ ordenar por fecha
|
||||||
|
list.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||||
|
|
||||||
|
list.forEach(item => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card';
|
||||||
|
|
||||||
|
el.onclick = () => window.location.href =
|
||||||
|
item.source === 'anilist'
|
||||||
|
? `/anime/${item.entry_id}`
|
||||||
|
: `/anime/${item.source}/${item.entry_id}`;
|
||||||
|
|
||||||
|
const progressText = item.total_episodes
|
||||||
|
? `${item.progress || 0}/${item.total_episodes}`
|
||||||
|
: `${item.progress || 0}`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card-img-wrap">
|
||||||
|
<img src="${item.poster}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>${item.title}</h3>
|
||||||
|
<p>Ep ${progressText} - ${item.source}</p> </div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderSearchResults(results) {
|
function renderSearchResults(results) {
|
||||||
searchResults.innerHTML = '';
|
searchResults.innerHTML = '';
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
@@ -263,4 +327,5 @@ function renderList(id, list) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchContent();
|
fetchContent();
|
||||||
|
loadContinueWatching();
|
||||||
setInterval(() => fetchContent(true), 60000);
|
setInterval(() => fetchContent(true), 60000);
|
||||||
@@ -199,6 +199,72 @@ function updateHeroUI(book) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadContinueReading() {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/list/filter?status=reading&entry_type=MANGA', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const list = data.results || [];
|
||||||
|
|
||||||
|
renderContinueReading('my-status-books', list);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Continue Reading Error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContinueReading(id, list) {
|
||||||
|
const container = document.getElementById(id);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
container.innerHTML = `<div style="padding:1rem; color:#888">No reading manga</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ordenar por updated_at DESC
|
||||||
|
list.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));
|
||||||
|
|
||||||
|
list.forEach(item => {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card';
|
||||||
|
|
||||||
|
el.onclick = () => window.location.href =
|
||||||
|
item.source === 'anilist'
|
||||||
|
? `/book/${item.entry_id}`
|
||||||
|
: `/book/${item.source}/${item.entry_id}`;
|
||||||
|
|
||||||
|
const progressText = item.total_episodes
|
||||||
|
? `${item.progress || 0}/${item.total_episodes}`
|
||||||
|
: `${item.progress || 0}`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card-img-wrap">
|
||||||
|
<img src="${item.poster}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<h3>${item.title}</h3>
|
||||||
|
<p>Ch ${progressText} - ${item.source}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function renderList(id, list) {
|
function renderList(id, list) {
|
||||||
const container = document.getElementById(id);
|
const container = document.getElementById(id);
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -226,4 +292,5 @@ function renderList(id, list) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
loadContinueReading();
|
||||||
@@ -122,6 +122,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Continue watching</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-wrapper">
|
||||||
|
<button class="scroll-btn left" onclick="scrollCarousel('my-status', -1)">‹</button>
|
||||||
|
<div class="carousel" id="my-status">
|
||||||
|
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||||
|
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||||
|
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||||
|
<div class="card"><div class="card-img-wrap skeleton"></div></div>
|
||||||
|
</div>
|
||||||
|
<button class="scroll-btn right" onclick="scrollCarousel('my-status', 1)">›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-header"><div class="section-title">Trending This Season</div></div>
|
<div class="section-header"><div class="section-title">Trending This Season</div></div>
|
||||||
|
|||||||
@@ -111,6 +111,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<div class="section-title">Continue Reading</div>
|
||||||
|
</div>
|
||||||
|
<div class="carousel-wrapper">
|
||||||
|
<button class="scroll-btn left" onclick="scrollCarousel('my-status-books', -1)">‹</button>
|
||||||
|
<div class="carousel" id="my-status-books"></div>
|
||||||
|
<button class="scroll-btn right" onclick="scrollCarousel('my-status-books', 1)">›</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="section-header"><div class="section-title">Trending Books</div></div>
|
<div class="section-header"><div class="section-title">Trending Books</div></div>
|
||||||
<div class="carousel-wrapper">
|
<div class="carousel-wrapper">
|
||||||
|
|||||||
@@ -338,9 +338,23 @@ body {
|
|||||||
|
|
||||||
.card {
|
.card {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
|
width: 220px;
|
||||||
|
flex: 0 0 220px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
transition: transform 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
.source-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
background: rgba(0,0,0,0.75);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.card:hover {
|
.card:hover {
|
||||||
transform: translateY(-8px);
|
transform: translateY(-8px);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -312,10 +312,9 @@ body {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Diseño de Lista (Grid/List View) mejorado --- */
|
|
||||||
.list-grid {
|
.list-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,15 +367,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.list-grid.list-view .item-poster {
|
.list-grid.list-view .item-poster {
|
||||||
width: 100px;
|
/* Cambiar el ancho y alto */
|
||||||
height: 150px;
|
width: 120px; /* Antes: 100px */
|
||||||
|
height: 180px; /* Antes: 150px */
|
||||||
aspect-ratio: auto;
|
aspect-ratio: auto;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 1rem;
|
margin: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-content {
|
.item-content {
|
||||||
padding: 1.2rem;
|
padding: 1rem; /* Antes: 1.2rem */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
@@ -393,7 +393,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-title {
|
.item-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1rem; /* Antes: 1.1rem */
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -412,14 +412,16 @@ body {
|
|||||||
|
|
||||||
.item-meta {
|
.item-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.3rem; /* Antes: 0.75rem. Espacio entre los pills */
|
||||||
margin-bottom: 0.8rem;
|
margin-bottom: 0.5rem; /* Antes: 0.8rem */
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
/* Añadir: Asegura que si se envuelven, lo hagan con poco margen vertical */
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-pill {
|
.meta-pill {
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem; /* Antes: 0.7rem */
|
||||||
padding: 0.25rem 0.6rem;
|
padding: 0.15rem 0.4rem; /* Antes: 0.25rem 0.6rem. Reduce el padding interno */
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
Reference in New Issue
Block a user