Add document upload to Redact tool

api/extract.php — new endpoint accepting .pdf/.docx/.txt up to 4 MB;
pdftotext for PDFs, ZipArchive+DOMXPath for DOCX, mb_convert_encoding
for TXT; truncates to 32 000 chars to stay within redact limit.

index.php — drop/browse upload zone above the textarea, visible only
in Redact mode.

tools.js — setupUpload(), handleFileUpload(), resetUpload(); drag-and-drop
and file picker both call the extract endpoint then populate the textarea.

tools.css — upload zone, drag-over, file-info, clear button styles.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 06:52:14 +02:00
parent 3c8d7ebc34
commit bbe5307c03
4 changed files with 335 additions and 0 deletions
+90
View File
@@ -873,3 +873,93 @@ p {
overflow-x: auto;
color: #374151;
}
/* ─── Upload zone (Redact tool) ──────────────────────────────────────────── */
.upload-zone {
border: 2px dashed var(--line);
border-radius: 8px;
padding: 18px 14px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
position: relative;
}
.upload-zone:hover {
border-color: var(--teal);
background: #f7fdfb;
}
.upload-zone.is-drag-over {
border-color: var(--teal);
background: var(--soft-teal);
}
#uploadInput {
position: absolute;
width: 0;
height: 0;
opacity: 0;
pointer-events: none;
}
.upload-icon {
display: block;
font-size: 1.8rem;
line-height: 1;
color: var(--teal);
opacity: 0.55;
margin-bottom: 6px;
}
.upload-prompt p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.88rem;
}
.upload-browse {
color: var(--teal);
font-weight: 700;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.upload-hint {
font-size: 0.76rem !important;
opacity: 0.7;
}
.upload-file {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 48px;
}
.upload-filename {
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
word-break: break-all;
}
.upload-clear {
flex-shrink: 0;
background: transparent;
color: var(--muted);
font-size: 1.25rem;
line-height: 1;
padding: 2px 7px;
border-radius: 4px;
border: 1px solid transparent;
}
.upload-clear:hover {
background: var(--soft-coral);
color: var(--coral);
border-color: #f5c6aa;
}
+95
View File
@@ -80,6 +80,12 @@ document.addEventListener('DOMContentLoaded', () => {
traceList: document.querySelector('#traceList'),
healthButton: document.querySelector('#healthButton'),
healthPill: document.querySelector('#healthPill'),
uploadZone: document.querySelector('#uploadZone'),
uploadInput: document.querySelector('#uploadInput'),
uploadPrompt: document.querySelector('#uploadPrompt'),
uploadFileInfo: document.querySelector('#uploadFileInfo'),
uploadFileName: document.querySelector('#uploadFileName'),
uploadClear: document.querySelector('#uploadClear'),
});
els.tabs.forEach((button) => {
@@ -88,6 +94,7 @@ document.addEventListener('DOMContentLoaded', () => {
els.form.addEventListener('submit', runTool);
els.passcodeForm.addEventListener('submit', submitPasscode);
els.healthButton.addEventListener('click', checkHealth);
setupUpload();
setTool(state.activeTool);
if (state.authenticated) {
@@ -114,6 +121,8 @@ function setTool(toolName) {
els.input.placeholder = tool.placeholder;
els.languageControl.classList.toggle('is-hidden', !tool.usesLanguage);
els.redactionControl.classList.toggle('is-hidden', toolName !== 'redact');
els.uploadZone.classList.toggle('is-hidden', toolName !== 'redact');
resetUpload();
els.status.textContent = '';
renderTrace([]);
}
@@ -186,6 +195,92 @@ async function runTool(event) {
}
}
function resetUpload() {
if (!els.uploadInput) return;
els.uploadInput.value = '';
els.uploadPrompt.classList.remove('is-hidden');
els.uploadFileInfo.classList.add('is-hidden');
els.uploadFileName.textContent = '';
els.uploadZone.classList.remove('is-drag-over');
}
function setupUpload() {
els.uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
els.uploadZone.classList.add('is-drag-over');
});
els.uploadZone.addEventListener('dragleave', (e) => {
if (!els.uploadZone.contains(e.relatedTarget)) {
els.uploadZone.classList.remove('is-drag-over');
}
});
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
els.uploadZone.classList.remove('is-drag-over');
const file = e.dataTransfer?.files?.[0];
if (file) handleFileUpload(file);
});
els.uploadZone.addEventListener('click', (e) => {
if (e.target === els.uploadClear || els.uploadClear?.contains(e.target)) return;
if (e.target.tagName === 'LABEL') return;
els.uploadInput.click();
});
els.uploadInput.addEventListener('change', () => {
const file = els.uploadInput.files?.[0];
if (file) handleFileUpload(file);
});
els.uploadClear.addEventListener('click', () => {
resetUpload();
els.status.textContent = '';
});
}
async function handleFileUpload(file) {
const allowed = ['pdf', 'docx', 'txt'];
const ext = file.name.split('.').pop().toLowerCase();
if (!allowed.includes(ext)) {
els.status.textContent = 'Unsupported file type. Use .pdf, .docx, or .txt.';
return;
}
els.status.textContent = `Extracting ${file.name}`;
setBusy(true);
try {
const formData = new FormData();
formData.append('file', file);
const resp = await fetch('api/extract.php', {
method: 'POST',
credentials: 'same-origin',
body: formData,
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.ok) {
throw new Error(data.error?.message || `Extraction failed (HTTP ${resp.status}).`);
}
els.input.value = data.text;
els.uploadFileName.textContent = file.name;
els.uploadPrompt.classList.add('is-hidden');
els.uploadFileInfo.classList.remove('is-hidden');
const note = data.truncated ? ' (truncated to 32000 chars)' : '';
els.status.textContent = `Extracted ${data.chars.toLocaleString()} chars from ${file.name}${note}.`;
} catch (err) {
els.status.textContent = err.message;
resetUpload();
} finally {
setBusy(false);
}
}
async function checkHealth() {
els.healthPill.textContent = 'Checking...';
try {