From 11b16bd692c2b99ee02f02b9830576280a004bfe Mon Sep 17 00:00:00 2001 From: Ben Little Date: Thu, 25 Jul 2024 11:45:26 -0600 Subject: [PATCH] Feature/notifications (#9) * basic mock with no styles * new media appears in list * media inits properly * mock with notifications about new media * rename toast -> notification * notifs with sse * update TODOs * rm unnused type def --- TODO.md | 28 +++-- api/main.go | 57 ++++++++- env.sh | 8 +- nginx.conf | 4 +- ui/src/client/http.rs | 9 ++ ui/src/client/mock.rs | 39 +++++- ui/src/components.rs | 33 +----- ui/src/components/dashboard.rs | 98 ++++++++------- ui/src/components/notification_tray.rs | 38 ++++++ ui/src/data.rs | 27 ++++- ui/src/lib.rs | 157 ++++++++++++------------- ui/style.scss | 19 ++- 12 files changed, 327 insertions(+), 190 deletions(-) create mode 100644 ui/src/components/notification_tray.rs diff --git a/TODO.md b/TODO.md index f6cb8b4..4c47104 100644 --- a/TODO.md +++ b/TODO.md @@ -8,10 +8,12 @@ - [x] Select should preserve scroll - [x] Filter media in selector by query - [x] Media detail on hover -- [ ] Toaster notifications - - [ ] new media +- [x] notifications + - [x] new media - [x] Uploads - +- [ ] hightlight new media items +- [ ] improve notification tray look and feel + ### API - [ ] Persist storage on server @@ -20,10 +22,18 @@ - [ ] on-disk kv storage - [ ] sqlite storage - [ ] garbage collection instead of deletion -- [ ] Improve metadata automation +- [x] Improve metadata automation - [x] `ffprobe` to mine metadata - ~~[ ] Use collections for nested directories~~ - [x] Recurse directories for media discovery + +### Protocol + +- [ ] Bidi communication + - [ ] Send ack for accepted updates +- [ ] Abstractions + - [ ] Transport (req+sse, ws, quic) + - [ ] Remotes (servers, peers) ## Maintenance @@ -42,7 +52,7 @@ - [ ] Resumable - [ ] Upload progress bars - [ ] Use object storage -- [ ] Alternative protocols (ws,quic) +- ~~[ ] Alternative protocols (ws,quic)~~ - [ ] API tests ## PR @@ -50,11 +60,3 @@ - [x] Dry demo - [ ] User docs - [x] GitHub pages automation - -## Under Consideration - -- [x] better data model and sync procedure (CRDT?) - - [x] database in browser storage - - [x] only pull out-of-date items from server - - [ ] ~~treat browser storage as write-back cache~~ -- [ ] ~~Consider HTTP/2 for event streams~~ diff --git a/api/main.go b/api/main.go index 59328a5..0b8b5ab 100644 --- a/api/main.go +++ b/api/main.go @@ -4,12 +4,14 @@ import ( "bytes" "encoding/json" "fmt" + "io" "log" "net/http" "os" "os/exec" "path" "strings" + "sync" "github.com/fsnotify/fsnotify" "github.com/gin-gonic/gin" @@ -39,7 +41,7 @@ func main() { path_to_id := make(map[string]string) media := NewMemCollection() - index := func(p string) { + index := func(p string) gin.H { title, format := extract_title_format(p) if format == "unknown" { title, format = ffprobe(p) @@ -48,25 +50,72 @@ func main() { id, err := media.Create(url, title, format) if err != nil { log.Print(err) + return nil } path_to_id[p] = id + return gin.H{ + "id": id, + "title": title, + "format": format, + "url": url, + } + } walk("/data", index) + + clients := make(map[chan gin.H]struct{}) + mu := sync.Mutex{} watch("/data", func(ev fsnotify.Event) { switch ev.Op { case fsnotify.Create: log.Printf("create %s", ev.Name) - index(ev.Name) + item := index(ev.Name) + mu.Lock() + log.Printf("clients: %d", len(clients)) + for client := range clients { + client <- item + } + mu.Unlock() case fsnotify.Rename, fsnotify.Remove: log.Printf("remove %s", ev.Name) id := path_to_id[ev.Name] - media.Drop(id) + if err := media.Drop(id); err != nil { + log.Print(err) + } } }) router := gin.Default() add_collection(router, media, "media") + router.GET("/events/media", func(c *gin.Context) { + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + ch := make(chan gin.H) + log.Printf("new client") + mu.Lock() + clients[ch] = struct{}{} + mu.Unlock() + defer func() { + log.Printf("closing client channel") + mu.Lock() + close(ch) + delete(clients, ch) + mu.Unlock() + }() + c.Stream(func(_ io.Writer) bool { + if event, ok := <-ch; ok { + log.Printf("sending event to client") + c.SSEvent("message", event) + log.Printf("event sent") + return true + } + return false + }) + }) + log.Fatal(router.Run()) } @@ -134,7 +183,7 @@ func add_collection(router *gin.Engine, coll Collection, name string) { }) } -func walk(dir string, f func(string)) { +func walk(dir string, f func(string) gin.H) { if entries, err := os.ReadDir(dir); err != nil { log.Print(err) } else { diff --git a/env.sh b/env.sh index dcc2735..683ff0d 100644 --- a/env.sh +++ b/env.sh @@ -1,5 +1,11 @@ #!/bin/bash function serve { - RUSTFLAGS=--cfg=web_sys_unstable_apis APP_BASE_PATH=media-manager trunk serve --features=demo + pushd ui || exit 1 + RUSTFLAGS=--cfg=web_sys_unstable_apis APP_BASE_PATH=media-manager trunk serve --features=demo + popd || exit 1 +} + +function nginx-reload { + podman-compose -f compose.yaml exec nginx nginx -s reload } diff --git a/nginx.conf b/nginx.conf index 0aa1ab4..9b40a56 100644 --- a/nginx.conf +++ b/nginx.conf @@ -68,8 +68,8 @@ http { proxy_pass http://api:8080/; } - location ~ /api/(media|jobs)/subscribe { - # proxy_pass http://api:8080/events; + location /api/events/ { + proxy_pass http://api:8080/events/; # add_header 'Access-Control-Allow-Origin' '*' always; proxy_http_version 1.1; proxy_read_timeout 300s; diff --git a/ui/src/client/http.rs b/ui/src/client/http.rs index 10d0feb..7dcb35c 100644 --- a/ui/src/client/http.rs +++ b/ui/src/client/http.rs @@ -1,4 +1,5 @@ use crate::{data::MediaItem, log}; +use leptos::*; #[inline] fn origin() -> String { @@ -64,3 +65,11 @@ pub async fn upload_file(file: web_sys::File) { } }; } + +pub fn new_media() -> Signal> { + let event_source = leptos_use::use_event_source::( + &format!("{}/api/events/media", origin()), + ); + create_effect(move |_| log!("{:?}", event_source.data.get())); + event_source.data +} diff --git a/ui/src/client/mock.rs b/ui/src/client/mock.rs index 74296a2..93ca8fa 100644 --- a/ui/src/client/mock.rs +++ b/ui/src/client/mock.rs @@ -1,7 +1,7 @@ //! Generate fake data for faster debugging cycles. use crate::{data::MediaItem, log}; -use std::sync::Mutex; +use std::{array::IntoIter, sync::Mutex}; lazy_static::lazy_static! { static ref MEDIA: Mutex>> = Mutex::new(None); @@ -18,9 +18,11 @@ fn init_media() -> Option> { }); } for i in 0..5 { + let id = (7 + i).to_string(); + let title = format!("Big Buck Bunny {}", id); m.push(MediaItem { - id: (7 + i).to_string(), - title: format!("Big Buck Bunny {}", i), + id, + title, format: "webm".to_string(), url: "https://dl6.webmfiles.org/big-buck-bunny_trailer.webm".to_owned(), }); @@ -54,6 +56,35 @@ pub async fn update_media(id: String, field: String, value: String) -> anyhow::R Ok(true) } -pub async fn upload_file(file: web_sys::File) { +pub async fn upload_file(_file: web_sys::File) { log!("File uploads not supported in demo mode!"); } + +use leptos::*; + +pub fn new_media() -> Signal> { + let (data, set_data) = create_signal(None::); + let interval = leptos_use::use_interval(10_000); + create_effect(move |items| { + (interval.counter).track(); + let mut items = if items.is_none() { + [1, 2, 3, 4].into_iter() + } else { + items.unwrap() + }; + if let Some(i) = items.next() { + let id = (12 + i).to_string(); + let title = format!("Big Buck Bunny {}", id); + set_data(Some(MediaItem { + id, + title, + format: "webm".to_string(), + url: "https://dl6.webmfiles.org/big-buck-bunny_trailer.webm".to_owned(), + })); + } else { + (interval.pause)() + } + items + }); + return data.into(); +} diff --git a/ui/src/components.rs b/ui/src/components.rs index 081a2f1..cec303a 100644 --- a/ui/src/components.rs +++ b/ui/src/components.rs @@ -1,26 +1,8 @@ pub mod dashboard; +pub mod notification_tray; use leptos::*; -#[component] -pub fn Loading(#[prop(optional)] what: Option) -> impl IntoView { - view! { -

- "Loading" - {if let Some(what) = what { - view! { - " " - {what} - } - .into_view() - } else { - view! {}.into_view() - }} - "..." -

- } -} - #[component] pub fn LoremIpsum() -> impl IntoView { view! { @@ -32,19 +14,6 @@ pub fn LoremIpsum() -> impl IntoView { } } -#[component] -pub fn SyncButton(action: Action<(), T>, pending: ReadSignal) -> impl IntoView { - view! { - - } -} - #[component] pub fn ClickToEdit(value: String, onset: Cb) -> impl IntoView where diff --git a/ui/src/components/dashboard.rs b/ui/src/components/dashboard.rs index 0f86e20..513b0d1 100644 --- a/ui/src/components/dashboard.rs +++ b/ui/src/components/dashboard.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; + use leptos::*; use leptos_router::*; -use crate::{components::ClickToEdit, data::MediaItem, log}; +use crate::{components::ClickToEdit, data::MediaItem, log, MediaUpdate}; #[cfg(web_sys_unstable_apis)] use crate::components::CopyButton; @@ -13,7 +15,7 @@ where { let query = use_query_map(); let search = move || query().get("q").cloned().unwrap_or_default(); - let media = use_context::>>>().unwrap(); + let media = use_context::>>().unwrap(); view! {