diff --git a/src/application/routes/mod.rs b/src/application/routes/mod.rs
index 6d5833f..7fbddba 100644
--- a/src/application/routes/mod.rs
+++ b/src/application/routes/mod.rs
@@ -113,6 +113,7 @@ pub fn app_router(state: AppState) -> axum::Router {
.route("/scan", get(scan::scan_page))
.route("/timeline", get(timeline::timeline_page))
.route("/styles.css", get(styles))
+ .route("/extract.js", get(extract_js))
.route("/favicon.ico", get(favicon))
.nest("/api/v1", api_routes)
.layer(ServiceBuilder::new().layer(CookieManagerLayer::new()))
@@ -130,6 +131,13 @@ async fn styles() -> impl IntoResponse {
)
}
+async fn extract_js() -> impl IntoResponse {
+ (
+ [("content-type", "application/javascript; charset=utf-8")],
+ include_str!("../../../templates/extract.js"),
+ )
+}
+
async fn favicon() -> impl IntoResponse {
(
[("content-type", "image/x-icon")],
diff --git a/templates/extract.js b/templates/extract.js
new file mode 100644
index 0000000..4941256
--- /dev/null
+++ b/templates/extract.js
@@ -0,0 +1,57 @@
+let _extracting = false;
+
+const triggerPhotoExtract = (formId, endpoint, onSuccess) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = 'image/*';
+ input.capture = 'environment';
+ input.onchange = () => {
+ if (input.files.length === 0) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ doExtract(formId, endpoint, { image: reader.result }, onSuccess);
+ };
+ reader.readAsDataURL(input.files[0]);
+ };
+ input.click();
+};
+
+const extractFromText = (formId, endpoint, onSuccess) => {
+ const input = document.getElementById(`${formId}-extract-text`);
+ const prompt = input.value.trim();
+ if (prompt.length < 3) return;
+ doExtract(formId, endpoint, { prompt }, onSuccess);
+};
+
+const doExtract = async (formId, endpoint, body, onSuccess) => {
+ if (_extracting) return;
+ _extracting = true;
+ const errorEl = document.getElementById(`${formId}-extract-error`);
+ const controlsEl = document.getElementById(`${formId}-extract-controls`);
+ const waitingEl = document.getElementById(`${formId}-extract-waiting`);
+ errorEl.classList.add('hidden');
+ controlsEl.classList.add('hidden');
+ waitingEl.classList.remove('hidden');
+
+ try {
+ const resp = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify(body),
+ });
+ if (!resp.ok) {
+ const errData = await resp.json().catch(() => ({}));
+ throw new Error(errData.message || `Server returned ${resp.status}`);
+ }
+ const data = await resp.json();
+ onSuccess(data);
+ } catch (e) {
+ errorEl.textContent = `Extraction failed: ${e.message}`;
+ errorEl.classList.remove('hidden');
+ } finally {
+ waitingEl.classList.add('hidden');
+ controlsEl.classList.remove('hidden');
+ _extracting = false;
+ }
+};
diff --git a/templates/roasters.html b/templates/roasters.html
index 086dd80..857cb66 100644
--- a/templates/roasters.html
+++ b/templates/roasters.html
@@ -2,67 +2,10 @@
{% block head %}
{% if is_authenticated && has_ai_extract %}
+