added manual match modal for local library
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user