feat(pdf): adopt Home-page Files-card layout

User wants the PDF page's upload UX to match the Home page
exactly — Files section header + bordered card containing the
file rows AND the "Add more files" button at the bottom, no
visible Streamlit file_uploader competing for attention.

Layout changes mirroring ``src/gui/_home.py``:

- ``st.file_uploader`` is positioned off-screen via CSS
  (``position:absolute;left:-10000px;…``). The underlying
  ``<input type=file>`` stays reachable to JS so the in-card
  "Add more files" button can programmatically click it.
- ``<h2>Files</h2>`` section header with ``N files · X.X MB
  total`` meta on the right, identical markup
  (``dt-files-section-head``).
- Single ``st.container(border=True)`` hosts every file row
  (``✕ | 📄 filename | size``, using ``dt-file-row`` /
  ``dt-file-icon-chip`` / ``dt-file-name`` / ``dt-file-size``
  classes) AND the "Add more files" button (``dt-file-add``)
  at the bottom. All classes are already defined globally in
  ``_legacy.py`` so no new CSS.
- The Add button click is wired to the off-screen uploader's
  ``stFileUploaderDropzoneInput`` via a 30-line iframe script,
  identical to the Home page's pattern. A ``MutationObserver``
  re-wires after Streamlit reruns when the button gets
  re-mounted.

Action buttons (Scan + Clear all) sit BELOW the Files card,
side-by-side in a `[1, 1, 4]` column split with
``use_container_width=True`` so they fill their cells cleanly
without stretching across the whole row. Both buttons are
disabled when no files are uploaded — the empty Files card is
its own affordance for the empty state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 01:34:31 +00:00
parent 34b56b404a
commit dbcf4d4048

View File

