mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-21 18:55:36 +02:00
feat: browser capture button
This commit is contained in:
commit
10c41ef84f
8 changed files with 1479 additions and 1228 deletions
File diff suppressed because it is too large
Load diff
1261
crates/archivr-core/src/capture.rs
Normal file
1261
crates/archivr-core/src/capture.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,4 @@
|
|||
pub mod capture;
|
||||
pub mod archive;
|
||||
pub mod database;
|
||||
pub mod downloader;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use archivr_core::{archive, database};
|
||||
use archivr_core::{archive, capture, database};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get},
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower::ServiceExt;
|
||||
|
|
@ -44,6 +44,7 @@ pub fn app(registry: ServerRegistry) -> Router {
|
|||
get(serve_artifact),
|
||||
)
|
||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
||||
.route("/api/archives/:archive_id/tags", get(list_tags).post(create_tag_handler))
|
||||
.route(
|
||||
"/api/archives/:archive_id/entries/:entry_uid/tags",
|
||||
|
|
@ -211,6 +212,27 @@ async fn remove_entry_tag_handler(
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct CaptureBody {
|
||||
locator: String,
|
||||
}
|
||||
|
||||
async fn capture_handler(
|
||||
State(state): State<AppState>,
|
||||
Path(archive_id): Path<String>,
|
||||
Json(body): Json<CaptureBody>,
|
||||
) -> Result<Json<capture::CaptureResult>, ApiError> {
|
||||
if body.locator.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("locator must not be empty"));
|
||||
}
|
||||
let mounted = mounted_archive(&state, &archive_id)?;
|
||||
let archive_paths = archive::read_archive_paths(&mounted.archive_path)
|
||||
.map_err(ApiError::from)?;
|
||||
let result = capture::perform_capture(&archive_paths, &body.locator)
|
||||
.map_err(ApiError::from)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
fn mounted_archive<'a>(
|
||||
state: &'a AppState,
|
||||
archive_id: &str,
|
||||
|
|
@ -892,4 +914,36 @@ mod tests {
|
|||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_rejects_empty_locator() {
|
||||
let response = app(ServerRegistry::default())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/archives/test/captures")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"locator":""}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_rejects_unknown_archive() {
|
||||
let response = app(ServerRegistry::default())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/archives/nonexistent/captures")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"locator":"tweet:1234567890"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ const entryTagsEl = document.querySelector("#entry-tags");
|
|||
const assignTagForm = document.querySelector("#assign-tag-form");
|
||||
const assignTagInput = document.querySelector("#assign-tag-input");
|
||||
const assignTagBtn = document.querySelector("#assign-tag-btn");
|
||||
const captureButton = document.querySelector('.capture-button');
|
||||
const captureDialog = document.querySelector('#capture-dialog');
|
||||
const captureLocatorInput = document.querySelector('#capture-locator');
|
||||
const captureSubmitBtn = document.querySelector('#capture-submit-btn');
|
||||
const captureCancelBtn = document.querySelector('#capture-cancel-btn');
|
||||
const captureError = document.querySelector('#capture-error');
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return "0 B";
|
||||
|
|
@ -425,6 +431,45 @@ assignTagBtn.addEventListener("click", async () => {
|
|||
}
|
||||
});
|
||||
|
||||
captureButton.addEventListener('click', () => {
|
||||
captureLocatorInput.value = '';
|
||||
captureError.hidden = true;
|
||||
captureDialog.showModal();
|
||||
});
|
||||
|
||||
captureCancelBtn.addEventListener('click', () => captureDialog.close());
|
||||
|
||||
captureSubmitBtn.addEventListener('click', async () => {
|
||||
const locator = captureLocatorInput.value.trim();
|
||||
if (!locator) {
|
||||
captureError.textContent = 'Enter a locator.';
|
||||
captureError.hidden = false;
|
||||
return;
|
||||
}
|
||||
captureSubmitBtn.disabled = true;
|
||||
captureSubmitBtn.textContent = 'Capturing\u2026';
|
||||
captureError.hidden = true;
|
||||
try {
|
||||
const res = await fetch(`/api/archives/${state.archiveId}/captures`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locator }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const msg = await res.text();
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
captureDialog.close();
|
||||
await Promise.all([loadEntries(searchInput.value), loadRuns()]);
|
||||
} catch (e) {
|
||||
captureError.textContent = e.message;
|
||||
captureError.hidden = false;
|
||||
} finally {
|
||||
captureSubmitBtn.disabled = false;
|
||||
captureSubmitBtn.textContent = 'Capture';
|
||||
}
|
||||
});
|
||||
|
||||
loadArchives().catch((error) => {
|
||||
contextBody.textContent = `Failed to load archives: ${error.message}`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,6 +84,20 @@
|
|||
</aside>
|
||||
</main>
|
||||
|
||||
<dialog id="capture-dialog" class="capture-dialog">
|
||||
<div class="capture-dialog-inner">
|
||||
<h2 class="capture-dialog-title">Capture</h2>
|
||||
<label for="capture-locator" class="capture-label">Locator</label>
|
||||
<input id="capture-locator" class="capture-input" type="text"
|
||||
placeholder="tweet:1234567890 or https://..." autocomplete="off">
|
||||
<div id="capture-error" class="capture-error" hidden></div>
|
||||
<div class="capture-actions">
|
||||
<button type="button" id="capture-cancel-btn" class="capture-cancel">Cancel</button>
|
||||
<button type="button" id="capture-submit-btn" class="capture-submit">Capture</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -496,3 +496,93 @@ select {
|
|||
.tag-filter-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Capture dialog */
|
||||
.capture-dialog {
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper);
|
||||
padding: 0;
|
||||
min-width: 360px;
|
||||
max-width: 520px;
|
||||
box-shadow: 4px 4px 0 var(--ink);
|
||||
}
|
||||
|
||||
.capture-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.capture-dialog-inner {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.capture-dialog-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 18px;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.capture-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.capture-input {
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
border: 2px solid var(--ink);
|
||||
background: var(--paper-3);
|
||||
color: var(--ink);
|
||||
padding: 0 12px;
|
||||
font-size: 15px;
|
||||
margin-bottom: 10px;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.capture-error {
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.capture-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.capture-cancel {
|
||||
border: 1px solid var(--line);
|
||||
background: none;
|
||||
color: var(--ink);
|
||||
padding: 8px 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capture-cancel:hover {
|
||||
background: var(--paper-2);
|
||||
}
|
||||
|
||||
.capture-submit {
|
||||
border: 0;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capture-submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.capture-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,13 +129,18 @@
|
|||
pname = "archivr-server-wrapped";
|
||||
inherit version;
|
||||
nativeBuildInputs = [ pkgs.makeWrapper ];
|
||||
buildInputs = [ tweetPython ];
|
||||
phases = [ "installPhase" ];
|
||||
installPhase = ''
|
||||
mkdir -p $out/bin $out/libexec/archivr-server $out/share/archivr-server/static
|
||||
cp ${archivr_server_unwrapped}/bin/archivr-server $out/libexec/archivr-server/archivr-server
|
||||
cp ${./vendor/twitter/scrape_user_tweet_contents.py} $out/libexec/archivr-server/scrape_user_tweet_contents.py
|
||||
chmod +x $out/libexec/archivr-server/scrape_user_tweet_contents.py
|
||||
cp -r ${./crates/archivr-server/static}/* $out/share/archivr-server/static/
|
||||
makeWrapper $out/libexec/archivr-server/archivr-server $out/bin/archivr-server \
|
||||
--set ARCHIVR_STATIC_DIR $out/share/archivr-server/static
|
||||
--set ARCHIVR_STATIC_DIR $out/share/archivr-server/static \
|
||||
--set ARCHIVR_TWEET_PYTHON ${tweetPython}/bin/python3 \
|
||||
--set ARCHIVR_TWEET_SCRAPER $out/libexec/archivr-server/scrape_user_tweet_contents.py
|
||||
'';
|
||||
};
|
||||
in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue