better ui for anilist entries & fixes

This commit is contained in:
2025-12-07 02:24:30 +01:00
parent 6ae823ac0b
commit 1973069949
15 changed files with 1723 additions and 668 deletions

View File

@@ -2,7 +2,82 @@ import { queryOne } from '../../shared/database';
const USER_DB = 'userdata'; const USER_DB = 'userdata';
// Configuración de reintentos
const RETRY_CONFIG = {
maxRetries: 3,
initialDelay: 1000,
maxDelay: 5000,
backoffMultiplier: 2
};
// Helper para hacer requests con reintentos y manejo de errores
async function fetchWithRetry(
url: string,
options: RequestInit,
retries = RETRY_CONFIG.maxRetries
): Promise<Response> {
let lastError: Error | null = null;
let delay = RETRY_CONFIG.initialDelay;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
// Si es rate limit (429), esperamos más tiempo
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After');
const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : delay;
if (attempt < retries) {
console.warn(`Rate limited. Esperando ${waitTime}ms antes de reintentar...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
}
// Si es un error de servidor (5xx), reintentamos
if (response.status >= 500 && attempt < retries) {
console.warn(`Error del servidor (${response.status}). Reintentando en ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
return response;
} catch (error) {
lastError = error as Error;
if (attempt < retries && (
error instanceof Error && (
error.name === 'AbortError' ||
error.message.includes('fetch') ||
error.message.includes('network')
)
)) {
console.warn(`Error de conexión (intento ${attempt + 1}/${retries + 1}). Reintentando en ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
delay = Math.min(delay * RETRY_CONFIG.backoffMultiplier, RETRY_CONFIG.maxDelay);
continue;
}
throw error;
}
}
throw lastError || new Error('Request failed after all retries');
}
export async function getUserAniList(appUserId: number) { export async function getUserAniList(appUserId: number) {
try {
const sql = ` const sql = `
SELECT access_token, anilist_user_id SELECT access_token, anilist_user_id
FROM UserIntegration FROM UserIntegration
@@ -13,6 +88,7 @@ export async function getUserAniList(appUserId: number) {
if (!integration) return []; if (!integration) return [];
const { access_token, anilist_user_id } = integration; const { access_token, anilist_user_id } = integration;
if (!access_token || !anilist_user_id) return [];
const query = ` const query = `
query ($userId: Int) { query ($userId: Int) {
@@ -23,6 +99,11 @@ export async function getUserAniList(appUserId: number) {
status status
progress progress
score score
startedAt { year month day }
completedAt { year month day }
repeat
notes
private
} }
} }
} }
@@ -33,17 +114,23 @@ export async function getUserAniList(appUserId: number) {
status status
progress progress
score score
startedAt { year month day }
completedAt { year month day }
repeat
notes
private
} }
} }
} }
} }
`; `;
const res = await fetch('https://graphql.anilist.co', { const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${access_token}`, 'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
query, query,
@@ -51,8 +138,23 @@ export async function getUserAniList(appUserId: number) {
}), }),
}); });
if (!res.ok) {
throw new Error(`AniList API error: ${res.status}`);
}
const json = await res.json(); const json = await res.json();
if (json?.errors?.length) {
throw new Error(json.errors[0].message);
}
const fromFuzzy = (d: any) => {
if (!d?.year) return null;
const m = String(d.month || 1).padStart(2, '0');
const day = String(d.day || 1).padStart(2, '0');
return `${d.year}-${m}-${day}`;
};
const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => { const normalize = (lists: any[], type: 'ANIME' | 'MANGA') => {
const result: any[] = []; const result: any[] = [];
@@ -66,6 +168,11 @@ export async function getUserAniList(appUserId: number) {
status: entry.status, status: entry.status,
progress: entry.progress || 0, progress: entry.progress || 0,
score: entry.score || null, score: entry.score || null,
start_date: fromFuzzy(entry.startedAt),
end_date: fromFuzzy(entry.completedAt),
repeat_count: entry.repeat || 0,
notes: entry.notes || null,
is_private: entry.private ? 1 : 0
}); });
} }
} }
@@ -77,6 +184,10 @@ export async function getUserAniList(appUserId: number) {
...normalize(json?.data?.anime?.lists, 'ANIME'), ...normalize(json?.data?.anime?.lists, 'ANIME'),
...normalize(json?.data?.manga?.lists, 'MANGA') ...normalize(json?.data?.manga?.lists, 'MANGA')
]; ];
} catch (error) {
console.error('Error fetching AniList data:', error);
return [];
}
} }
export async function updateAniListEntry(token: string, params: { export async function updateAniListEntry(token: string, params: {
@@ -84,75 +195,185 @@ export async function updateAniListEntry(token: string, params: {
status?: string | null; status?: string | null;
progress?: number | null; progress?: number | null;
score?: number | null; score?: number | null;
start_date?: string | null; // YYYY-MM-DD
end_date?: string | null; // YYYY-MM-DD
repeat_count?: number | null;
notes?: string | null;
is_private?: boolean | number | null;
}) { }) {
try {
if (!token) throw new Error('AniList token is required');
const mutation = ` const mutation = `
mutation ($mediaId: Int, $status: MediaListStatus, $progress: Int, $score: Float) { mutation (
$mediaId: Int,
$status: MediaListStatus,
$progress: Int,
$score: Float,
$startedAt: FuzzyDateInput,
$completedAt: FuzzyDateInput,
$repeat: Int,
$notes: String,
$private: Boolean
) {
SaveMediaListEntry ( SaveMediaListEntry (
mediaId: $mediaId, mediaId: $mediaId,
status: $status, status: $status,
progress: $progress, progress: $progress,
score: $score score: $score,
startedAt: $startedAt,
completedAt: $completedAt,
repeat: $repeat,
notes: $notes,
private: $private
) { ) {
id id
status status
progress progress
score score
startedAt { year month day }
completedAt { year month day }
repeat
notes
private
} }
} }
`; `;
const variables: any = { const toFuzzyDate = (dateStr?: string | null) => {
mediaId: Number(params.mediaId), if (!dateStr) return null;
const [year, month, day] = dateStr.split('-').map(Number);
return { year, month, day };
}; };
if (params.status != null) variables.status = params.status; const variables: any = {
if (params.progress != null) variables.progress = params.progress; mediaId: Number(params.mediaId),
if (params.score != null) variables.score = params.score; status: params.status ?? undefined,
progress: params.progress ?? undefined,
score: params.score ?? undefined,
startedAt: toFuzzyDate(params.start_date),
completedAt: toFuzzyDate(params.end_date),
repeat: params.repeat_count ?? undefined,
notes: params.notes ?? undefined,
private: typeof params.is_private === 'boolean'
? params.is_private
: params.is_private === 1
};
const res = await fetch('https://graphql.anilist.co', { const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json',
}, },
body: JSON.stringify({ query: mutation, variables }), body: JSON.stringify({ query: mutation, variables }),
}); });
if (!res.ok) {
const errorText = await res.text();
throw new Error(`AniList update failed: ${res.status} - ${errorText}`);
}
const json = await res.json(); const json = await res.json();
if (!res.ok || json?.errors?.length) { if (json?.errors?.length) {
throw new Error("AniList update failed"); throw new Error(`AniList GraphQL error: ${json.errors[0].message}`);
} }
return json.data?.SaveMediaListEntry || null; return json.data?.SaveMediaListEntry || null;
} catch (error) {
console.error('Error updating AniList entry:', error);
throw error;
}
} }
export async function deleteAniListEntry(token: string, mediaId: number) { export async function deleteAniListEntry(token: string, mediaId: number) {
if (!token) throw new Error("AniList token required");
const query = ` try {
query ($mediaId: Int) { // 1⃣ OBTENER VIEWER
MediaList(mediaId: $mediaId) { const viewerQuery = `query { Viewer { id name } }`;
const vRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ query: viewerQuery }),
});
const vJson = await vRes.json();
const userId = vJson?.data?.Viewer?.id;
if (!userId) throw new Error("Invalid AniList token");
// 2⃣ DETECTAR TIPO REAL DEL MEDIA
const mediaQuery = `
query ($id: Int) {
Media(id: $id) {
id
type
}
}
`;
const mTypeRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query: mediaQuery,
variables: { id: mediaId }
}),
});
const mTypeJson = await mTypeRes.json();
const mediaType = mTypeJson?.data?.Media?.type;
if (!mediaType) {
throw new Error("Media not found in AniList");
}
// 3⃣ BUSCAR ENTRY CON TIPO REAL
const listQuery = `
query ($userId: Int, $mediaId: Int, $type: MediaType) {
MediaList(userId: $userId, mediaId: $mediaId, type: $type) {
id id
} }
} }
`; `;
const qRes = await fetch('https://graphql.anilist.co', { const qRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json',
}, },
body: JSON.stringify({ query, variables: { mediaId } }), body: JSON.stringify({
query: listQuery,
variables: {
userId,
mediaId,
type: mediaType
}
}),
}); });
const qJson = await qRes.json(); const qJson = await qRes.json();
const listEntryId = qJson?.data?.MediaList?.id; const listEntryId = qJson?.data?.MediaList?.id;
if (!listEntryId) { if (!listEntryId) {
throw new Error("Entry not found or unauthorized to delete."); throw new Error("Entry not found in user's AniList");
} }
// 4⃣ BORRAR
const mutation = ` const mutation = `
mutation ($id: Int) { mutation ($id: Int) {
DeleteMediaListEntry(id: $id) { DeleteMediaListEntry(id: $id) {
@@ -161,11 +382,12 @@ export async function deleteAniListEntry(token: string, mediaId: number) {
} }
`; `;
const mRes = await fetch('https://graphql.anilist.co', { const delRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
query: mutation, query: mutation,
@@ -173,42 +395,118 @@ export async function deleteAniListEntry(token: string, mediaId: number) {
}), }),
}); });
const mJson = await mRes.json(); const delJson = await delRes.json();
if (mJson?.errors?.length) { if (delJson?.errors?.length) {
throw new Error("Error eliminando entrada en AniList"); throw new Error(delJson.errors[0].message);
} }
return true; return true;
} catch (err) {
console.error("AniList DELETE failed:", err);
throw err;
} }
}
export async function getSingleAniListEntry( export async function getSingleAniListEntry(
token: string, token: string,
mediaId: number, mediaId: number,
type: 'ANIME' | 'MANGA' type: 'ANIME' | 'MANGA'
) { ) {
const query = ` try {
query ($mediaId: Int, $type: MediaType) { if (!token) {
MediaList(mediaId: $mediaId, type: $type) { throw new Error('AniList token is required');
status
progress
score
} }
}
`;
const res = await fetch('https://graphql.anilist.co', { // 1⃣ Obtener userId desde el token
const viewerRes = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST', method: 'POST',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Accept': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
query, query: `query { Viewer { id } }`
variables: { mediaId, type }
}) })
}); });
const json = await res.json(); const viewerJson = await viewerRes.json();
return json?.data?.MediaList || null; const userId = viewerJson?.data?.Viewer?.id;
if (!userId) {
throw new Error('Failed to get AniList userId');
}
// 2⃣ Query correcta con userId
const query = `
query ($mediaId: Int, $type: MediaType, $userId: Int) {
MediaList(mediaId: $mediaId, type: $type, userId: $userId) {
id
status
progress
score
repeat
private
notes
startedAt { year month day }
completedAt { year month day }
}
}
`;
const res = await fetchWithRetry('https://graphql.anilist.co', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
query,
variables: { mediaId, type, userId }
})
});
if (!res.ok) {
throw new Error(`Failed to fetch entry: ${res.status}`);
}
const json = await res.json();
if (json?.errors?.length) {
if (json.errors[0].status === 404) return null;
throw new Error(`GraphQL error: ${json.errors[0].message}`);
}
const entry = json?.data?.MediaList;
if (!entry) return null;
return {
entry_id: mediaId,
source: 'anilist',
entry_type: type,
status: entry.status,
progress: entry.progress || 0,
score: entry.score ?? null,
start_date: entry.startedAt?.year
? `${entry.startedAt.year}-${String(entry.startedAt.month).padStart(2, '0')}-${String(entry.startedAt.day).padStart(2, '0')}`
: null,
end_date: entry.completedAt?.year
? `${entry.completedAt.year}-${String(entry.completedAt.month).padStart(2, '0')}-${String(entry.completedAt.day).padStart(2, '0')}`
: null,
repeat_count: entry.repeat || 0,
notes: entry.notes || null,
is_private: entry.private ? 1 : 0,
};
} catch (error) {
console.error('Error fetching single AniList entry:', error);
throw error;
}
} }