@@ -174,19 +174,20 @@ pdf_uploads: dict = st.session_state.setdefault(K_UPLOADS, {})
upload_counter: int = st.session_state.setdefault(K_UPLOAD_COUNTER, 0)
uploader_key = f"pdf_upload_v{upload_counter}"
# Hide the file_uploader's built-in file list (Streamlit shows
# tiny chips with X buttons under its dropzone). We render our own
# Home-style list below, so suppressing the native one leaves a
# single source of truth on screen.
# Mirror the Home-page upload pattern: the Streamlit file_uploader
# is positioned off-screen via CSS (keeps its underlying ``<input
# type=file>`` reachable to JS), and the page renders a Home-style
# bordered file list with an "Add more files" button at the
# bottom. A small iframe-injected script wires that button to
# programmatically click the hidden uploader so the OS file picker
# opens. Same approach as ``_sync_uploader_to_home_uploads`` in
# ``src/gui/_home.py``.
st.markdown(
"""<style>
[data-testid="stFileUploader"] [data-testid="stFileUploaderFile"] {
display: none !important;
}
[data-testid="stFileUploader"] [data-testid="stFileUploaderDeleteBtn"] {
display: none !important;
}
</style>""",
'<style>[data-testid="stFileUploader"] {'
'position:absolute!important;left:-10000px!important;'
'width:1px!important;height:1px!important;overflow:hidden!important;'
'pointer-events:none!important;}</style>',
unsafe_allow_html=True,
)
@@ -195,8 +196,6 @@ def _sync_pdf_uploads() -> None:
"""``on_change`` callback. Adds newly-uploaded files to the
persistent stash. **Add-only** — removal happens through the
custom X buttons + counter bump, NOT through this callback.
That way the widget's hidden native X buttons can't silently
drop files behind the user's back, and we can ignore them.
"""
widget_files = st.session_state.get(uploader_key) or []
for f in widget_files:
@@ -220,81 +219,178 @@ st.file_uploader(
accept_multiple_files=True,
key=uploader_key,
on_change=_sync_pdf_uploads,
label_visibility="collapsed",
help="Drop one or more bank-statement PDFs. Multi-file batches "
"are merged into a single table with a ``source_file`` column.",
)
# ---------------------------------------------------------------------------
# Custom file list (Home-style: one row per file, X to remove)
# Files section (Home-style layout)
# ---------------------------------------------------------------------------
if pdf_uploads:
n = len(pdf_uploads)
total = sum(m["size"] for m in pdf_uploads.values())
word = "file" if n == 1 else "files"
st.markdown(
f"**{n} {word}** · {_format_size(total)} total",
import html as _html
_DOC_SVG = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
'<path d="M14 2v6h6"/>'
'</svg>'
)
_PLUS_SVG = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
'<path d="M12 5v14M5 12h14"/>'
'</svg>'
)
n_files = len(pdf_uploads)
if n_files:
total_bytes = sum(m["size"] for m in pdf_uploads.values())
files_word = "file" if n_files == 1 else "files"
meta_html = (
f'{n_files} {files_word} · '
f'{_html.escape(_format_size(total_bytes))} total'
)
to_remove: str | None = None
with st.container(border=True):
for name, meta in pdf_uploads.items():
digest = hashlib.sha1(
name.encode("utf-8"), usedforsecurity=False,
).hexdigest()[:10]
col_x, col_name, col_size = st.columns([0.55, 8, 1.6])
if col_x.button(
"",
key=f"pdf_rm_{digest}",
help=f"Remove {name}",
type="tertiary",
):
to_remove = name
col_name.markdown(f"📄 **{name}**")
col_size.markdown(
f"<div style='text-align:right;color:#5a5f6e;'>"
f"{_format_size(meta['size'])}</div>",
unsafe_allow_html=True,
)
else:
meta_html = "No files imported yet"
c_scan, c_clear = st.columns([1, 4])
with c_scan:
scan_clicked = st.button("Scan", type="primary")
with c_clear:
if st.button(
"Clear all files",
type="secondary",
help="Removes all uploaded files and the last scan result.",
st.markdown(
'<div class="dt-files-section-head">'
'<h2>Files</h2>'
f'<span class="dt-section-meta">{meta_html}</span>'
'</div>',
unsafe_allow_html=True,
)
# Single bordered card hosting the file rows + the in-card
# "Add more files" button at the bottom, matching the Home page.
# Two-phase remove pattern: walk all rows once, accumulate
# ``to_remove`` if any X was clicked, then mutate state + rerun
# ONCE after the loop so Streamlit doesn't see a half-mutated
# dict mid-render.
to_remove: str | None = None
with st.container(border=True):
for name, meta in pdf_uploads.items():
digest = hashlib.sha1(
name.encode("utf-8"), usedforsecurity=False,
).hexdigest()[:10]
col_x, col_name, col_size = st.columns([0.55, 8, 1.6])
if col_x.button(
"",
key=f"pdf_rm_{digest}",
help=f"Remove {name}",
type="tertiary",
):
st.session_state[K_UPLOADS] = {}
st.session_state[K_UPLOAD_COUNTER] = upload_counter + 1
for k in (K_ROWS, K_WARNINGS, K_SOURCE_COUNT):
st.session_state.pop(k, None)
log_event(
"upload",
"PDF list cleared",
page="10_PDF_Extractor",
count=n,
)
st.rerun()
to_remove = name
col_name.markdown(
'<div class="dt-file-row">'
f'<span class="dt-file-icon-chip">{_DOC_SVG}</span>'
f'<span class="dt-file-name">{_html.escape(name)}</span>'
'</div>',
unsafe_allow_html=True,
)
col_size.markdown(
f'<div style="text-align:right;">'
f'<span class="dt-file-size">'
f'{_html.escape(_format_size(meta["size"]))}'
'</span></div>',
unsafe_allow_html=True,
)
# In-card "Add more files" button. The HTML is rendered as-is
# — Streamlit's sanitiser strips inline ``onclick``, so the
# click wiring is done by the iframe script below.
st.markdown(
'<button class="dt-file-add" type="button">'
f'{_PLUS_SVG} Add more files'
'</button>',
unsafe_allow_html=True,
)
if to_remove is not None:
# Wire the in-card "Add more files" button to the off-screen
# ``stFileUploaderDropzoneInput``. Identical pattern to the
# Home page (see ``src/gui/_home.py``); a ``MutationObserver``
# re-wires after every Streamlit rerun in case the button got
# re-mounted.
st.iframe(
"""
<script>
(function () {
function wire(doc) {
var btn = doc.querySelector('button.dt-file-add');
var input = doc.querySelector('[data-testid="stFileUploaderDropzoneInput"]');
if (!btn || !input) return;
if (btn.dataset.dtWired === '1') return;
btn.dataset.dtWired = '1';
btn.addEventListener('click', function (e) {
e.preventDefault();
input.click();
});
}
var doc;
try { doc = window.parent.document; }
catch (e) { doc = document; }
wire(doc);
var win = doc.defaultView || window.parent || window;
if ('MutationObserver' in win) {
var raf = 0;
try {
new win.MutationObserver(function () {
if (raf) return;
raf = win.requestAnimationFrame(function () { raf = 0; wire(doc); });
}).observe(doc.body, { childList: true, subtree: true });
} catch (e) {}
}
})();
</script>
""",
height=1,
)
if to_remove is not None:
log_event(
"upload",
f"PDF removed: {to_remove}",
filename=to_remove,
page="10_PDF_Extractor",
)
del pdf_uploads[to_remove]
# Bump the uploader counter so the widget re-instantiates and
# forgets the removed file.
st.session_state[K_UPLOAD_COUNTER] = upload_counter + 1
st.rerun()
# ---------------------------------------------------------------------------
# Action buttons (Scan + Clear all) live below the Files card
# ---------------------------------------------------------------------------
c_scan, c_clear, _spacer = st.columns([1, 1, 4])
with c_scan:
scan_clicked = st.button(
"Scan",
type="primary",
disabled=not pdf_uploads,
use_container_width=True,
)
with c_clear:
if st.button(
"Clear all files",
type="secondary",
disabled=not pdf_uploads,
help="Removes all uploaded files and the last scan result.",
use_container_width=True,
):
st.session_state[K_UPLOADS] = {}
st.session_state[K_UPLOAD_COUNTER] = upload_counter + 1
for k in (K_ROWS, K_WARNINGS, K_SOURCE_COUNT):
st.session_state.pop(k, None)
log_event(
"upload",
f"PDF removed: {to_remove}",
filename=to_remove,
"PDF list cleared",
page="10_PDF_Extractor",
count=n_files,
)
del pdf_uploads[to_remove]
# Bump the uploader counter so the widget re-instantiates
# and forgets the removed file. Without this, the user
# would have to click the widget's own X (which is hidden)
# OR re-upload to refresh the state.
st.session_state[K_UPLOAD_COUNTER] = upload_counter + 1
st.rerun()
else:
st.caption("No files uploaded yet.")
scan_clicked = False
# ---------------------------------------------------------------------------