added manual match modal for local library

This commit is contained in:
2026-01-03 20:32:27 +01:00
parent 148beb6c5a
commit c5a96d59ff
10 changed files with 398 additions and 34 deletions

View File

@@ -91,8 +91,7 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
const { type } = request.params;
const entries = await service.getEntriesByType(type);
return entries;
return await service.getEntriesByType(type);
} catch {
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
}

View File

@@ -186,8 +186,22 @@ export async function performLibraryScan(mode: 'full' | 'incremental' = 'increme
}
export async function getEntriesByType(type: string) {
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
const sql = `
SELECT local_entries.*, COUNT(local_files.id) as file_count
FROM local_entries
LEFT JOIN local_files ON local_entries.id = local_files.entry_id
WHERE local_entries.type = ?
GROUP BY local_entries.id
`;
const entries = await queryAll(sql, [type], 'local_library');
return await Promise.all(entries.map(async (entry: any) => {
const metadata = await resolveEntryMetadata(entry, type);
return {
...metadata,
path: entry.path,
files: entry.file_count
};
}));
}
export async function getEntryDetails(type: string, id: string) {

View File

@@ -387,6 +387,7 @@ const DashboardApp = {
},
Library: {
tempMatchContext: null,
loadStats: async function() {
const types = ['anime', 'manga', 'novels'];
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
@@ -446,7 +447,7 @@ const DashboardApp = {
const meta = entry.metadata || {};
let poster = meta.coverImage?.large || '/public/assets/placeholder.svg';
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.folder_name;
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.path;
if (!isMatched) title = title.replace(/\[.*?\]|\(.*?\)|\.mkv|\.mp4/g, '').trim();
const url = isMatched ? (type === 'anime' ? `/anime/${meta.id}` : `/book/${meta.id}`) : '#';
@@ -459,12 +460,12 @@ const DashboardApp = {
${!isMatched ? `<div class="unmatched-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> UNMATCHED</div>` : ''}
</div>
<div class="item-content">
<h3 class="item-title" title="${entry.folder_name}">${title}</h3>
<h3 class="item-title" title="${entry.path}">${title}</h3>
<div class="item-meta">
<span class="meta-pill type-pill">${entry.files ? entry.files.length : 0} FILES</span>
<span class="meta-pill type-pill">${entry.files} FILES</span>
${isMatched ? '<span class="meta-pill status-pill">MATCHED</span>' : ''}
</div>
<div class="folder-path-tooltip">${entry.folder_name}</div>
<div class="folder-path-tooltip">${entry.path}</div>
</div>
<button class="edit-icon-btn" onclick="DashboardApp.Library.openManualMatch('${entry.id}', '${type}')" title="${isMatched ? 'Rematch' : 'Fix Match'}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
@@ -505,16 +506,67 @@ const DashboardApp = {
},
openManualMatch: function(id, type) {
const newId = prompt("Enter AniList ID to force match:");
if (newId) {
fetch(`${API_BASE}/library/${type}/${id}/match`, {
const item = DashboardApp.State.localLibraryData.find(x => x.id === id);
const pathName = item ? item.path : 'Unknown path';
this.tempMatchContext = { id, type };
document.getElementById('manual-match-path').textContent = pathName;
document.getElementById('manual-match-id').value = '';
const modal = document.getElementById('manual-match-modal');
modal.classList.remove('hidden');
setTimeout(() => document.getElementById('manual-match-id').focus(), 100);
},
closeManualMatch: function() {
document.getElementById('manual-match-modal').classList.add('hidden');
this.tempMatchContext = null;
},
submitManualMatch: async function() {
if (!this.tempMatchContext) return;
const newId = document.getElementById('manual-match-id').value;
if (!newId) {
alert("Please enter a valid ID");
return;
}
const { id, type } = this.tempMatchContext;
const confirmBtn = document.querySelector('#manual-match-modal .btn-primary');
const originalText = confirmBtn.textContent;
confirmBtn.textContent = "Matching...";
confirmBtn.disabled = true;
try {
const res = await fetch(`${API_BASE}/library/${type}/${id}/match`, {
method: 'POST',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'anilist', matched_id: parseInt(newId) })
}).then(res => {
if(res.ok) { alert("Matched! Refreshing..."); this.loadContent(type); }
else { alert("Failed to match."); }
headers: {
...window.AuthUtils.getSimpleAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
source: 'anilist',
matched_id: parseInt(newId)
})
});
if(res.ok) {
this.closeManualMatch();
this.loadContent(type);
} else {
const errData = await res.json();
alert("Failed to match: " + (errData.error || "Unknown error"));
}
} catch (e) {
console.error(e);
alert("Connection error");
} finally {
confirmBtn.textContent = originalText;
confirmBtn.disabled = false;
}
},

View File

@@ -704,4 +704,95 @@
margin-bottom: 1.5rem;
}
.hidden { display: none !important; }
.hidden { display: none !important; }
/* =========================================
8. CUSTOM MODAL (Manual Match)
========================================= */
.custom-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.2s;
}
.custom-modal-overlay.hidden {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.custom-modal-content {
background: var(--color-bg-elevated, #18181b);
border: 1px solid var(--border-medium, rgba(255,255,255,0.1));
width: 100%;
max-width: 500px;
border-radius: 16px;
box-shadow: var(--shadow-lg);
transform: scale(1);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
}
.custom-modal-overlay.hidden .custom-modal-content {
transform: scale(0.95);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 { margin: 0; font-size: 1.25rem; }
.close-modal-btn {
background: transparent; border: none;
color: var(--color-text-muted); font-size: 1.5rem;
cursor: pointer; line-height: 1;
}
.close-modal-btn:hover { color: white; }
.modal-body { padding: 1.5rem; }
.modal-description {
color: var(--color-text-secondary);
font-size: 0.9rem; margin-bottom: 1.5rem;
}
.path-display {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
padding: 0.8rem;
border-radius: 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
color: #e4e4e7;
word-break: break-all;
margin-top: 0.5rem;
max-height: 100px;
overflow-y: auto;
}
.input-group { margin-bottom: 1.5rem; }
.input-group label {
display: block; font-size: 0.85rem;
font-weight: 600; color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
background: rgba(0,0,0,0.2);
display: flex;
justify-content: flex-end;
gap: 1rem;
}

View File

@@ -194,7 +194,33 @@
</div>
</div>
<div id="manual-match-modal" class="custom-modal-overlay hidden">
<div class="custom-modal-content">
<div class="modal-header">
<h3>Fix Match</h3>
<button class="close-modal-btn" onclick="DashboardApp.Library.closeManualMatch()">×</button>
</div>
<div class="modal-body">
<p class="modal-description">Introduce el ID de AniList correcto para asociar este archivo local.</p>
<div class="input-group">
<label>File Path / Folder Name</label>
<div id="manual-match-path" class="path-display"></div>
</div>
<div class="input-group">
<label>AniList ID</label>
<input type="number" id="manual-match-id" class="stream-input" placeholder="Ej: 21 (One Piece)">
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="DashboardApp.Library.closeManualMatch()">Cancel</button>
<button class="btn-primary" onclick="DashboardApp.Library.submitManualMatch()">Confirm Match</button>
</div>
</div>
</div>
<div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">Download</a>

View File

@@ -91,8 +91,7 @@ export async function scanLibrary(request: FastifyRequest<{ Querystring: ScanQue
export async function listEntries(request: FastifyRequest<{ Params: Params }>, reply: FastifyReply) {
try {
const { type } = request.params;
const entries = await service.getEntriesByType(type);
return entries;
return await service.getEntriesByType(type);
} catch {
return reply.status(500).send({ error: 'FAILED_TO_LIST_ENTRIES' });
}

View File

@@ -186,8 +186,22 @@ export async function performLibraryScan(mode: 'full' | 'incremental' = 'increme
}
export async function getEntriesByType(type: string) {
const entries = await queryAll(`SELECT * FROM local_entries WHERE type = ?`, [type], 'local_library');
return await Promise.all(entries.map((entry: any) => resolveEntryMetadata(entry, type)));
const sql = `
SELECT local_entries.*, COUNT(local_files.id) as file_count
FROM local_entries
LEFT JOIN local_files ON local_entries.id = local_files.entry_id
WHERE local_entries.type = ?
GROUP BY local_entries.id
`;
const entries = await queryAll(sql, [type], 'local_library');
return await Promise.all(entries.map(async (entry: any) => {
const metadata = await resolveEntryMetadata(entry, type);
return {
...metadata,
path: entry.path,
files: entry.file_count
};
}));
}
export async function getEntryDetails(type: string, id: string) {

View File

@@ -387,6 +387,7 @@ const DashboardApp = {
},
Library: {
tempMatchContext: null,
loadStats: async function() {
const types = ['anime', 'manga', 'novels'];
const elements = { 'anime': 'local-anime-count', 'manga': 'local-manga-count', 'novels': 'local-novel-count' };
@@ -446,7 +447,7 @@ const DashboardApp = {
const meta = entry.metadata || {};
let poster = meta.coverImage?.large || '/public/assets/placeholder.svg';
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.folder_name;
let title = isMatched ? (meta.title?.english || meta.title?.romaji) : entry.path;
if (!isMatched) title = title.replace(/\[.*?\]|\(.*?\)|\.mkv|\.mp4/g, '').trim();
const url = isMatched ? (type === 'anime' ? `/anime/${meta.id}` : `/book/${meta.id}`) : '#';
@@ -459,12 +460,12 @@ const DashboardApp = {
${!isMatched ? `<div class="unmatched-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg> UNMATCHED</div>` : ''}
</div>
<div class="item-content">
<h3 class="item-title" title="${entry.folder_name}">${title}</h3>
<h3 class="item-title" title="${entry.path}">${title}</h3>
<div class="item-meta">
<span class="meta-pill type-pill">${entry.files ? entry.files.length : 0} FILES</span>
<span class="meta-pill type-pill">${entry.files} FILES</span>
${isMatched ? '<span class="meta-pill status-pill">MATCHED</span>' : ''}
</div>
<div class="folder-path-tooltip">${entry.folder_name}</div>
<div class="folder-path-tooltip">${entry.path}</div>
</div>
<button class="edit-icon-btn" onclick="DashboardApp.Library.openManualMatch('${entry.id}', '${type}')" title="${isMatched ? 'Rematch' : 'Fix Match'}">
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
@@ -505,16 +506,67 @@ const DashboardApp = {
},
openManualMatch: function(id, type) {
const newId = prompt("Enter AniList ID to force match:");
if (newId) {
fetch(`${API_BASE}/library/${type}/${id}/match`, {
const item = DashboardApp.State.localLibraryData.find(x => x.id === id);
const pathName = item ? item.path : 'Unknown path';
this.tempMatchContext = { id, type };
document.getElementById('manual-match-path').textContent = pathName;
document.getElementById('manual-match-id').value = '';
const modal = document.getElementById('manual-match-modal');
modal.classList.remove('hidden');
setTimeout(() => document.getElementById('manual-match-id').focus(), 100);
},
closeManualMatch: function() {
document.getElementById('manual-match-modal').classList.add('hidden');
this.tempMatchContext = null;
},
submitManualMatch: async function() {
if (!this.tempMatchContext) return;
const newId = document.getElementById('manual-match-id').value;
if (!newId) {
alert("Please enter a valid ID");
return;
}
const { id, type } = this.tempMatchContext;
const confirmBtn = document.querySelector('#manual-match-modal .btn-primary');
const originalText = confirmBtn.textContent;
confirmBtn.textContent = "Matching...";
confirmBtn.disabled = true;
try {
const res = await fetch(`${API_BASE}/library/${type}/${id}/match`, {
method: 'POST',
headers: { ...window.AuthUtils.getSimpleAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'anilist', matched_id: parseInt(newId) })
}).then(res => {
if(res.ok) { alert("Matched! Refreshing..."); this.loadContent(type); }
else { alert("Failed to match."); }
headers: {
...window.AuthUtils.getSimpleAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
source: 'anilist',
matched_id: parseInt(newId)
})
});
if(res.ok) {
this.closeManualMatch();
this.loadContent(type);
} else {
const errData = await res.json();
alert("Failed to match: " + (errData.error || "Unknown error"));
}
} catch (e) {
console.error(e);
alert("Connection error");
} finally {
confirmBtn.textContent = originalText;
confirmBtn.disabled = false;
}
},

View File

@@ -704,4 +704,95 @@
margin-bottom: 1.5rem;
}
.hidden { display: none !important; }
.hidden { display: none !important; }
/* =========================================
8. CUSTOM MODAL (Manual Match)
========================================= */
.custom-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transition: opacity 0.2s;
}
.custom-modal-overlay.hidden {
opacity: 0;
pointer-events: none;
visibility: hidden;
}
.custom-modal-content {
background: var(--color-bg-elevated, #18181b);
border: 1px solid var(--border-medium, rgba(255,255,255,0.1));
width: 100%;
max-width: 500px;
border-radius: 16px;
box-shadow: var(--shadow-lg);
transform: scale(1);
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
overflow: hidden;
}
.custom-modal-overlay.hidden .custom-modal-content {
transform: scale(0.95);
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.05);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 { margin: 0; font-size: 1.25rem; }
.close-modal-btn {
background: transparent; border: none;
color: var(--color-text-muted); font-size: 1.5rem;
cursor: pointer; line-height: 1;
}
.close-modal-btn:hover { color: white; }
.modal-body { padding: 1.5rem; }
.modal-description {
color: var(--color-text-secondary);
font-size: 0.9rem; margin-bottom: 1.5rem;
}
.path-display {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
padding: 0.8rem;
border-radius: 6px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.85rem;
color: #e4e4e7;
word-break: break-all;
margin-top: 0.5rem;
max-height: 100px;
overflow-y: auto;
}
.input-group { margin-bottom: 1.5rem; }
.input-group label {
display: block; font-size: 0.85rem;
font-weight: 600; color: var(--color-text-secondary);
margin-bottom: 0.5rem;
}
.modal-footer {
padding: 1rem 1.5rem;
background: rgba(0,0,0,0.2);
display: flex;
justify-content: flex-end;
gap: 1rem;
}

View File

@@ -181,7 +181,33 @@
</div>
</div>
<div id="manual-match-modal" class="custom-modal-overlay hidden">
<div class="custom-modal-content">
<div class="modal-header">
<h3>Fix Match</h3>
<button class="close-modal-btn" onclick="DashboardApp.Library.closeManualMatch()">×</button>
</div>
<div class="modal-body">
<p class="modal-description">Introduce el ID de AniList correcto para asociar este archivo local.</p>
<div class="input-group">
<label>File Path / Folder Name</label>
<div id="manual-match-path" class="path-display"></div>
</div>
<div class="input-group">
<label>AniList ID</label>
<input type="number" id="manual-match-id" class="stream-input" placeholder="Ej: 21 (One Piece)">
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="DashboardApp.Library.closeManualMatch()">Cancel</button>
<button class="btn-primary" onclick="DashboardApp.Library.submitManualMatch()">Confirm Match</button>
</div>
</div>
</div>
<div id="updateToast" class="hidden">
<p>Update available: <span id="latestVersionDisplay">v1.x</span></p>
<a id="downloadButton" href="https://git.waifuboard.app/ItsSkaiya/WaifuBoard/releases" target="_blank">Download</a>