View File

@@ -1,5 +1,5 @@
import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries'; import { getCachedExtension, cacheExtension, getCache, setCache, getExtensionTitle } from '../../shared/queries';
import { queryOne, queryAll } from '../../shared/database'; import { queryOne, queryAll, run } from '../../shared/database';
import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions'; import { getAllExtensions, getBookExtensionsMap } from '../../shared/extensions';
import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types'; import { Book, Extension, ChapterWithProvider, ChapterContent } from '../types';
@@ -62,7 +62,10 @@ const MEDIA_FIELDS = `
`; `;
export async function getBookById(id: string | number): Promise<Book | { error: string }> { export async function getBookById(id: string | number): Promise<Book | { error: string }> {
const row = await queryOne("SELECT full_data FROM books WHERE id = ?", [id]); const row = await queryOne(
"SELECT full_data FROM books WHERE id = ?",
[id]
);
if (row) { if (row) {
return JSON.parse(row.full_data); return JSON.parse(row.full_data);
@@ -70,6 +73,7 @@ export async function getBookById(id: string | number): Promise<Book | { error:
try { try {
console.log(`[Book] Local miss for ID ${id}, fetching live...`); console.log(`[Book] Local miss for ID ${id}, fetching live...`);
const query = ` const query = `
query ($id: Int) { query ($id: Int) {
Media(id: $id, type: MANGA) { Media(id: $id, type: MANGA) {
@@ -90,13 +94,38 @@ export async function getBookById(id: string | number): Promise<Book | { error:
const response = await fetch('https://graphql.anilist.co', { const response = await fetch('https://graphql.anilist.co', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, headers: {
body: JSON.stringify({ query, variables: { id: parseInt(id.toString()) } }) 'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify({
query,
variables: { id: parseInt(id.toString()) }
})
}); });
const data = await response.json(); const data = await response.json();
if (data.data && data.data.Media) {
return data.data.Media; if (data?.data?.Media) {
const media = data.data.Media;
const insertSql = `
INSERT INTO books (id, title, updatedAt, full_data)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = EXCLUDED.title,
updatedAt = EXCLUDED.updatedAt,
full_data = EXCLUDED.full_data;
`;
await run(insertSql, [
media.id,
media.title?.userPreferred || media.title?.romaji || media.title?.english || null,
media.updatedAt || Math.floor(Date.now() / 1000),
JSON.stringify(media)
]);
return media;
} }
} catch (e) { } catch (e) {
console.error("Fetch error:", e); console.error("Fetch error:", e);

View File

@@ -53,13 +53,11 @@ export async function getSingleEntry(req: UserRequest, reply: FastifyReply) {
return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." }); return reply.code(400).send({ error: "Missing required identifier: entryId, source, or entry_type." });
} }
const entryIdentifier = entryId;
try { try {
const entry = await listService.getSingleListEntry( const entry = await listService.getSingleListEntry(
userId, userId,
entryIdentifier, entryId,
source, source,
entry_type entry_type
); );
@@ -78,14 +76,16 @@ export async function getSingleEntry(req: UserRequest, reply: FastifyReply) {
export async function upsertEntry(req: UserRequest, reply: FastifyReply) { export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id; const userId = req.user?.id;
const body = req.body as UpsertEntryBody; const body = req.body as any;
if (!userId) { if (!userId) {
return reply.code(401).send({ error: "Unauthorized" }); return reply.code(401).send({ error: "Unauthorized" });
} }
if (!body.entry_id || !body.source || !body.status || !body.entry_type) { if (!body.entry_id || !body.source || !body.status || !body.entry_type) {
return reply.code(400).send({ error: "Missing required fields (entry_id, source, status, entry_type)." }); return reply.code(400).send({
error: "Missing required fields (entry_id, source, status, entry_type)."
});
} }
try { try {
@@ -97,7 +97,12 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
entry_type: body.entry_type, entry_type: body.entry_type,
status: body.status, status: body.status,
progress: body.progress || 0, progress: body.progress || 0,
score: body.score || null score: body.score || null,
start_date: body.start_date || null,
end_date: body.end_date || null,
repeat_count: body.repeat_count ?? 0,
notes: body.notes || null,
is_private: body.is_private ?? 0
}; };
const result = await listService.upsertListEntry(entryData); const result = await listService.upsertListEntry(entryData);
@@ -112,24 +117,29 @@ export async function upsertEntry(req: UserRequest, reply: FastifyReply) {
export async function deleteEntry(req: UserRequest, reply: FastifyReply) { export async function deleteEntry(req: UserRequest, reply: FastifyReply) {
const userId = req.user?.id; const userId = req.user?.id;
const { entryId } = req.params as EntryParams; const { entryId } = req.params as EntryParams;
const { source } = req.query as { source?: string }; // ✅ VIENE DEL FRONT
if (!userId) { if (!userId) {
return reply.code(401).send({ error: "Unauthorized" }); return reply.code(401).send({ error: "Unauthorized" });
} }
const entryIdentifier = entryId; if (!entryId || !source) {
return reply.code(400).send({ error: "Missing entryId or source." });
if (!entryIdentifier) {
return reply.code(400).send({ error: "Invalid entry ID." });
} }
try { try {
const result = await listService.deleteListEntry(userId, entryIdentifier); const result = await listService.deleteListEntry(
userId,
entryId,
source
);
if (result.success) { if (result.success) {
return { success: true, message: "Entry deleted successfully." }; return { success: true, external: result.external };
} else { } else {
return reply.code(404).send({ error: "Entry not found or unauthorized to delete." }); return reply.code(404).send({
error: "Entry not found or unauthorized to delete."
});
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);

View File

@@ -27,43 +27,60 @@ export async function upsertListEntry(entry: any) {
entry_type, entry_type,
status, status,
progress, progress,
score score,
start_date,
end_date,
repeat_count,
notes,
is_private
} = entry; } = entry;
if (source === 'anilist') { if (source === 'anilist') {
const token = await getActiveAccessToken(user_id); const token = await getActiveAccessToken(user_id);
if (token) { if (token) {
try { try {
const result = await aniListService.updateAniListEntry(token, { const result = await aniListService.updateAniListEntry(token, {
mediaId: entry_id, mediaId: entry_id,
status, status,
progress, progress,
score score,
start_date,
end_date,
repeat_count,
notes,
is_private
}); });
return { changes: 0, external: true, anilistResult: result }; return { changes: 0, external: true, anilistResult: result };
} catch (err) { } catch (err) {
console.error("Error actualizando AniList:", err); console.error("Error actualizando AniList:", err);
} }
} }
} }
const sql = ` const sql = `
INSERT INTO ListEntry INSERT INTO ListEntry
(user_id, entry_id, source, entry_type, status, progress, score, updated_at) (
user_id, entry_id, source, entry_type, status,
progress, score,
start_date, end_date, repeat_count, notes, is_private,
updated_at
)
VALUES VALUES
(?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, entry_id) DO UPDATE SET ON CONFLICT(user_id, entry_id) DO UPDATE SET
source = EXCLUDED.source, source = EXCLUDED.source,
entry_type = EXCLUDED.entry_type, entry_type = EXCLUDED.entry_type,
status = EXCLUDED.status, status = EXCLUDED.status,
progress = EXCLUDED.progress, progress = EXCLUDED.progress,
score = EXCLUDED.score, score = EXCLUDED.score,
start_date = EXCLUDED.start_date,
end_date = EXCLUDED.end_date,
repeat_count = EXCLUDED.repeat_count,
notes = EXCLUDED.notes,
is_private = EXCLUDED.is_private,
updated_at = CURRENT_TIMESTAMP; updated_at = CURRENT_TIMESTAMP;
`; `;
@@ -74,7 +91,12 @@ export async function upsertListEntry(entry: any) {
entry_type, entry_type,
status, status,
progress, progress,
score || null score ?? null,
start_date || null,
end_date || null,
repeat_count ?? 0,
notes || null,
is_private ?? 0
]; ];
try { try {
@@ -101,8 +123,6 @@ export async function getUserList(userId: number): Promise<any> {
let finalList: ListEntryData[] = [...dbList]; let finalList: ListEntryData[] = [...dbList];
if (connected) { if (connected) {
const token = await getActiveAccessToken(userId);
const anilistEntries = await aniListService.getUserAniList(userId); const anilistEntries = await aniListService.getUserAniList(userId);
const localWithoutAnilist = dbList.filter( const localWithoutAnilist = dbList.filter(
@@ -184,30 +204,22 @@ export async function getUserList(userId: number): Promise<any> {
} }
} }
export async function deleteListEntry(userId: number, entryId: string | number) { export async function deleteListEntry(
userId: number,
entryId: string | number,
source: string
) {
const checkSql = ` if (source === 'anilist') {
SELECT source
FROM ListEntry
WHERE user_id = ? AND entry_id = ?;
`;
const existing = await queryOne(checkSql, [userId, entryId], USER_DB) as any;
if (existing?.source === 'anilist') { const token = await getActiveAccessToken(userId);
const sql = `
SELECT access_token, anilist_user_id
FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList';
`;
const integration = await queryOne(sql, [userId], USER_DB) as any; if (token) {
if (integration?.access_token) {
try { try {
await aniListService.deleteAniListEntry( await aniListService.deleteAniListEntry(
integration.access_token, token,
Number(entryId) Number(entryId),
); );
return { success: true, external: true }; return { success: true, external: true };
@@ -217,18 +229,14 @@ export async function deleteListEntry(userId: number, entryId: string | number)
} }
} }
// ✅ SOLO LOCAL
const sql = ` const sql = `
DELETE FROM ListEntry DELETE FROM ListEntry
WHERE user_id = ? AND entry_id = ?; WHERE user_id = ? AND entry_id = ?;
`; `;
try {
const result = await run(sql, [userId, entryId], USER_DB); const result = await run(sql, [userId, entryId], USER_DB);
return { success: result.changes > 0, changes: result.changes, external: false }; return { success: result.changes > 0, changes: result.changes, external: false };
} catch (error) {
console.error("Error al eliminar la entrada de lista:", error);
throw new Error("Error en la base de datos al eliminar la entrada.");
}
} }
export async function getSingleListEntry( export async function getSingleListEntry(
@@ -238,19 +246,61 @@ export async function getSingleListEntry(
entryType: string entryType: string
): Promise<any> { ): Promise<any> {
const connected = await isConnected(userId); // ✅ 1. BUSCAR PRIMERO EN TU BASE DE DATOS
const localSql = `
SELECT * FROM ListEntry
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
`;
if (source === 'anilist' && connected) { const localResult = await queryAll(
localSql,
[userId, entryId, source, entryType],
USER_DB
) as any[];
if (localResult.length > 0) {
const entry = localResult[0];
const contentDetails: any =
entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
let finalTitle = contentDetails?.title || 'Unknown';
let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
if (typeof finalTitle === 'object') {
finalTitle =
finalTitle.userPreferred ||
finalTitle.english ||
finalTitle.romaji ||
'Unknown';
}
return {
...entry,
title: finalTitle,
poster: finalPoster,
total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.chapters,
};
}
if (source === 'anilist') {
const connected = await isConnected(userId);
if (!connected) return null;
const sql = ` const sql = `
SELECT access_token, anilist_user_id SELECT access_token
FROM UserIntegration FROM UserIntegration
WHERE user_id = ? AND platform = 'AniList'; WHERE user_id = ? AND platform = 'AniList';
`; `;
const integration = await queryOne(sql, [userId], USER_DB) as any; const integration = await queryOne(sql, [userId], USER_DB) as any;
if (!integration?.access_token) return null;
if (!integration) return null;
const aniEntry = await aniListService.getSingleAniListEntry( const aniEntry = await aniListService.getSingleAniListEntry(
integration.access_token, integration.access_token,
@@ -258,118 +308,38 @@ export async function getSingleListEntry(
entryType as any entryType as any
); );
if (!aniEntry) return null; if (!aniEntry) return null;
let contentDetails: any = null; const contentDetails: any =
entryType === 'ANIME'
? await animeService.getAnimeById(entryId).catch(() => null)
: await booksService.getBookById(entryId).catch(() => null);
if (entryType === 'ANIME') { let finalTitle = contentDetails?.title || 'Unknown';
const anime:any = await animeService.getAnimeById(entryId); let finalPoster = contentDetails?.coverImage?.extraLarge ||
contentDetails?.image ||
'https://placehold.co/400x600?text=No+Cover';
contentDetails = { if (typeof finalTitle === 'object') {
title: anime?.title, finalTitle =
poster: anime?.coverImage?.extraLarge || anime?.image || '', finalTitle.userPreferred ||
total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0, finalTitle.english ||
}; finalTitle.romaji ||
} else { 'Unknown';
const book: any = await booksService.getBookById(entryId);
contentDetails = {
title: book?.title,
poster: book?.coverImage?.extraLarge || book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
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 { return {
user_id: userId, user_id: userId,
entry_id: Number(entryId), ...aniEntry,
source: 'anilist',
entry_type: entryType,
status: aniEntry.status,
progress: aniEntry.progress,
score: aniEntry.score,
title: finalTitle, title: finalTitle,
poster: finalPoster, poster: finalPoster,
total_episodes: contentDetails?.total_episodes, total_episodes: contentDetails?.episodes,
total_chapters: contentDetails?.total_chapters, total_chapters: contentDetails?.chapters,
}; };
} }
const sql = ` return null;
SELECT * FROM ListEntry
WHERE user_id = ? AND entry_id = ? AND source = ? AND entry_type = ?;
`;
const dbEntry = await queryAll(sql, [userId, entryId, source, entryType], USER_DB) as ListEntryData[];
if (!dbEntry || dbEntry.length === 0) return null;
const entry = dbEntry[0];
let contentDetails: any | null = null;
try {
if (entryType === 'ANIME') {
let anime: any;
if (source === 'anilist') {
anime = await animeService.getAnimeById(entryId);
} else {
const ext = getExtension(source);
anime = await animeService.getAnimeInfoExtension(ext, entryId.toString());
}
contentDetails = {
title: anime?.title,
poster: anime?.coverImage?.extraLarge || anime?.image || '',
total_episodes: anime?.episodes || anime?.nextAiringEpisode?.episode - 1 || 0,
};
} else {
let book: any;
if (source === 'anilist') {
book = await booksService.getBookById(entryId);
} else {
const ext = getExtension(source);
book = await booksService.getBookInfoExtension(ext, entryId.toString());
}
contentDetails = {
title: book?.title,
poster: book?.coverImage?.extraLarge || book?.image || '',
total_chapters: book?.chapters || book?.volumes * 10 || 0,
};
}
} catch {
contentDetails = {
title: 'Unknown',
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,
};
} }
export async function getActiveAccessToken(userId: number): Promise<string | null> { export async function getActiveAccessToken(userId: number): Promise<string | null> {

Binary file not shown.

View File

@@ -34,8 +34,8 @@ function getSimpleAuthHeaders() {
}; };
} }
// Lógica de chequeo de lista corregida para usar /list/entry/{id} y esperar 'found'
async function checkIfInList() { async function checkIfInList() {
const entryId = window.location.pathname.split('/').pop(); const entryId = window.location.pathname.split('/').pop();
const source = extensionName || 'anilist'; const source = extensionName || 'anilist';
const entryType = 'ANIME'; const entryType = 'ANIME';
@@ -51,8 +51,7 @@ async function checkIfInList() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
if (data.found && data.entry) { if (data.found && data.entry) { // Esperamos 'found'
isInList = true; isInList = true;
currentListEntry = data.entry; currentListEntry = data.entry;
} else { } else {
@@ -69,6 +68,8 @@ async function checkIfInList() {
function updateAddToListButton() { function updateAddToListButton() {
const btn = document.getElementById('add-to-list-btn'); const btn = document.getElementById('add-to-list-btn');
if (!btn) return;
if (isInList) { if (isInList) {
btn.innerHTML = ` btn.innerHTML = `
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24"> <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
@@ -87,36 +88,73 @@ function updateAddToListButton() {
} }
} }
// Función openAddToListModal actualizada con todos los campos extendidos
function openAddToListModal() { function openAddToListModal() {
if (isInList) { // Referencias
const modalTitle = document.getElementById('modal-title');
const deleteBtn = document.getElementById('modal-delete-btn');
document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING'; // Mapeo de prefijos (usamos 'entry-' para el HTML del modal extendido)
document.getElementById('modal-progress').value = currentListEntry.progress || 0; const statusEl = document.getElementById('entry-status');
document.getElementById('modal-score').value = currentListEntry.score || ''; const progressEl = document.getElementById('entry-progress');
const scoreEl = document.getElementById('entry-score');
const startDateEl = document.getElementById('entry-start-date');
const endDateEl = document.getElementById('entry-end-date');
const repeatCountEl = document.getElementById('entry-repeat-count');
const notesEl = document.getElementById('entry-notes');
const privateEl = document.getElementById('entry-is-private');
document.getElementById('modal-title').textContent = 'Edit List Entry';
document.getElementById('modal-delete-btn').style.display = 'block'; if (isInList && currentListEntry) {
statusEl.value = currentListEntry.status || 'PLANNING';
progressEl.value = currentListEntry.progress || 0;
scoreEl.value = currentListEntry.score || '';
// Campos extendidos
startDateEl.value = currentListEntry.start_date ? currentListEntry.start_date.split('T')[0] : '';
endDateEl.value = currentListEntry.end_date ? currentListEntry.end_date.split('T')[0] : '';
repeatCountEl.value = currentListEntry.repeat_count || 0;
notesEl.value = currentListEntry.notes || '';
privateEl.checked = currentListEntry.is_private === true || currentListEntry.is_private === 1;
modalTitle.textContent = 'Edit List Entry';
deleteBtn.style.display = 'block';
} else { } else {
// Valores por defecto
statusEl.value = 'PLANNING';
progressEl.value = 0;
scoreEl.value = '';
startDateEl.value = '';
endDateEl.value = '';
repeatCountEl.value = 0;
notesEl.value = '';
privateEl.checked = false;
document.getElementById('modal-status').value = 'PLANNING'; modalTitle.textContent = 'Add to List';
document.getElementById('modal-progress').value = 0; deleteBtn.style.display = 'none';
document.getElementById('modal-score').value = '';
document.getElementById('modal-title').textContent = 'Add to List';
document.getElementById('modal-delete-btn').style.display = 'none';
} }
document.getElementById('modal-progress').max = totalEpisodes || 999; progressEl.max = totalEpisodes || 999;
document.getElementById('add-list-modal').classList.add('active');} document.getElementById('add-list-modal').classList.add('active');
}
function closeAddToListModal() { function closeAddToListModal() {
document.getElementById('add-list-modal').classList.remove('active'); document.getElementById('add-list-modal').classList.remove('active');
} }
// Función saveToList actualizada con todos los campos extendidos
async function saveToList() { async function saveToList() {
const status = document.getElementById('modal-status').value; const status = document.getElementById('entry-status').value;
const progress = parseInt(document.getElementById('modal-progress').value) || 0; const progress = parseInt(document.getElementById('entry-progress').value) || 0;
const score = parseFloat(document.getElementById('modal-score').value) || null; const scoreValue = document.getElementById('entry-score').value;
const score = scoreValue ? parseFloat(scoreValue) : null;
// Nuevos campos
const start_date = document.getElementById('entry-start-date').value || null;
const end_date = document.getElementById('entry-end-date').value || null;
const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0;
const notes = document.getElementById('entry-notes').value || null;
const is_private = document.getElementById('entry-is-private').checked;
try { try {
const response = await fetch(`${API_BASE}/list/entry`, { const response = await fetch(`${API_BASE}/list/entry`, {
@@ -128,7 +166,13 @@ async function saveToList() {
entry_type: 'ANIME', entry_type: 'ANIME',
status: status, status: status,
progress: progress, progress: progress,
score: score score: score,
// Campos extendidos
start_date: start_date,
end_date: end_date,
repeat_count: repeat_count,
notes: notes,
is_private: is_private
}) })
}); });
@@ -136,16 +180,10 @@ async function saveToList() {
throw new Error('Failed to save entry'); throw new Error('Failed to save entry');
} }
isInList = true; const data = await response.json();
currentListEntry = {
entry_id: parseInt(animeId),
source: extensionName || 'anilist',
entry_type: 'ANIME',
status, isInList = true;
progress, currentListEntry = data.entry;
score
};
updateAddToListButton(); updateAddToListButton();
closeAddToListModal(); closeAddToListModal();
showNotification(isInList ? 'Updated successfully!' : 'Added to your list!', 'success'); showNotification(isInList ? 'Updated successfully!' : 'Added to your list!', 'success');
@@ -158,8 +196,11 @@ async function saveToList() {
async function deleteFromList() { async function deleteFromList() {
if (!confirm('Remove this anime from your list?')) return; if (!confirm('Remove this anime from your list?')) return;
const source = extensionName || 'anilist';
const entryType = 'ANIME';
try { try {
const response = await fetch(`${API_BASE}/list/entry/${animeId}`, { const response = await fetch(`${API_BASE}/list/entry/${animeId}?source=${source}&entry_type=${entryType}`, {
method: 'DELETE', method: 'DELETE',
headers: getSimpleAuthHeaders() headers: getSimpleAuthHeaders()

View File

@@ -46,6 +46,7 @@ function getBookEntryType(bookData) {
return (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') ? 'MANGA' : 'NOVEL'; return (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') ? 'MANGA' : 'NOVEL';
} }
// CORRECCIÓN: Usar el endpoint /list/entry/{id} y esperar 'found'
async function checkIfInList() { async function checkIfInList() {
if (!currentBookData) return; if (!currentBookData) return;
@@ -54,6 +55,7 @@ async function checkIfInList() {
const entryType = getBookEntryType(currentBookData); const entryType = getBookEntryType(currentBookData);
// URL CORRECTA: /list/entry/{id}?source={source}&entry_type={entryType}
const fetchUrl = `${API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`; const fetchUrl = `${API_BASE}/list/entry/${entryId}?source=${source}&entry_type=${entryType}`;
try { try {
@@ -64,6 +66,7 @@ async function checkIfInList() {
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
// LÓGICA CORRECTA: Comprobar data.found
if (data.found && data.entry) { if (data.found && data.entry) {
isInList = true; isInList = true;
@@ -73,6 +76,11 @@ async function checkIfInList() {
currentListEntry = null; currentListEntry = null;
} }
updateAddToListButton(); updateAddToListButton();
} else if (response.status === 404) {
// Manejar 404 como 'no encontrado' si la API lo devuelve así
isInList = false;
currentListEntry = null;
updateAddToListButton();
} }
} catch (error) { } catch (error) {
console.error('Error checking single list entry:', error); console.error('Error checking single list entry:', error);
@@ -103,40 +111,71 @@ function updateAddToListButton() {
} }
} }
/**
* REFACTORIZADO para usar la estructura del modal completo.
* Asume que el HTML usa IDs como 'entry-status', 'entry-progress', 'entry-score', etc.
*/
function openAddToListModal() { function openAddToListModal() {
if (!currentBookData) return; if (!currentBookData) return;
const totalUnits = currentBookData.chapters || currentBookData.volumes || 999; const totalUnits = currentBookData.chapters || currentBookData.volumes || 999;
const entryType = getBookEntryType(currentBookData);
if (isInList) { // Referencias a los elementos del nuevo modal (usando 'entry-' prefix)
const modalTitle = document.getElementById('modal-title');
const deleteBtn = document.getElementById('modal-delete-btn');
const progressLabel = document.getElementById('progress-label');
document.getElementById('modal-status').value = currentListEntry.status || 'PLANNING'; // **VERIFICACIÓN CRÍTICA**
document.getElementById('modal-progress').value = currentListEntry.progress || 0; if (!modalTitle || !deleteBtn || !progressLabel) {
document.getElementById('modal-score').value = currentListEntry.score || ''; console.error("Error: Uno o más elementos críticos del modal (título, botón eliminar, o etiqueta de progreso) no se encontraron. Verifique los IDs en el HTML.");
return;
document.getElementById('modal-title').textContent = 'Edit Library Entry';
document.getElementById('modal-delete-btn').style.display = 'block';
} else {
document.getElementById('modal-status').value = 'PLANNING';
document.getElementById('modal-progress').value = 0;
document.getElementById('modal-score').value = '';
document.getElementById('modal-title').textContent = 'Add to Library';
document.getElementById('modal-delete-btn').style.display = 'none';
} }
const progressLabel = document.getElementById('modal-progress-label'); // --- Población de Datos ---
if (isInList && currentListEntry) {
// Datos comunes
document.getElementById('entry-status').value = currentListEntry.status || 'PLANNING';
document.getElementById('entry-progress').value = currentListEntry.progress || 0;
document.getElementById('entry-score').value = currentListEntry.score || '';
// Nuevos datos
// Usar formato ISO si viene como ISO, o limpiar si es necesario. Tu ejemplo JSON no tenía fechas.
document.getElementById('entry-start-date').value = currentListEntry.start_date ? currentListEntry.start_date.split('T')[0] : '';
document.getElementById('entry-end-date').value = currentListEntry.end_date ? currentListEntry.end_date.split('T')[0] : '';
document.getElementById('entry-repeat-count').value = currentListEntry.repeat_count || 0;
document.getElementById('entry-notes').value = currentListEntry.notes || '';
document.getElementById('entry-is-private').checked = currentListEntry.is_private === true || currentListEntry.is_private === 1;
modalTitle.textContent = 'Edit Library Entry';
deleteBtn.style.display = 'block';
} else {
// Valores por defecto
document.getElementById('entry-status').value = 'PLANNING';
document.getElementById('entry-progress').value = 0;
document.getElementById('entry-score').value = '';
document.getElementById('entry-start-date').value = '';
document.getElementById('entry-end-date').value = '';
document.getElementById('entry-repeat-count').value = 0;
document.getElementById('entry-notes').value = '';
document.getElementById('entry-is-private').checked = false;
modalTitle.textContent = 'Add to Library';
deleteBtn.style.display = 'none';
}
// --- Configuración de Etiquetas y Máximo ---
if (progressLabel) { if (progressLabel) {
const format = currentBookData.format?.toUpperCase() || 'MANGA'; if (entryType === 'MANGA') {
if (format === 'MANGA' || format === 'ONE_SHOT' || format === 'MANHWA') {
progressLabel.textContent = 'Chapters Read'; progressLabel.textContent = 'Chapters Read';
} else { } else {
progressLabel.textContent = 'Volumes/Parts Read'; progressLabel.textContent = 'Volumes/Parts Read';
} }
} }
document.getElementById('modal-progress').max = totalUnits; document.getElementById('entry-progress').max = totalUnits;
document.getElementById('add-list-modal').classList.add('active'); document.getElementById('add-list-modal').classList.add('active');
} }
@@ -144,10 +183,23 @@ function closeAddToListModal() {
document.getElementById('add-list-modal').classList.remove('active'); document.getElementById('add-list-modal').classList.remove('active');
} }
/**
* REFACTORIZADO para guardar TODOS los campos del modal.
*/
async function saveToList() { async function saveToList() {
const status = document.getElementById('modal-status').value; // Datos comunes
const progress = parseInt(document.getElementById('modal-progress').value) || 0; const status = document.getElementById('entry-status').value;
const score = parseFloat(document.getElementById('modal-score').value) || null; const progress = parseInt(document.getElementById('entry-progress').value) || 0;
const scoreValue = document.getElementById('entry-score').value;
const score = scoreValue ? parseFloat(scoreValue) : null;
// Nuevos datos
const start_date = document.getElementById('entry-start-date').value || null;
const end_date = document.getElementById('entry-end-date').value || null;
const repeat_count = parseInt(document.getElementById('entry-repeat-count').value) || 0;
const notes = document.getElementById('entry-notes').value || null;
const is_private = document.getElementById('entry-is-private').checked;
if (!currentBookData) { if (!currentBookData) {
showNotification('Cannot save: Book data not loaded.', 'error'); showNotification('Cannot save: Book data not loaded.', 'error');
@@ -167,7 +219,13 @@ async function saveToList() {
entry_type: entryType, entry_type: entryType,
status: status, status: status,
progress: progress, progress: progress,
score: score score: score,
// Nuevos campos
start_date: start_date,
end_date: end_date,
repeat_count: repeat_count,
notes: notes,
is_private: is_private
}) })
}); });
@@ -175,8 +233,10 @@ async function saveToList() {
throw new Error('Failed to save entry'); throw new Error('Failed to save entry');
} }
const data = await response.json();
isInList = true; isInList = true;
currentListEntry = { entry_id: idToSave, source: extensionName || 'anilist', entry_type: entryType, status, progress, score }; currentListEntry = data.entry; // Usar la respuesta del servidor si está disponible
updateAddToListButton(); updateAddToListButton();
closeAddToListModal(); closeAddToListModal();
showNotification(isInList ? 'Updated successfully!' : 'Added to your library!', 'success'); showNotification(isInList ? 'Updated successfully!' : 'Added to your library!', 'success');
@@ -186,16 +246,19 @@ async function saveToList() {
} }
} }
// CORRECCIÓN: Usar el endpoint /list/entry/{id} con los parámetros correctos.
async function deleteFromList() { async function deleteFromList() {
if (!confirm('Remove this book from your library?')) return; if (!confirm('Remove this book from your library?')) return;
const idToDelete = extensionName ? bookSlug : bookId; const idToDelete = extensionName ? bookSlug : bookId;
const source = extensionName || 'anilist';
const entryType = getBookEntryType(currentBookData); // Obtener el tipo de entrada
try { try {
const response = await fetch(`${API_BASE}/list/entry/${idToDelete}`, { // URL CORRECTA para DELETE: /list/entry/{id}?source={source}&entry_type={entryType}
const response = await fetch(`${API_BASE}/list/entry/${idToDelete}?source=${source}&entry_type=${entryType}`, {
method: 'DELETE', method: 'DELETE',
headers: getSimpleAuthHeaders() headers: getSimpleAuthHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -519,6 +582,7 @@ function openReader(bookId, chapterId, provider) {
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// El ID del modal sigue siendo 'add-list-modal' para mantener la compatibilidad con el código original.
const modal = document.getElementById('add-list-modal'); const modal = document.getElementById('add-list-modal');
if (modal) { if (modal) {
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {

View File

@@ -224,6 +224,7 @@ function createListItem(item) {
const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0; const progressPercent = totalUnits > 0 ? (progress / totalUnits) * 100 : 0;
const score = item.score ? item.score.toFixed(1) : null; const score = item.score ? item.score.toFixed(1) : null;
const repeatCount = item.repeat_count || 0;
const entryType = (item.entry_type || 'ANIME').toUpperCase(); const entryType = (item.entry_type || 'ANIME').toUpperCase();
let unitLabel = 'units'; let unitLabel = 'units';
@@ -243,6 +244,15 @@ function createListItem(item) {
'DROPPED': 'Dropped' 'DROPPED': 'Dropped'
}; };
const extraInfo = [];
if (repeatCount > 0) {
extraInfo.push(`<span class="meta-pill repeat-pill">🔁 ${repeatCount}</span>`);
}
if (item.is_private) {
extraInfo.push('<span class="meta-pill private-pill">🔒 Private</span>');
}
div.innerHTML = ` div.innerHTML = `
<a href="${itemLink}" class="item-poster-link"> <a href="${itemLink}" class="item-poster-link">
<img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'"> <img src="${posterUrl}" alt="${item.title || 'Entry'}" class="item-poster" onerror="this.src='/public/assets/placeholder.png'">
@@ -256,6 +266,7 @@ function createListItem(item) {
<span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span> <span class="meta-pill status-pill">${statusLabels[item.status] || item.status}</span>
<span class="meta-pill type-pill">${entryType}</span> <span class="meta-pill type-pill">${entryType}</span>
<span class="meta-pill source-pill">${item.source.toUpperCase()}</span> <span class="meta-pill source-pill">${item.source.toUpperCase()}</span>
${extraInfo.join('')}
</div> </div>
</div> </div>
@@ -282,9 +293,21 @@ function createListItem(item) {
function openEditModal(item) { function openEditModal(item) {
currentEditingEntry = item; currentEditingEntry = item;
// Campos existentes
document.getElementById('edit-status').value = item.status; document.getElementById('edit-status').value = item.status;
document.getElementById('edit-progress').value = item.progress || 0; document.getElementById('edit-progress').value = item.progress || 0;
document.getElementById('edit-score').value = item.score || ''; // Asegura que el score se muestre si existe.
document.getElementById('edit-score').value = item.score !== null && item.score !== undefined ? item.score : '';
// Nuevos campos
// Usamos split('T')[0] para asegurar que solo se muestra la parte de la fecha (YYYY-MM-DD) si viene con formato DATETIME.
document.getElementById('edit-start-date').value = item.start_date?.split('T')[0] || '';
document.getElementById('edit-end-date').value = item.end_date?.split('T')[0] || '';
document.getElementById('edit-repeat-count').value = item.repeat_count || 0;
document.getElementById('edit-notes').value = item.notes || '';
// Maneja el booleano o el entero (1/0)
document.getElementById('edit-is-private').checked = item.is_private === 1 || item.is_private === true;
const entryType = (item.entry_type || 'ANIME').toUpperCase(); const entryType = (item.entry_type || 'ANIME').toUpperCase();
const progressLabel = document.querySelector('label[for="edit-progress"]'); const progressLabel = document.querySelector('label[for="edit-progress"]');
@@ -315,9 +338,20 @@ function closeEditModal() {
async function saveEntry() { async function saveEntry() {
if (!currentEditingEntry) return; if (!currentEditingEntry) return;
// Campos existentes
const status = document.getElementById('edit-status').value; const status = document.getElementById('edit-status').value;
const progress = parseInt(document.getElementById('edit-progress').value) || 0; const progress = parseInt(document.getElementById('edit-progress').value) || 0;
const score = parseFloat(document.getElementById('edit-score').value) || null; // Usar null si el score está vacío
const scoreValue = document.getElementById('edit-score').value;
const score = scoreValue ? parseFloat(scoreValue) : null;
// Nuevos campos
const start_date = document.getElementById('edit-start-date').value || null;
const end_date = document.getElementById('edit-end-date').value || null;
const repeat_count = parseInt(document.getElementById('edit-repeat-count').value) || 0;
const notesValue = document.getElementById('edit-notes').value;
const notes = notesValue ? notesValue : null;
const is_private = document.getElementById('edit-is-private').checked;
try { try {
const response = await fetch(`${API_BASE}/list/entry`, { const response = await fetch(`${API_BASE}/list/entry`, {
@@ -329,7 +363,13 @@ async function saveEntry() {
entry_type: currentEditingEntry.entry_type || 'ANIME', entry_type: currentEditingEntry.entry_type || 'ANIME',
status: status, status: status,
progress: progress, progress: progress,
score: score score: score,
// Nuevos datos a enviar al backend
start_date: start_date,
end_date: end_date,
repeat_count: repeat_count,
notes: notes,
is_private: is_private
}) })
}); });
@@ -354,10 +394,13 @@ async function deleteEntry() {
} }
try { try {
const response = await fetch(`${API_BASE}/list/entry/${currentEditingEntry.entry_id}`, { const response = await fetch(
`${API_BASE}/list/entry/${currentEditingEntry.entry_id}?source=${currentEditingEntry.source}`,
{
method: 'DELETE', method: 'DELETE',
headers: getSimpleAuthHeaders() headers: getSimpleAuthHeaders()
}); }
);
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to delete entry'); throw new Error('Failed to delete entry');
@@ -372,6 +415,7 @@ async function deleteEntry() {
} }
} }
function showNotification(message, type = 'info') { function showNotification(message, type = 'info') {
const notification = document.createElement('div'); const notification = document.createElement('div');
notification.style.cssText = ` notification.style.cssText = `

View File

@@ -46,6 +46,12 @@ async function ensureUserDataDB(dbPath) {
status TEXT NOT NULL, status TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0, progress INTEGER NOT NULL DEFAULT 0,
score INTEGER, score INTEGER,
start_date DATE,
end_date DATE,
repeat_count INTEGER NOT NULL DEFAULT 0,
notes TEXT,
is_private BOOLEAN NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, entry_id), UNIQUE (user_id, entry_id),
FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES User(id) ON DELETE CASCADE
@@ -89,8 +95,6 @@ async function ensureAnilistSchema(db) {
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT, title TEXT,
updatedAt INTEGER, updatedAt INTEGER,
cache_created_at INTEGER DEFAULT 0,
cache_ttl_ms INTEGER DEFAULT 0,
full_data JSON full_data JSON
); );

View File

@@ -23,7 +23,6 @@
</div> </div>
</div> </div>
<!-- Synopsis Modal -->
<div class="modal-overlay" id="desc-modal"> <div class="modal-overlay" id="desc-modal">
<div class="modal-content"> <div class="modal-content">
<button class="modal-close" onclick="closeModal()"></button> <button class="modal-close" onclick="closeModal()"></button>
@@ -32,16 +31,17 @@
</div> </div>
</div> </div>
<!-- Add to List Modal -->
<div class="modal-overlay" id="add-list-modal"> <div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list"> <div class="modal-content modal-list">
<button class="modal-close" onclick="closeAddToListModal()"></button> <button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to List</h2> <h2 class="modal-title" id="modal-title">Add to List</h2>
<div class="modal-body"> <div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group"> <div class="form-group">
<label>Status</label> <label>Status</label>
<select id="modal-status" class="form-input"> <select id="entry-status" class="form-input">
<option value="WATCHING">Watching</option> <option value="WATCHING">Watching</option>
<option value="COMPLETED">Completed</option> <option value="COMPLETED">Completed</option>
<option value="PLANNING">Plan to Watch</option> <option value="PLANNING">Plan to Watch</option>
@@ -52,19 +52,49 @@
<div class="form-group"> <div class="form-group">
<label>Episodes Watched</label> <label>Episodes Watched</label>
<input type="number" id="modal-progress" class="form-input" min="0" placeholder="0"> <input type="number" id="entry-progress" class="form-input" min="0" placeholder="0">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Your Score (0-10)</label> <label>Your Score (0-10)</label>
<input type="number" id="modal-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional"> <input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Rewatch Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-modal-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-modal-secondary" onclick="closeAddToListModal()">Cancel</button> <button class="btn-modal-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-modal-danger" id="modal-delete-btn" onclick="deleteFromList()" style="display: none;">Remove</button> <button class="btn-modal-primary" onclick="saveToList()">Save Changes</button>
<button class="btn-modal-primary" onclick="saveToList()">Save</button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,7 @@
<base href="/"> <base href="/">
<title>WaifuBoard Book</title> <title>WaifuBoard Book</title>
<link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon"> <link rel="icon" href="/public/assets/waifuboards.ico" type="image/x-icon">
<link rel="stylesheet" href="/views/css/anime/home.css">
<link rel="stylesheet" href="/views/css/books/book.css"> <link rel="stylesheet" href="/views/css/books/book.css">
<link rel="stylesheet" href="/views/css/updateNotifier.css"> <link rel="stylesheet" href="/views/css/updateNotifier.css">
<link rel="stylesheet" href="/views/css/titlebar.css"> <link rel="stylesheet" href="/views/css/titlebar.css">
@@ -24,15 +25,17 @@
</div> </div>
<div class="modal-overlay" id="add-list-modal"> <div class="modal-overlay" id="add-list-modal">
<div class="modal-content modal-list"> <div class="modal-content">
<button class="modal-close" onclick="closeAddToListModal()"></button> <button class="modal-close" onclick="closeAddToListModal()"></button>
<h2 class="modal-title" id="modal-title">Add to Library</h2> <h2 class="modal-title" id="modal-title">Add to Library</h2>
<div class="modal-body"> <div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group"> <div class="form-group">
<label>Status</label> <label for="entry-status">Status</label>
<select id="modal-status" class="form-input"> <select id="entry-status" class="form-input">
<option value="WATCHING">Reading</option> <option value="CURRENT">Reading</option>
<option value="COMPLETED">Completed</option> <option value="COMPLETED">Completed</option>
<option value="PLANNING">Plan to Read</option> <option value="PLANNING">Plan to Read</option>
<option value="PAUSED">Paused</option> <option value="PAUSED">Paused</option>
@@ -41,25 +44,51 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label id="modal-progress-label">Chapters Read</label> <label for="entry-progress" id="progress-label">Chapters Read</label>
<input type="number" id="modal-progress" class="form-input" min="0" placeholder="0"> <input type="number" id="entry-progress" class="form-input" min="0" max="0" placeholder="0">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Your Score (0-10)</label> <label for="entry-score">Score (0-10)</label>
<input type="number" id="modal-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional"> <input type="number" id="entry-score" class="form-input" min="0" max="10" step="0.1" placeholder="Optional">
</div>
<div class="form-group full-width date-group">
<div class="date-input-pair">
<label for="entry-start-date">Start Date</label>
<input type="date" id="entry-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="entry-end-date">End Date</label>
<input type="date" id="entry-end-date" class="form-input">
</div>
</div>
<div class="form-group">
<label for="entry-repeat-count">Re-read Count</label>
<input type="number" id="entry-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="entry-notes">Notes</label>
<textarea id="entry-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="entry-is-private" class="form-checkbox">
<label for="entry-is-private">Mark as Private</label>
</div>
</div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-modal-secondary" onclick="closeAddToListModal()">Cancel</button> <button class="btn-danger" id="modal-delete-btn" onclick="deleteFromList()">Remove</button>
<button class="btn-modal-danger" id="modal-delete-btn" onclick="deleteFromList()" style="display: none;">Remove</button> <button class="btn-secondary" onclick="closeAddToListModal()">Cancel</button>
<button class="btn-modal-primary" onclick="saveToList()">Save</button> <button class="btn-primary" onclick="saveToList()">Save Changes</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<a href="/books" class="back-btn"> <a href="/books" class="back-btn">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg> <svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M15 19l-7-7 7-7"/></svg>
Back to Books Back to Books

View File

@@ -11,6 +11,8 @@
--radius-full: 9999px; --radius-full: 9999px;
--danger: #ef4444; --danger: #ef4444;
--success: #22c55e; --success: #22c55e;
--bg-amoled: #0a0a0a; /* Añadido de list.css */
--bg-field: #0e0e0f; /* Añadido de list.css */
} }
body { body {
@@ -271,92 +273,144 @@ body {
.sidebar { display: none; } .sidebar { display: none; }
} }
/* Modal Styles */ /* --- ESTILOS DE MODAL (INCLUIDOS DE LIST.CSS) --- */
.modal-overlay { .modal-overlay {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.9); /* Más oscuro */
backdrop-filter: blur(8px); backdrop-filter: blur(10px);
z-index: 2000; z-index: 2000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
padding: 2rem; padding: 1rem;
} }
.modal-overlay.active { .modal-overlay.active {
display: flex; display: flex;
opacity: 1; opacity: 1;
} }
.modal-content {
background: #18181b; @keyframes modalSlideUp {
border: 1px solid rgba(255, 255, 255, 0.1); from { opacity: 0; transform: translateY(20px); }
border-radius: 24px; to { opacity: 1; transform: translateY(0); }
padding: 2.5rem;
max-width: 650px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
transform: scale(0.95);
transition: transform 0.3s;
}
.modal-overlay.active .modal-content {
transform: scale(1);
} }
.modal-content.modal-list { .modal-content {
max-width: 500px; background: var(--bg-amoled); /* AMOLED */
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-lg);
max-width: 900px; /* Ancho para el grid de 3 columnas */
width: 95%;
padding: 0; /* padding se moverá al título y cuerpo */
position: relative;
box-shadow: 0 20px 50px rgba(0,0,0,0.8);
max-height: 90vh;
display: flex;
flex-direction: column;
animation: modalSlideUp 0.3s ease;
}
.modal-content.modal-list { /* Usamos esto para sobrescribir el ancho genérico del modal de descripción */
max-width: 700px;
} }
.modal-close { .modal-close {
position: absolute; position: absolute;
top: 1.5rem; top: 1rem;
right: 1.5rem; right: 1rem;
background: rgba(255, 255, 255, 0.1); background: rgba(255,255,255,0.05);
border: none; border: 1px solid rgba(255,255,255,0.1);
color: white; color: white;
width: 32px; width: 36px;
height: 32px; height: 36px;
border-radius: 50%; border-radius: 8px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.2rem;
transition: 0.2s; transition: 0.2s;
z-index: 2001;
} }
.modal-close:hover { background: rgba(255, 255, 255, 0.2); }
.modal-text { .modal-close:hover {
line-height: 1.8; background: var(--danger);
font-size: 1.1rem; border-color: var(--danger);
color: #e4e4e7; }
.modal-title {
font-size: 1.8rem;
font-weight: 800;
padding: 1.5rem 2rem 0.5rem;
margin-top: 0;
margin-bottom: 0;
color: var(--text-primary);
border-bottom: 1px solid rgba(255,255,255,0.05);
} }
.modal-title { margin-top: 0; margin-bottom: 1.5rem; font-size: 1.5rem; font-weight: 800; }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; overflow-y: auto;
padding: 0 2rem;
flex-grow: 1;
} }
/* GRUPO PRINCIPAL DE CAMPOS (GRID) */
.modal-fields-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem 2rem;
padding: 1.5rem 0;
}
.form-group { .form-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
/* Grid layout overrides */
.form-group.notes-group {
grid-column: 1 / span 2;
}
.form-group.checkbox-group {
grid-column: 3 / 4;
align-self: flex-end;
margin-bottom: 0.5rem;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 1rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
/* Date Input Styling */
.date-group {
display: flex;
gap: 1rem;
}
.date-input-pair {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label { .form-group label {
font-size: 0.9rem; font-size: 0.8rem; /* Tamaño más pequeño */
font-weight: 600; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.form-input { .form-input {
background: var(--bg-base); background: var(--bg-field); /* Fondo específico para campos */
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
color: var(--text-primary); color: var(--text-primary);
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
@@ -372,10 +426,52 @@ body {
box-shadow: 0 0 10px var(--accent-glow); box-shadow: 0 0 10px var(--accent-glow);
} }
.notes-textarea {
resize: vertical;
min-height: 100px;
}
/* Checkbox Styling */
.form-checkbox {
width: 18px;
height: 18px;
border: 1px solid rgba(255,255,255,0.2);
background: var(--bg-base);
border-radius: 4px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.form-checkbox:checked {
background: var(--accent);
border-color: var(--accent);
}
.form-checkbox:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
}
/* ACCIONES (Barra inferior pegajosa) */
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 0;
justify-content: flex-end;
flex-shrink: 0;
padding: 1rem 2rem;
border-top: 1px solid rgba(255,255,255,0.05);
background: var(--bg-amoled); /* Fondo para la barra sticky */
position: sticky;
bottom: 0;
z-index: 10;
} }
.btn-modal-primary, .btn-modal-secondary, .btn-modal-danger { .btn-modal-primary, .btn-modal-secondary, .btn-modal-danger {
@@ -385,15 +481,14 @@ body {
font-size: 0.95rem; font-size: 0.95rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, opacity 0.2s; transition: transform 0.2s, background 0.2s;
flex: 1; flex: none; /* Asegura que no se estiren */
} }
.btn-modal-primary { .btn-modal-primary {
background: var(--accent); background: var(--accent);
color: white; color: white;
} }
.btn-modal-primary:hover { .btn-modal-primary:hover {
transform: scale(1.05); transform: scale(1.05);
} }
@@ -403,7 +498,6 @@ body {
color: white; color: white;
border: 1px solid rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.2);
} }
.btn-modal-secondary:hover { .btn-modal-secondary:hover {
background: rgba(255,255,255,0.15); background: rgba(255,255,255,0.15);
} }
@@ -411,12 +505,34 @@ body {
.btn-modal-danger { .btn-modal-danger {
background: var(--danger); background: var(--danger);
color: white; color: white;
margin-right: auto; /* Para alinear a la izquierda */
} }
.btn-modal-danger:hover { .btn-modal-danger:hover {
opacity: 0.9; opacity: 0.9;
} }
/* Media Queries para el Modal */
@media (max-width: 900px) {
.modal-content { max-width: 95%; }
.modal-fields-grid { grid-template-columns: repeat(2, 1fr); }
.form-group.notes-group { grid-column: 1 / -1; }
.form-group.checkbox-group { grid-column: 1 / -1; align-self: auto; justify-content: flex-start; }
.modal-actions { padding: 1rem 1.5rem; }
.modal-title, .modal-body { padding-left: 1.5rem; padding-right: 1.5rem; }
}
@media (max-width: 550px) {
.modal-content { margin: 0.5rem; width: auto; }
.modal-fields-grid { grid-template-columns: 1fr; gap: 1rem; padding-bottom: 0; }
.date-group { flex-direction: column; gap: 1rem; }
.form-group.notes-group, .form-group.checkbox-group { grid-column: auto; }
.modal-actions { flex-direction: column; align-items: stretch; }
.btn-modal-danger { margin-right: 0; order: 3; }
.btn-modal-secondary { order: 2; }
.btn-modal-primary { order: 1; }
}
.read-more-btn { .read-more-btn {
background: none; background: none;
border: none; border: none;

View File

@@ -3,6 +3,7 @@
--bg-surface: #121215; --bg-surface: #121215;
--bg-surface-hover: #1e1e22; --bg-surface-hover: #1e1e22;
--accent: #8b5cf6; --accent: #8b5cf6;
--accent-glow: rgba(139, 92, 246, 0.4); /* Añadido */
--text-primary: #ffffff; --text-primary: #ffffff;
--text-secondary: #a1a1aa; --text-secondary: #a1a1aa;
--radius-md: 12px; --radius-md: 12px;
@@ -11,6 +12,9 @@
--glass-border: 1px solid rgba(255, 255, 255, 0.1); --glass-border: 1px solid rgba(255, 255, 255, 0.1);
--glass-bg: rgba(20, 20, 23, 0.7); --glass-bg: rgba(20, 20, 23, 0.7);
--nav-height: 80px; --nav-height: 80px;
--danger: #ef4444; /* Añadido */
--bg-amoled: #0a0a0a; /* Añadido */
--bg-field: #0e0e0f; /* Añadido */
} }
* { box-sizing: border-box; outline: none; } * { box-sizing: border-box; outline: none; }
@@ -219,144 +223,325 @@ body {
} }
/* ==================================== */ /* ==================================== */
/* MODAL STYLES (Add to Library Modal) */ /* MODAL STYLES (REEMPLAZO POR MODAL COMPLETO) */
/* ==================================== */ /* ==================================== */
.modal-overlay { .modal-overlay {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.85); background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px); backdrop-filter: blur(10px);
z-index: 2000; z-index: 2000;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
padding: 2rem; padding: 1rem;
} }
.modal-overlay.active { .modal-overlay.active {
display: flex; display: flex;
opacity: 1; opacity: 1;
} }
.modal-content { .modal-content {
background: #18181b; background: var(--bg-amoled); /* Fondo AMOLED oscuro */
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px; border-radius: var(--radius-lg);
padding: 2.5rem; max-width: 900px; /* Ancho para el grid de 3 columnas */
max-width: 650px; width: 95%;
width: 90%; padding: 0;
max-height: 80vh;
overflow-y: auto;
position: relative; position: relative;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); box-shadow: 0 20px 50px rgba(0,0,0,0.8);
transform: scale(0.95); max-height: 90vh;
transition: transform 0.3s; display: flex;
} flex-direction: column;
.modal-overlay.active .modal-content { animation: modalSlideUp 0.3s ease;
transform: scale(1);
} }
.modal-content.modal-list { @keyframes modalSlideUp {
max-width: 500px; from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.modal-close { .modal-close {
position: absolute; position: absolute;
top: 1.5rem; top: 1rem;
right: 1.5rem; right: 1rem;
background: rgba(255, 255, 255, 0.1); background: rgba(255,255,255,0.05);
border: none; border: 1px solid rgba(255,255,255,0.1);
color: white; color: white;
width: 32px; width: 36px;
height: 32px; height: 36px;
border-radius: 50%; border-radius: 8px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1.2rem;
transition: 0.2s; transition: 0.2s;
z-index: 2001;
}
.modal-close:hover {
background: var(--danger);
border-color: var(--danger);
}
.modal-title {
font-size: 1.8rem;
font-weight: 800;
padding: 1.5rem 2rem 0.5rem;
margin-bottom: 0;
color: var(--text-primary);
border-bottom: 1px solid rgba(255,255,255,0.05);
} }
.modal-close:hover { background: rgba(255, 255, 255, 0.2); }
.modal-title { margin-top: 0; margin-bottom: 1.5rem; font-size: 1.5rem; font-weight: 800; }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; overflow-y: auto;
padding: 0 2rem;
flex-grow: 1;
} }
.form-group { /* GRUPO PRINCIPAL DE CAMPOS (GRID) */
.modal-fields-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem 2rem;
padding: 1.5rem 0;
}
.form-group { /* Agregado para consistencia con list.css */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.form-group label { .form-group label {
font-size: 0.9rem; font-size: 0.8rem;
font-weight: 600; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.form-input {
background: var(--bg-base); /* Ajustes de campos específicos */
.form-group.notes-group {
grid-column: 1 / span 2;
}
.form-group.checkbox-group {
grid-column: 3 / 4;
align-self: flex-end;
margin-bottom: 0.5rem;
flex-direction: row; /* Necesario para la checkbox en línea */
align-items: center;
justify-content: flex-end;
gap: 1rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
/* Date Input Styling */
.date-group {
display: flex;
gap: 1rem;
}
.date-input-pair {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Input Styling dentro del modal */
.modal-content .form-input {
background: var(--bg-field); /* Fondo específico para campos */
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
color: var(--text-primary); color: var(--text-primary); /* Asegura color del texto */
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
border-radius: 8px; border-radius: 8px;
font-family: inherit; font-family: inherit; /* Asegura fuente consistente */
font-size: 1rem; font-size: 1rem; /* Asegura tamaño de fuente */
transition: 0.2s; transition: 0.2s; /* Asegura transiciones */
} }
.form-input:focus { .modal-content .form-input:focus {
outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 10px rgba(139, 92, 246, 0.4); /* Usar el color acento */ box-shadow: 0 0 10px var(--accent-glow);
outline: none; /* Agregado */
} }
.notes-textarea {
resize: vertical;
min-height: 100px;
}
/* Select Styling */
.modal-content select.form-input {
/* Reiniciar la apariencia por defecto para selects, si es necesario */
-webkit-appearance: menulist;
appearance: menulist;
}
/* Checkbox Styling */
.form-checkbox {
width: 18px;
height: 18px;
border: 1px solid rgba(255,255,255,0.2);
background: var(--bg-base);
border-radius: 4px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.form-checkbox:checked {
background: var(--accent);
border-color: var(--accent);
}
.form-checkbox:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
}
/* ACCIONES (Barra inferior pegajosa) */
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 0;
justify-content: flex-end;
flex-shrink: 0;
padding: 1rem 2rem;
border-top: 1px solid rgba(255,255,255,0.05);
background: var(--bg-amoled); /* Fondo para la barra sticky */
position: sticky;
bottom: 0;
z-index: 10;
} }
.btn-modal-primary, .btn-modal-secondary, .btn-modal-danger { /* Botones con nuevas clases para el modal */
.btn-primary, .btn-secondary, .btn-danger {
padding: 0.8rem 1.5rem; padding: 0.8rem 1.5rem;
border-radius: 999px; border-radius: 999px;
font-weight: 700; font-weight: 700;
font-size: 0.95rem; font-size: 0.95rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, opacity 0.2s; transition: transform 0.2s, background 0.2s;
flex: 1; flex: none;
} }
.btn-modal-primary { /* Re-estilizar btn-primary/secondary para el contexto del modal, pero manteniendo la base existente */
/* El .btn-primary global en book.css es blanco/negro, aquí es el color de acento/blanco para el modal */
.modal-actions .btn-primary { /* Asegura que el botón primario del modal use el color de acento */
background: var(--accent); background: var(--accent);
color: white; color: white;
} }
.btn-modal-primary:hover { .modal-actions .btn-primary:hover {
transform: scale(1.05); transform: scale(1.05);
/* No hace falta redefinir el hover */
} }
.btn-modal-secondary { .modal-actions .btn-secondary { /* Asegura que el botón secundario del modal use los estilos de blur */
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
color: white; color: white;
border: 1px solid rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.2);
} }
.btn-modal-secondary:hover { .modal-actions .btn-secondary:hover {
background: rgba(255,255,255,0.15); background: rgba(255,255,255,0.15); /* Cambiado para ser coherente con list.css */
} }
.btn-modal-danger { .btn-danger {
background: #ef4444; /* Usar el color danger */ background: var(--danger);
color: white; color: white;
margin-right: auto; /* Para alinear a la izquierda */
} }
.btn-modal-danger:hover { .btn-danger:hover {
opacity: 0.9; opacity: 0.9;
} }
/* --- Media Queries (Ajuste del Modal) --- */
@media (max-width: 900px) {
.modal-content {
max-width: 95%;
}
.modal-fields-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-group.notes-group {
grid-column: 1 / -1;
}
.form-group.checkbox-group {
grid-column: 1 / -1;
align-self: auto;
justify-content: flex-start;
}
.modal-actions {
padding: 1rem 1.5rem;
}
.modal-title, .modal-body {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (max-width: 550px) {
.modal-content {
margin: 0.5rem;
width: auto;
}
.modal-fields-grid {
grid-template-columns: 1fr;
gap: 1rem;
padding-bottom: 0;
}
.date-group {
flex-direction: column;
gap: 1rem;
}
.form-group.notes-group,
.form-group.checkbox-group {
grid-column: auto;
}
.modal-actions {
flex-direction: column;
align-items: stretch;
}
.btn-danger {
margin-right: 0;
order: 3;
}
.btn-secondary {
order: 2;
}
.btn-primary {
order: 1;
}
}

View File

@@ -11,11 +11,14 @@
--nav-height: 80px; --nav-height: 80px;
--danger: #ef4444; --danger: #ef4444;
--success: #22c55e; --success: #22c55e;
--bg-amoled: #0a0a0a;
--bg-field: #0e0e0f;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body {
margin: 0; /* Aseguramos que no haya margen en el body */
background-color: var(--bg-base); background-color: var(--bg-base);
color: var(--text-primary); color: var(--text-primary);
font-family: 'Inter', system-ui, sans-serif; font-family: 'Inter', system-ui, sans-serif;
@@ -134,6 +137,7 @@ body {
.header-section { .header-section {
margin-bottom: 3rem; margin-bottom: 3rem;
margin-top: 3rem;
} }
.page-title { .page-title {
@@ -161,12 +165,12 @@ body {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
transition: transform 0.3s, box-shadow 0.3s; transition: transform 0.3s, box-shadow 0.3s;
box-shadow: 0 10px 30px rgba(0,0,0,0.3); box-shadow: 0 5px 20px rgba(0,0,0,0.2);
} }
.stat-card:hover { .stat-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
box-shadow: 0 10px 40px var(--accent-glow); box-shadow: 0 15px 35px var(--accent-glow);
} }
.stat-value { .stat-value {
@@ -181,15 +185,17 @@ body {
font-weight: 600; font-weight: 600;
} }
/* --- Filtros mejorados --- */
.filters-section { .filters-section {
display: flex; display: flex;
gap: 1rem; gap: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
padding: 1.5rem; padding: 1.5rem;
background: var(--bg-surface); background: var(--bg-surface);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.05);
flex-wrap: wrap; flex-wrap: wrap;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
} }
.filter-group { .filter-group {
@@ -201,11 +207,11 @@ body {
} }
.filter-group label { .filter-group label {
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 600; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 1px;
} }
.filter-select { .filter-select {
@@ -217,6 +223,13 @@ body {
font-family: inherit; font-family: inherit;
cursor: pointer; cursor: pointer;
transition: 0.2s; transition: 0.2s;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%23a1a1aa'%3E%3Cpath fill-rule='evenodd' 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' clip-rule='evenodd'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.7rem center;
background-size: 1.2em;
padding-right: 2.5rem;
} }
.filter-select:hover { .filter-select:hover {
@@ -299,46 +312,52 @@ 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(250px, 1fr)); gap: 2rem;
gap: 1.5rem;
} }
.list-grid.list-view { .list-grid.list-view {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1rem;
} }
.list-item { .list-item {
background: var(--bg-surface); background: var(--bg-surface-hover);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.08);
border-radius: var(--radius-md); border-radius: var(--radius-md);
overflow: hidden; overflow: hidden;
transition: transform 0.3s, border-color 0.3s, box-shadow 0.3s; transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative; position: relative;
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
} }
.list-item:hover { .list-item:hover {
transform: translateY(-5px); transform: translateY(-8px);
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 10px 20px rgba(0,0,0,0.5); box-shadow: 0 15px 30px var(--accent-glow);
} }
.list-grid.list-view .list-item { .list-grid.list-view .list-item {
flex-direction: row; flex-direction: row;
align-items: center;
padding-right: 1rem;
transition: all 0.3s ease;
} }
.list-grid.list-view .list-item:hover { .list-grid.list-view .list-item:hover {
transform: translateX(5px); transform: none;
box-shadow: 0 4px 20px var(--accent-glow);
} }
.item-poster-link { .item-poster-link {
display: block; display: block;
cursor: pointer; cursor: pointer;
flex-shrink: 0;
} }
.item-poster { .item-poster {
@@ -349,48 +368,60 @@ body {
} }
.list-grid.list-view .item-poster { .list-grid.list-view .item-poster {
width: 120px; width: 100px;
height: 180px; height: 150px;
aspect-ratio: auto; aspect-ratio: auto;
border-radius: 8px;
margin: 1rem;
} }
.item-content { .item-content {
padding: 1rem; padding: 1.2rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1; flex-grow: 1;
}
.list-grid.list-view .item-content {
justify-content: space-between; justify-content: space-between;
} }
.list-grid.list-view .item-content {
padding: 1rem 0;
flex-direction: row;
align-items: center;
}
.list-grid.list-view .item-content > div:first-child {
flex-basis: 75%;
}
.item-title { .item-title {
font-size: 1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 800;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: white;
} }
.list-grid.list-view .item-title { .list-grid.list-view .item-title {
font-size: 1.3rem; font-size: 1.3rem;
white-space: normal; white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
max-width: 400px;
} }
.item-meta { .item-meta {
display: flex; display: flex;
gap: 0.5rem; gap: 0.75rem;
margin-bottom: 0.8rem; margin-bottom: 0.8rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
.meta-pill { .meta-pill {
font-size: 0.75rem; font-size: 0.7rem;
padding: 0.3rem 0.7rem; padding: 0.25rem 0.6rem;
border-radius: 6px; border-radius: 999px;
font-weight: 600; font-weight: 700;
white-space: nowrap; white-space: nowrap;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -413,12 +444,26 @@ body {
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
} }
.repeat-pill {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.3);
text-transform: none;
}
.private-pill {
background: rgba(251, 191, 36, 0.15);
color: #facc15;
border: 1px solid rgba(251, 191, 36, 0.3);
text-transform: none;
}
.progress-bar-container { .progress-bar-container {
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.08);
border-radius: 999px; border-radius: 999px;
height: 8px; height: 10px;
overflow: hidden; overflow: hidden;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
} }
.progress-bar { .progress-bar {
@@ -434,6 +479,7 @@ body {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
font-weight: 500;
} }
.score-badge { .score-badge {
@@ -441,46 +487,86 @@ body {
align-items: center; align-items: center;
gap: 0.3rem; gap: 0.3rem;
font-weight: 700; font-weight: 700;
color: var(--success); color: #facc15;
background: rgba(250, 204, 21, 0.1);
padding: 0.1rem 0.5rem;
border-radius: 4px;
} }
.edit-btn-card { /* --- Botón de edición flotante --- */
background: var(--accent); .edit-icon-btn {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 50;
background: rgba(18, 18, 21, 0.9);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white; color: white;
padding: 0.5rem 1rem; width: 40px;
border-radius: 999px; height: 40px;
font-weight: 700; border-radius: 50%;
border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, background 0.2s; display: flex;
margin-top: 1rem; align-items: center;
} justify-content: center;
.edit-btn-card:hover { opacity: 0;
background: #7c3aed; transition: opacity 0.3s, transform 0.2s, background 0.2s;
transform: scale(1.03);
} }
.list-item:hover .edit-icon-btn {
opacity: 1;
transform: scale(1.05);
}
.edit-icon-btn:hover {
background: var(--accent);
border-color: var(--accent);
}
.list-grid.list-view .edit-icon-btn {
position: relative;
top: auto;
right: auto;
margin-left: auto;
opacity: 1;
transform: none;
background: var(--bg-surface);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.list-grid.list-view .list-item:hover .edit-icon-btn {
opacity: 1;
background: var(--accent);
border-color: var(--accent);
transform: none;
}
/* --- Modal de Edición Mejorado (Estilo Anilist + AMOLED) --- */
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0,0,0,0.8); background: rgba(0,0,0,0.9);
backdrop-filter: blur(8px); backdrop-filter: blur(10px);
z-index: 2000; z-index: 2000;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 2rem; padding: 1rem;
} }
.modal-content { .modal-content {
background: var(--bg-surface); background: var(--bg-amoled);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
max-width: 500px; max-width: 900px;
width: 100%; width: 95%;
padding: 2rem; padding: 0;
position: relative; position: relative;
animation: modalSlideUp 0.3s ease; animation: modalSlideUp 0.3s ease;
box-shadow: 0 20px 50px rgba(0,0,0,0.8);
max-height: 90vh;
display: flex;
flex-direction: column;
} }
@keyframes modalSlideUp { @keyframes modalSlideUp {
@@ -510,6 +596,7 @@ body {
justify-content: center; justify-content: center;
font-size: 1.2rem; font-size: 1.2rem;
transition: 0.2s; transition: 0.2s;
z-index: 2001;
} }
.modal-close:hover { .modal-close:hover {
@@ -519,14 +606,27 @@ body {
.modal-title { .modal-title {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 900; font-weight: 800;
margin-bottom: 1.5rem; padding: 1.5rem 2rem 0.5rem;
margin-bottom: 0;
color: var(--text-primary);
border-bottom: 1px solid rgba(255,255,255,0.05);
} }
.modal-body { .modal-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; overflow-y: auto;
padding: 0 2rem;
flex-grow: 1;
}
/* GRUPO PRINCIPAL DE CAMPOS */
.modal-fields-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem 2rem;
padding: 1.5rem 0;
} }
.form-group { .form-group {
@@ -535,14 +635,30 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
/* Column Span Overrides */
.form-group.notes-group {
grid-column: 1 / span 2;
}
.form-group.checkbox-group {
grid-column: 3 / 4;
align-self: flex-end;
margin-bottom: 0.5rem;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label { .form-group label {
font-size: 0.9rem; font-size: 0.8rem;
font-weight: 600; font-weight: 700;
color: var(--text-secondary); color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
} }
.form-input { .form-input {
background: var(--bg-base); background: var(--bg-field);
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
color: var(--text-primary); color: var(--text-primary);
padding: 0.8rem 1rem; padding: 0.8rem 1rem;
@@ -558,10 +674,73 @@ body {
box-shadow: 0 0 10px var(--accent-glow); box-shadow: 0 0 10px var(--accent-glow);
} }
.notes-textarea {
resize: vertical;
min-height: 100px;
}
.date-group {
display: flex;
gap: 1rem;
}
/* CLAVE: Hace que la etiqueta de fecha esté encima del input */
.date-input-pair {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checkbox-group {
flex-direction: row;
align-items: center;
gap: 1rem;
}
.form-checkbox {
width: 18px;
height: 18px;
border: 1px solid rgba(255,255,255,0.2);
background: var(--bg-base);
border-radius: 4px;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
position: relative;
transition: all 0.2s;
flex-shrink: 0;
}
.form-checkbox:checked {
background: var(--accent);
border-color: var(--accent);
}
.form-checkbox:checked::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 14px;
}
/* ACCIONES (Barra inferior pegajosa) */
.modal-actions { .modal-actions {
display: flex; display: flex;
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 0;
justify-content: flex-end;
flex-shrink: 0;
padding: 1rem 2rem;
border-top: 1px solid rgba(255,255,255,0.05);
background: var(--bg-amoled);
position: sticky;
bottom: 0;
z-index: 10;
} }
.btn-primary, .btn-secondary, .btn-danger { .btn-primary, .btn-secondary, .btn-danger {
@@ -571,8 +750,8 @@ body {
font-size: 0.95rem; font-size: 0.95rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
transition: transform 0.2s, opacity 0.2s; transition: transform 0.2s, background 0.2s;
flex: 1; flex: none;
} }
.btn-primary { .btn-primary {
@@ -597,6 +776,7 @@ body {
.btn-danger { .btn-danger {
background: var(--danger); background: var(--danger);
color: white; color: white;
margin-right: auto;
} }
.btn-danger:hover { .btn-danger:hover {
@@ -604,7 +784,6 @@ body {
} }
.modal-overlay { .modal-overlay {
display: none; display: none;
opacity: 0; opacity: 0;
} }
@@ -613,65 +792,90 @@ body {
opacity: 1; opacity: 1;
} }
@media (max-width: 768px) { /* --- Media Queries (Responsive) --- */
.navbar { @media (max-width: 900px) {
padding: 0 1.5rem; .modal-content {
max-width: 95%;
} }
.modal-fields-grid {
.container {
padding: 2rem 1.5rem;
}
.page-title {
font-size: 2rem;
}
.stats-row {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.form-group.notes-group {
.filters-section { grid-column: 1 / -1;
flex-direction: column; }
.form-group.checkbox-group {
grid-column: 1 / -1;
align-self: auto;
}
.modal-actions {
padding: 1rem 1.5rem;
}
.modal-title, .modal-body {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
} }
@media (max-width: 550px) {
/* Layout de lista (card view) */
.list-grid { .list-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem;
} }
.list-grid.list-view .list-item {
flex-direction: column;
align-items: flex-start;
padding-right: 0;
} }
.list-grid.list-view .item-poster {
.edit-icon-btn { width: 100%;
height: auto;
margin: 0;
border-radius: 0;
aspect-ratio: 16/9;
}
.list-grid.list-view .item-content {
flex-direction: column;
padding: 1rem;
}
.list-grid.list-view .item-content > div:first-child {
flex-basis: auto;
}
.list-grid.list-view .edit-icon-btn {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
right: 1rem; right: 1rem;
z-index: 50;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(5px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s, background 0.2s;
}
.list-item:hover .edit-icon-btn {
opacity: 1; opacity: 1;
background: rgba(18, 18, 21, 0.8);
} }
.edit-icon-btn:hover { /* Modal en móvil */
background: var(--accent); .modal-content {
border-color: var(--accent); margin: 0.5rem;
width: auto;
}
.modal-fields-grid {
grid-template-columns: 1fr;
gap: 1rem;
padding-bottom: 0;
}
.form-group.notes-group,
.form-group.checkbox-group {
grid-column: auto;
}
.modal-actions {
flex-direction: column;
align-items: stretch;
}
.btn-danger {
margin-right: 0;
order: 3;
}
.btn-secondary {
order: 2;
}
.btn-primary {
order: 1;
}
} }
.edit-btn-card { .edit-btn-card {

View File

@@ -40,7 +40,6 @@
<button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button> <button class="nav-button" onclick="window.location.href='/marketplace'">Marketplace</button>
</div> </div>
<!-- Mejorado el contenedor de usuario con dropdown más completo -->
<div class="nav-right"> <div class="nav-right">
<div class="search-wrapper" style="visibility: hidden;"> <div class="search-wrapper" style="visibility: hidden;">
<svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="search-icon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
@@ -182,7 +181,7 @@
</svg> </svg>
<h2>Your list is empty</h2> <h2>Your list is empty</h2>
<p>Start adding anime to track your progress</p> <p>Start adding anime to track your progress</p>
<button class="btn-primary" onclick="window.location.href='/'">Browse Anime</button> <button class="btn-primary" onclick="window.location.href='/anime'">Browse Anime</button>
</div> </div>
<div id="list-container" class="list-grid"></div> <div id="list-container" class="list-grid"></div>
@@ -194,6 +193,8 @@
<h2 class="modal-title">Edit Entry</h2> <h2 class="modal-title">Edit Entry</h2>
<div class="modal-body"> <div class="modal-body">
<div class="modal-fields-grid">
<div class="form-group"> <div class="form-group">
<label>Status</label> <label>Status</label>
<select id="edit-status" class="form-input"> <select id="edit-status" class="form-input">
@@ -215,6 +216,37 @@
<input type="number" id="edit-score" class="form-input" min="0" max="10" step="0.1"> <input type="number" id="edit-score" class="form-input" min="0" max="10" step="0.1">
</div> </div>
<div class="form-group full-width">
<div class="date-group">
<div class="date-input-pair">
<label for="edit-start-date">Start Date</label>
<input type="date" id="edit-start-date" class="form-input">
</div>
<div class="date-input-pair">
<label for="edit-end-date">End Date</label>
<input type="date" id="edit-end-date" class="form-input">
</div>
</div>
</div>
<div class="form-group">
<label for="edit-repeat-count">Repeat Count</label>
<input type="number" id="edit-repeat-count" class="form-input" min="0">
</div>
<div class="form-group notes-group">
<label for="edit-notes">Notes</label>
<textarea id="edit-notes" class="form-input notes-textarea" rows="4" placeholder="Personal notes..."></textarea>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="edit-is-private" class="form-checkbox">
<label for="edit-is-private">Mark as Private</label>
</div>
</div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn-secondary" onclick="closeEditModal()">Cancel</button> <button class="btn-secondary" onclick="closeEditModal()">Cancel</button>
<button class="btn-danger" onclick="deleteEntry()">Delete</button> <button class="btn-danger" onclick="deleteEntry()">Delete</button>
@@ -222,7 +254,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div id="updateToast" class="hidden"> <div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p> <p>Update available: <span id="latestVersionDisplay">v1.x</span></p>