Updated html functionality and styling
This commit is contained in:
parent
d2faaf718f
commit
b60f352347
|
|
@ -6,7 +6,21 @@
|
|||
<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">
|
||||
|
|
@ -51,28 +65,223 @@
|
|||
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>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,6 @@ export class CasesResolver {
|
|||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
|
||||
@Query(() => CaseLaw, { name: 'caseLaw', nullable: true })
|
||||
async findOne(
|
||||
@Args('id', { type: () => String, nullable: true }) id?: string,
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ export class CasesService {
|
|||
return caseLaw;
|
||||
}
|
||||
|
||||
|
||||
async findOne(id?: string, caseNumber?: string) {
|
||||
if (!id && !caseNumber) throw new BadRequestException('Provide ID or Case Number');
|
||||
|
||||
|
|
@ -59,7 +58,6 @@ export class CasesService {
|
|||
return caseLaw;
|
||||
}
|
||||
|
||||
|
||||
async findAll(status?: CaseStatus, take = 20, skip = 0) {
|
||||
return this.prisma.caseLaw.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export class ParserService {
|
|||
|
||||
// I read that the most important part of the document for metadata extraction is the
|
||||
// start/end of the document. But I am not a lawyer and uncertain so decided not to
|
||||
// risk it. In the end I just set a hard cap at 500k characters to avoid abuse.
|
||||
// risk it. In the end I just set a hard cap at 500k characters to avoid abuse
|
||||
const maxChars = 500_000;
|
||||
const documentText = text.length > maxChars ? text.substring(0, maxChars) : text;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue