preview.rs
⎇
Raw
1//! File previews (milestone 4): browser-native media (image/PDF/video/audio)
2//! and read-only CodeMirror for text/code.
3
4use std::sync::{Arc, Mutex};
5
6use leptos::prelude::*;
7use wasm_bindgen::JsValue;
8use wasm_bindgen_futures::spawn_local;
9use web_sys::MouseEvent;
10
11use crate::api;
12use crate::cm;
13
14/// What kind of preview a file gets.
15#[derive(Clone, Copy, PartialEq, Eq)]
16pub enum PreviewKind {
17 Image,
18 Pdf,
19 Video,
20 Audio,
21 Text,
22}
23
24const IMAGE_EXTS: &[&str] = &[
25 "png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "avif", "tiff", "tif",
26];
27const VIDEO_EXTS: &[&str] = &["mp4", "webm", "ogv", "mov", "m4v"];
28const AUDIO_EXTS: &[&str] = &["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"];
29
30fn ext_of(name: &str) -> String {
31 let base = name.rsplit('/').next().unwrap_or(name);
32 base.rsplit('.').next().unwrap_or("").to_lowercase()
33}
34
35/// Decide how to preview a file by name. `None` = no preview (download only).
36pub fn preview_kind(name: &str) -> Option<PreviewKind> {
37 let ext = ext_of(name);
38 if IMAGE_EXTS.contains(&ext.as_str()) {
39 return Some(PreviewKind::Image);
40 }
41 if ext == "pdf" {
42 return Some(PreviewKind::Pdf);
43 }
44 if VIDEO_EXTS.contains(&ext.as_str()) {
45 return Some(PreviewKind::Video);
46 }
47 if AUDIO_EXTS.contains(&ext.as_str()) {
48 return Some(PreviewKind::Audio);
49 }
50 if cm::is_text(name) {
51 return Some(PreviewKind::Text);
52 }
53 None
54}
55
56/// A file the user wants to preview.
57#[derive(Clone)]
58pub struct PreviewTarget {
59 pub root_id: i64,
60 /// Path relative to the root (e.g. "docs/a.txt").
61 pub path: String,
62 /// Display name (used for the title + language detection).
63 pub name: String,
64 #[allow(dead_code)]
65 pub size: u64,
66}
67
68/// The preview overlay.
69#[component]
70pub fn PreviewModal(
71 target: PreviewTarget,
72 kind: PreviewKind,
73 close: Callback<()>,
74) -> impl IntoView {
75 let t = target;
76 let url = match kind {
77 PreviewKind::Text => String::new(),
78 _ => api::preview_url(t.root_id, &t.path),
79 };
80 let name = t.name.clone();
81 // Escape closes the preview.
82 {
83 let c = close;
84 crate::util::owned_window_listener(
85 leptos::ev::keydown,
86 move |ev: web_sys::KeyboardEvent| {
87 if ev.key() == "Escape" {
88 c.run(());
89 }
90 },
91 );
92 }
93 view! {
94 <div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
95 <div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
96 <div class="preview-head">
97 <span class="preview-title">{name}</span>
98 <button class="preview-close" on:click=move |_| close.run(())>
99 "×"
100 </button>
101 </div>
102 <div class="preview-body">
103 {move || match kind {
104 PreviewKind::Image => {
105 view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
106 .into_view()
107 .into_any()
108 }
109 PreviewKind::Pdf => {
110 view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
111 .into_view()
112 .into_any()
113 }
114 PreviewKind::Video => {
115 view! { <video class="preview-video" src=url.clone() controls=true/> }
116 .into_view()
117 .into_any()
118 }
119 PreviewKind::Audio => {
120 view! {
121 <div class="preview-audio">
122 <audio src=url.clone() controls=true/>
123 </div>
124 }
125 .into_view()
126 .into_any()
127 }
128 PreviewKind::Text => view! {
129 <TextPreview target=t.clone()/>
130 }
131 .into_view()
132 .into_any()
133 }}
134 </div>
135 </div>
136 </div>
137 }
138}
139
140// ---------------------------------------------------------------------------
141// Text preview (read-only CodeMirror)
142// ---------------------------------------------------------------------------
143
144#[component]
145fn TextPreview(target: PreviewTarget) -> impl IntoView {
146 let node = NodeRef::<leptos::html::Div>::new();
147 // Some(err) when the file couldn't be loaded.
148 let (status, set_status) = signal(Option::<String>::None);
149 // Holds the live CodeMirror view so we can destroy it on close.
150 let holder: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
151
152 {
153 let t = target.clone();
154 let st = set_status;
155 let h = holder.clone();
156 node.on_load(move |el: web_sys::HtmlDivElement| {
157 let t = t.clone();
158 let st = st;
159 let h = h.clone();
160 spawn_local(async move {
161 match api::fetch_content(t.root_id, &t.path).await {
162 Ok(text) => match cm::create(&el, &text, &t.name, false, None) {
163 Ok(c) => {
164 let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
165 }
166 Err(e) => st.set(Some(e)),
167 },
168 Err(e) => st.set(Some(e.to_string())),
169 }
170 });
171 });
172 }
173
174 let h = holder.clone();
175 on_cleanup(move || {
176 if let Ok(mut g) = h.lock()
177 && let Some(v) = g.take()
178 {
179 cm::destroy(&v);
180 }
181 });
182
183 view! {
184 <div class="cm-wrap">
185 <div node_ref=node class="cm-container"></div>
186 {move || {
187 let err = status.get();
188 match err {
189 Some(e) => view! { <div class="cm-error muted">{e}</div> }
190 .into_view()
191 .into_any(),
192 None => {
193 view! {}.into_any()
194 },
195 }
196 }}
197 </div>
198 }
199}
200