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) upload_counter: int = st.session_state.setdefault(K_UPLOAD_COUNTER, 0)
uploader_key = f"pdf_upload_v{upload_counter}" 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 # Mirror the Home-page upload pattern: the Streamlit file_uploader
# Home-style list below, so suppressing the native one leaves a # is positioned off-screen via CSS (keeps its underlying ``<input
# single source of truth on screen. # 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( st.markdown(
"""<style> '<style>[data-testid="stFileUploader"] {'
[data-testid="stFileUploader"] [data-testid="stFileUploaderFile"] { 'position:absolute!important;left:-10000px!important;'
display: none !important; 'width:1px!important;height:1px!important;overflow:hidden!important;'
} 'pointer-events:none!important;}</style>',
[data-testid="stFileUploader"] [data-testid="stFileUploaderDeleteBtn"] {
display: none !important;
}
</style>""",
unsafe_allow_html=True, unsafe_allow_html=True,
) )
@@ -195,8 +196,6 @@ def _sync_pdf_uploads() -> None:
"""``on_change`` callback. Adds newly-uploaded files to the """``on_change`` callback. Adds newly-uploaded files to the
persistent stash. **Add-only** — removal happens through the persistent stash. **Add-only** — removal happens through the
custom X buttons + counter bump, NOT through this callback. 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 [] widget_files = st.session_state.get(uploader_key) or []
for f in widget_files: for f in widget_files:
@@ -220,22 +219,55 @@ st.file_uploader(
accept_multiple_files=True, accept_multiple_files=True,
key=uploader_key, key=uploader_key,
on_change=_sync_pdf_uploads, on_change=_sync_pdf_uploads,
label_visibility="collapsed",
help="Drop one or more bank-statement PDFs. Multi-file batches " help="Drop one or more bank-statement PDFs. Multi-file batches "
"are merged into a single table with a ``source_file`` column.", "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: import html as _html
n = len(pdf_uploads)
total = sum(m["size"] for m in pdf_uploads.values()) _DOC_SVG = (
word = "file" if n == 1 else "files" '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
st.markdown( '<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
f"**{n} {word}** · {_format_size(total)} total", '<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'
)
else:
meta_html = "No files imported yet"
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 to_remove: str | None = None
with st.container(border=True): with st.container(border=True):
for name, meta in pdf_uploads.items(): for name, meta in pdf_uploads.items():
@@ -250,33 +282,69 @@ if pdf_uploads:
type="tertiary", type="tertiary",
): ):
to_remove = name to_remove = name
col_name.markdown(f"📄 **{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( col_size.markdown(
f"<div style='text-align:right;color:#5a5f6e;'>" f'<div style="text-align:right;">'
f"{_format_size(meta['size'])}</div>", 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, unsafe_allow_html=True,
) )
c_scan, c_clear = st.columns([1, 4]) # Wire the in-card "Add more files" button to the off-screen
with c_scan: # ``stFileUploaderDropzoneInput``. Identical pattern to the
scan_clicked = st.button("Scan", type="primary") # Home page (see ``src/gui/_home.py``); a ``MutationObserver``
with c_clear: # re-wires after every Streamlit rerun in case the button got
if st.button( # re-mounted.
"Clear all files", st.iframe(
type="secondary", """
help="Removes all uploaded files and the last scan result.", <script>
): (function () {
st.session_state[K_UPLOADS] = {} function wire(doc) {
st.session_state[K_UPLOAD_COUNTER] = upload_counter + 1 var btn = doc.querySelector('button.dt-file-add');
for k in (K_ROWS, K_WARNINGS, K_SOURCE_COUNT): var input = doc.querySelector('[data-testid="stFileUploaderDropzoneInput"]');
st.session_state.pop(k, None) if (!btn || !input) return;
log_event( if (btn.dataset.dtWired === '1') return;
"upload", btn.dataset.dtWired = '1';
"PDF list cleared", btn.addEventListener('click', function (e) {
page="10_PDF_Extractor", e.preventDefault();
count=n, 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,
) )
st.rerun()
if to_remove is not None: if to_remove is not None:
log_event( log_event(
@@ -286,15 +354,43 @@ if pdf_uploads:
page="10_PDF_Extractor", page="10_PDF_Extractor",
) )
del pdf_uploads[to_remove] del pdf_uploads[to_remove]
# Bump the uploader counter so the widget re-instantiates # Bump the uploader counter so the widget re-instantiates and
# and forgets the removed file. Without this, the user # forgets the removed file.
# 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.session_state[K_UPLOAD_COUNTER] = upload_counter + 1
st.rerun() st.rerun()
else:
st.caption("No files uploaded yet.")
scan_clicked = False # ---------------------------------------------------------------------------
# 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",
"PDF list cleared",
page="10_PDF_Extractor",
count=n_files,
)
st.rerun()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------