Updated html functionality and styling

This commit is contained in:
GeorgeWebberley 2026-03-01 13:05:49 +01:00
parent d2faaf718f
commit b60f352347
4 changed files with 210 additions and 6 deletions

View file

@ -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>

View file

@ -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,

View file

@ -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,

View file

@ -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;