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:
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user