diff --git a/Cargo.lock b/Cargo.lock index a2a4e8a..54add5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,7 +202,7 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "based" version = "0.1.0" -source = "git+https://git.hydrar.de/jmarya/based?branch=ui#c05d0dcc0a46e721e8664a15e6e2f264aa8d4b53" +source = "git+https://git.hydrar.de/jmarya/based?branch=ui#12e709d722e7fe9d61ca3d76b3642ff2ef5301c2" dependencies = [ "bcrypt", "chrono", diff --git a/src/archive/mod.rs b/src/archive/mod.rs index 5888407..5a89f63 100644 --- a/src/archive/mod.rs +++ b/src/archive/mod.rs @@ -1,4 +1,7 @@ -use std::{collections::HashSet, path::PathBuf}; +use std::{ + collections::{HashMap, HashSet}, + path::PathBuf, +}; use crate::{ blacklist::{check_blacklist, check_blacklist_path}, @@ -9,6 +12,7 @@ use crate::{ mod document; mod domain; use based::get_pg; +use chrono::NaiveDate; pub use document::Document; pub use domain::*; @@ -292,3 +296,25 @@ pub async fn index_document(doc: &Document) { } } } + +pub struct DocumentIndex {} + +impl DocumentIndex { + pub async fn get_documents_of_day(day: NaiveDate) -> HashMap> { + let res: Vec<(String, String)> = + sqlx::query_as("SELECT domain, path FROM document_index WHERE version = $1") + .bind(day) + .fetch_all(get_pg!()) + .await + .unwrap(); + + let mut ret = HashMap::new(); + + for (domain, path) in res { + let d: &mut Vec = ret.entry(domain).or_default(); + d.push(path); + } + + ret + } +} diff --git a/src/main.rs b/src/main.rs index 91487fe..2933ae5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use based::asset::AssetRoutes; use based::get_pg; -use based::ui::components::Shell; +use based::ui::components::{NavBar, Shell}; use based::ui::prelude::*; use rocket::routes; use webarc::ai::EmbedStore; @@ -57,7 +57,8 @@ async fn main() { pages::domain_info_route, pages::favicon_route, pages::vector_search, - pages::render_txt_website + pages::render_txt_website, + pages::timeline_route ], ) .manage(arc) @@ -189,6 +190,7 @@ pub fn get_shell() -> Shell { .color(Zinc::_950), ) .use_ui() + .with_navbar(NavBar("Web Archive")) } // TODO : archive cleanup code diff --git a/src/pages/mod.rs b/src/pages/mod.rs index f186d53..c05dcbe 100644 --- a/src/pages/mod.rs +++ b/src/pages/mod.rs @@ -1,19 +1,30 @@ -use std::{io::Read, path::PathBuf, sync::Arc}; +use std::{collections::HashMap, io::Read, path::PathBuf, sync::Arc}; use based::{ + page, request::{ - api::GeneratedPager, assets::DataResponse, respond_json, RequestContext, StringResponse, + api::GeneratedPager, assets::DataResponse, respond_html, respond_json, RequestContext, + StringResponse, + }, + ui::{ + components::{ + prelude::{InfinityScroll, Timeline, TimelineElement}, + ColoredSpinner, Search, Shell, + }, + primitives::flex::Column, + UIWidget, }, - ui::components::{Search, Shell}, }; +use chrono::NaiveDate; use maud::{html, PreEscaped}; use rocket::{get, request::FromSegments, State}; pub mod component; +use based::ui::prelude::*; use component::*; use serde_json::json; -use webarc::archive::Document; +use webarc::archive::{Document, DocumentIndex}; use webarc::{ ai::{generate_embedding, remove_data_urls, EmbedStore, SearchResult}, archive::{extract_domains, WebsiteArchive}, @@ -21,10 +32,113 @@ use webarc::{ render_page, }; -// TODO : Implement archive timeline page (chrono sorted documents) - const SEARCH_BAR_STYLE: &str = "w-full px-4 mb-4 py-2 text-white bg-black border-2 border-neon-blue placeholder-neon-blue focus:ring-2 focus:ring-neon-pink focus:outline-none font-mono text-lg"; +pub fn WebsiteIcon(domain: &str) -> PreEscaped { + html! { + h2 class="text-xl font-bold mb-4 -ml-2 flex items-center w-fit" { + img class="p-2" src=(format!("/favicon/{domain}")) {}; + a href=(format!("/d/{domain}")) { (domain) }; + }; + } +} + +pub fn build_timeline( + domains: HashMap>, + the_day: NaiveDate, + vanish: bool, +) -> PreEscaped { + let mut tl = Timeline(); + + let yesterday = the_day - chrono::Duration::days(1); + + let mut sorted_keys: Vec<_> = domains.keys().collect(); + sorted_keys.sort(); + + for key in sorted_keys { + let (domain, paths) = (key, domains.get(key).unwrap()); + tl = tl.add_element(TimelineElement::new( + &the_day.to_string(), + WebsiteIcon(domain), + Column( + paths + .into_iter() + .map(|path| Link(&format!("/s/{domain}/{path}"), Text(&path).large())) + .collect(), + ) + .gap(ScreenValue::_2), + )); + } + + tl = tl.add_after(InfinityScroll( + ColoredSpinner(Purple::_500), + &format!("/timeline?before={}", yesterday.to_string()), + )); + + if vanish { + tl = tl.vanish(); + } + + tl.render_with_class("") +} + +pub async fn get_domains(before: &str) -> HashMap> { + let today = NaiveDate::parse_from_str(before, "%Y-%m-%d").unwrap(); + DocumentIndex::get_documents_of_day(today).await +} + +pub async fn get_domains_lookback( + before: &str, + mut days: u64, +) -> (HashMap>, NaiveDate) { + let mut the_day = NaiveDate::parse_from_str(before, "%Y-%m-%d").unwrap(); + while days > 0 { + let domains = get_domains(&the_day.to_string()).await; + + if !domains.is_empty() { + return (domains, the_day); + } + + the_day -= chrono::Duration::days(1); + days -= 1; + } + + (HashMap::new(), the_day) +} + +#[get("/timeline?")] +pub async fn timeline_route( + ctx: RequestContext, + shell: &State, + before: Option<&str>, +) -> StringResponse { + let today = chrono::Local::now().date_naive(); + + let (domains, day) = if let Some(before) = before { + get_domains_lookback(before, 30 * 12).await + } else { + get_domains_lookback(&today.to_string(), 30 * 12).await + }; + + if domains.is_empty() { + return respond_html(Nothing().0); + } + + if ctx.is_htmx && !ctx.htmx_redirect { + return respond_html(build_timeline(domains, day, true).0); + } + + let tl = Screen::medium(Padding(Nothing()).all(ScreenValue::_6)) + .on(Column(vec![ + Margin(Text("Document Timeline")._3xl().extrabold()) + .left(ScreenValue::_6) + .render_with_class(""), + build_timeline(domains, day, false), + ])) + .render_with_class(""); + page!(shell, ctx, "Timeline", tl) +} + /// Get the favicon of a domain #[get("/favicon/")] pub async fn favicon_route(domain: &str) -> Option {