288 lines
14 KiB
HTML
288 lines
14 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Pandektes Tech Challenge</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<style>
|
|
body {
|
|
background-image: radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.05) 1px, transparent 0);
|
|
background-size: 40px 40px;
|
|
}
|
|
|
|
.no-scrollbar::-webkit-scrollbar {
|
|
width: 8px;
|
|
}
|
|
|
|
.no-scrollbar::-webkit-scrollbar-thumb {
|
|
background: rgba(255, 255, 255, 0.1);
|
|
border-radius: 999px;
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body class="bg-slate-900 text-slate-50 min-h-screen flex items-center justify-center m-0">
|
|
<div
|
|
class="bg-slate-800/70 backdrop-blur-xl border border-white/10 rounded-3xl p-8 md:p-12 w-full max-w-xl text-center shadow-2xl">
|
|
<h1 class="text-3xl font-bold mb-2">Pandektes Tech Challenge</h1>
|
|
<p class="text-slate-400 mb-8">PDF/HTML metadata extraction</p>
|
|
|
|
<div class="mb-8">
|
|
<input type="file" id="file-input" accept=".pdf,.html"
|
|
class="block w-full text-sm text-slate-400 file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-sm file:font-semibold file:bg-indigo-500/10 file:text-indigo-400 hover:file:bg-indigo-500/20 cursor-pointer transition-colors" />
|
|
</div>
|
|
|
|
<button id="upload-btn" onclick="handleUpload()"
|
|
class="bg-indigo-500 hover:bg-indigo-600 text-white font-semibold py-4 px-8 rounded-xl w-full transition-all disabled:bg-slate-700 disabled:cursor-not-allowed shadow-lg shadow-indigo-500/20">
|
|
Upload and Extract
|
|
</button>
|
|
|
|
<div class="mt-8 border-t border-white/5 pt-8">
|
|
<div class="text-[10px] uppercase tracking-widest text-slate-500 font-bold mb-3">Lookup Previous</div>
|
|
<div class="flex space-x-2">
|
|
<input id="search-input" placeholder="ID or Case Number"
|
|
class="bg-black/20 border border-white/10 rounded-xl px-4 py-2 text-sm grow focus:outline-none focus:border-indigo-500 transition-colors" />
|
|
<button onclick="handleSearch()"
|
|
class="bg-slate-700 hover:bg-slate-600 px-4 py-2 rounded-xl text-sm transition-colors grow-0 whitespace-nowrap">Find
|
|
Case</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="result-box"
|
|
class="mt-8 hidden text-left bg-indigo-500/5 rounded-2xl border border-indigo-500/10 overflow-hidden shadow-inner">
|
|
<div class="flex items-center justify-between px-6 py-4 bg-white/5 border-b border-white/5">
|
|
<div id="result-status" class="text-sm font-bold flex items-center"></div>
|
|
<div id="result-actions"></div>
|
|
</div>
|
|
<pre id="result-data"
|
|
class="p-6 text-[11px] font-mono max-h-96 overflow-y-auto text-slate-400 no-scrollbar"></pre>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tray-view"
|
|
class="fixed bottom-6 right-6 flex flex-col-reverse space-y-reverse space-y-4 z-50 w-80 max-h-[80vh] overflow-y-auto no-scrollbar">
|
|
</div>
|
|
|
|
<template id="job-template">
|
|
<div
|
|
class="bg-slate-800/95 backdrop-blur-xl border border-indigo-500/30 rounded-2xl shadow-2xl overflow-hidden ring-1 ring-white/5 animate-in fade-in slide-in-from-right-4">
|
|
<div class="bg-slate-700/50 px-4 py-2 flex items-center justify-between border-b border-white/5">
|
|
<div class="flex items-center space-x-2 truncate">
|
|
<div class="status-pulse w-2 h-2 rounded-full bg-green-500 animate-pulse shrink-0"></div>
|
|
<span
|
|
class="filename-display text-[10px] uppercase tracking-widest text-slate-300 font-bold truncate"></span>
|
|
</div>
|
|
<button class="remove-btn text-slate-500 hover:text-white shrink-0">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div
|
|
class="terminal-view p-3 text-[10px] font-mono h-32 overflow-y-auto bg-black/40 text-indigo-300 no-scrollbar">
|
|
<div class="logs-container space-y-1">
|
|
<div class="text-green-500/70">✔ Registered...</div>
|
|
</div>
|
|
</div>
|
|
<div class="footer-actions p-2 border-t border-white/5 bg-slate-900/50 hidden">
|
|
<button
|
|
class="view-result-btn w-full py-1.5 text-[10px] font-bold uppercase bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg transition-colors">View
|
|
Data</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
const activeJobs = {};
|
|
|
|
async function fetchGraphQL(query, variables = {}, isFileUpload = false) {
|
|
const options = { method: 'POST' };
|
|
|
|
if (isFileUpload) {
|
|
const formData = new FormData();
|
|
formData.append('operations', JSON.stringify({ query, variables: { file: null } }));
|
|
formData.append('map', JSON.stringify({ '0': ['variables.file'] }));
|
|
formData.append('0', variables.file);
|
|
options.body = formData;
|
|
} else {
|
|
options.headers = { 'Content-Type': 'application/json' };
|
|
options.body = JSON.stringify({ query, variables });
|
|
}
|
|
|
|
const response = await fetch('/graphql', options);
|
|
const result = await response.json();
|
|
|
|
if (result.errors) throw new Error(result.errors[0].message);
|
|
return result.data;
|
|
}
|
|
|
|
async function handleUpload() {
|
|
const fileInput = document.getElementById('file-input');
|
|
const uploadBtn = document.getElementById('upload-btn');
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
alert('Please select a file first.');
|
|
return;
|
|
}
|
|
|
|
const originalText = uploadBtn.innerText;
|
|
uploadBtn.disabled = true;
|
|
uploadBtn.innerText = 'Queueing...';
|
|
|
|
const query = `mutation($file: Upload!) { uploadCase(file: $file) { id } }`;
|
|
|
|
try {
|
|
const data = await fetchGraphQL(query, { file }, true);
|
|
const jobId = data.uploadCase.id;
|
|
|
|
activeJobs[jobId] = { status: 'PENDING', logCount: 0, caseData: null, filename: file.name };
|
|
createJobTerminal(jobId, file.name);
|
|
|
|
fileInput.value = '';
|
|
uploadBtn.innerText = 'Upload Another';
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
uploadBtn.innerText = originalText;
|
|
} finally {
|
|
uploadBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function createJobTerminal(jobId, filename) {
|
|
const template = document.getElementById('job-template');
|
|
const clone = template.content.cloneNode(true);
|
|
const container = clone.querySelector('div');
|
|
|
|
container.id = `job-${jobId}`;
|
|
clone.querySelector('.filename-display').textContent = `Worker ${jobId}`;
|
|
clone.querySelector('.status-pulse').id = `pulse-${jobId}`;
|
|
clone.querySelector('.terminal-view').id = `terminal-${jobId}`;
|
|
clone.querySelector('.logs-container').id = `logs-${jobId}`;
|
|
clone.querySelector('.footer-actions').id = `footer-${jobId}`;
|
|
|
|
clone.querySelector('.remove-btn').onclick = () => document.getElementById(`job-${jobId}`).remove();
|
|
clone.querySelector('.view-result-btn').onclick = () => renderResultView(jobId);
|
|
|
|
document.getElementById('tray-view').appendChild(clone);
|
|
}
|
|
|
|
function renderResultView(jobId) {
|
|
const job = activeJobs[jobId];
|
|
if (!job || !job.caseData) return;
|
|
|
|
const caseData = job.caseData;
|
|
const resultBox = document.getElementById('result-box');
|
|
const resultStatus = document.getElementById('result-status');
|
|
const resultActions = document.getElementById('result-actions');
|
|
const resultData = document.getElementById('result-data');
|
|
|
|
resultBox.classList.remove('hidden');
|
|
|
|
if (caseData.status === 'COMPLETED') {
|
|
resultStatus.innerHTML = `<span class="text-green-400">✔ ${job.filename}</span>`;
|
|
resultActions.innerHTML = `<a href="${caseData.downloadUrl}" target="_blank" class="px-3 py-1.5 bg-indigo-500 hover:bg-indigo-600 text-white text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all shadow-lg shadow-indigo-500/20">View File</a>`;
|
|
|
|
const displayData = JSON.stringify(caseData, (key, value) => ['logs', 'downloadUrl'].includes(key) ? undefined : value, 2);
|
|
resultData.textContent = displayData;
|
|
} else {
|
|
resultStatus.innerHTML = `<span class="text-red-400">❌ Error: ${job.filename}</span>`;
|
|
resultActions.innerHTML = '';
|
|
resultData.textContent = caseData.processingError || 'Unknown error occurred during processing.';
|
|
}
|
|
|
|
resultBox.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
async function handleSearch() {
|
|
const searchInput = document.getElementById('search-input').value;
|
|
if (!searchInput) return;
|
|
|
|
const resultBox = document.getElementById('result-box');
|
|
const resultStatus = document.getElementById('result-status');
|
|
const resultActions = document.getElementById('result-actions');
|
|
const resultData = document.getElementById('result-data');
|
|
|
|
resultBox.classList.remove('hidden');
|
|
resultStatus.innerHTML = `<span class="text-slate-400 animate-pulse">🔍 Searching...</span>`;
|
|
resultActions.innerHTML = '';
|
|
resultData.textContent = '';
|
|
|
|
const query = `
|
|
query($searchTerm: String!) {
|
|
caseLaw(id: $searchTerm, caseNumber: $searchTerm) {
|
|
id status title caseNumber summary downloadUrl
|
|
}
|
|
}`;
|
|
|
|
try {
|
|
const data = await fetchGraphQL(query, { searchTerm: searchInput });
|
|
const caseLaw = data?.caseLaw;
|
|
|
|
if (caseLaw) {
|
|
resultStatus.innerHTML = `<span class="text-indigo-400">🔎 Match: ${caseLaw.caseNumber || caseLaw.id.slice(0, 8)}</span>`;
|
|
resultActions.innerHTML = `<a href="${caseLaw.downloadUrl}" target="_blank" class="px-3 py-1.5 bg-indigo-500 hover:bg-indigo-600 text-white text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all">Open File</a>`;
|
|
resultData.textContent = JSON.stringify(caseLaw, (key, value) => key === 'downloadUrl' ? undefined : value, 2);
|
|
} else {
|
|
resultStatus.innerHTML = `<span class="text-slate-500">❌ Not Found</span>`;
|
|
resultData.textContent = 'No archive match.';
|
|
}
|
|
} catch (error) {
|
|
resultStatus.innerHTML = `<span class="text-red-400">❌ Error</span>`;
|
|
resultData.textContent = error.message;
|
|
}
|
|
}
|
|
|
|
setInterval(async () => {
|
|
const pendingIds = Object.keys(activeJobs).filter(id => !['COMPLETED', 'FAILED'].includes(activeJobs[id].status));
|
|
|
|
for (const jobId of pendingIds) {
|
|
const query = `
|
|
query($id: String!) {
|
|
caseLaw(id: $id) {
|
|
status logs title decisionType decisionDate court caseNumber summary downloadUrl processingError
|
|
}
|
|
}`;
|
|
|
|
try {
|
|
const data = await fetchGraphQL(query, { id: jobId });
|
|
const caseLaw = data.caseLaw;
|
|
const job = activeJobs[jobId];
|
|
|
|
job.status = caseLaw.status;
|
|
job.caseData = caseLaw;
|
|
|
|
const logContainer = document.getElementById(`logs-${jobId}`);
|
|
const terminalView = document.getElementById(`terminal-${jobId}`);
|
|
|
|
if (caseLaw.logs && caseLaw.logs.length > job.logCount) {
|
|
const newLogs = caseLaw.logs.slice(job.logCount);
|
|
newLogs.forEach(logText => {
|
|
logContainer.insertAdjacentHTML('beforeend', `<div class="flex"><span class="text-indigo-500/50 mr-2 opacity-50">➜</span>${logText}</div>`);
|
|
});
|
|
job.logCount = caseLaw.logs.length;
|
|
terminalView.scrollTop = terminalView.scrollHeight;
|
|
}
|
|
|
|
if (['COMPLETED', 'FAILED'].includes(caseLaw.status)) {
|
|
const statusMessage = caseLaw.status === 'COMPLETED' ? '✔ FINISHED' : '✘ FAILED';
|
|
logContainer.insertAdjacentHTML('beforeend', `<div class="mt-2 pt-2 border-t border-white/5 text-white font-bold">${statusMessage}</div>`);
|
|
|
|
document.getElementById(`footer-${jobId}`).classList.remove('hidden');
|
|
terminalView.scrollTop = terminalView.scrollHeight;
|
|
|
|
const pulseEl = document.getElementById(`pulse-${jobId}`);
|
|
pulseEl.classList.remove('bg-green-500', 'animate-pulse');
|
|
pulseEl.classList.add(caseLaw.status === 'COMPLETED' ? 'bg-indigo-500' : 'bg-red-500');
|
|
}
|
|
} catch (error) {
|
|
console.error(`Poll failed for job ${jobId}:`, error);
|
|
}
|
|
}
|
|
}, 1000);
|
|
</script>
|
|
</body>
|
|
|
|
</html> |