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 let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
85 if ev.key() == "Escape" {
86 c.run(());
87 }
88 });
89 }
90 view! {
91 <div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
92 <div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
93 <div class="preview-head">
94 <span class="preview-title">{name}</span>
95 <button class="preview-close" on:click=move |_| close.run(())>
96 "×"
97 </button>
98 </div>
99 <div class="preview-body">
100 {move || match kind {
101 PreviewKind::Image => {
102 view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
103 .into_view()
104 .into_any()
105 }
106 PreviewKind::Pdf => {
107 view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
108 .into_view()
109 .into_any()
110 }
111 PreviewKind::Video => {
112 view! { <video class="preview-video" src=url.clone() controls=true/> }
113 .into_view()
114 .into_any()
115 }
116 PreviewKind::Audio => {
117 view! {
118 <div class="preview-audio">
119 <audio src=url.clone() controls=true/>
120 </div>
121 }
122 .into_view()
123 .into_any()
124 }
125 PreviewKind::Text => view! {
126 <TextPreview target=t.clone()/>
127 }
128 .into_view()
129 .into_any()
130 }}
131 </div>
132 </div>
133 </div>
134 }
135}
136
137// ---------------------------------------------------------------------------
138// Text preview (read-only CodeMirror)
139// ---------------------------------------------------------------------------
140
141#[component]
142fn TextPreview(target: PreviewTarget) -> impl IntoView {
143 let node = NodeRef::<leptos::html::Div>::new();
144 // Some(err) when the file couldn't be loaded.
145 let (status, set_status) = signal(Option::<String>::None);
146 // Holds the live CodeMirror view so we can destroy it on close.
147 let holder: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
148
149 {
150 let t = target.clone();
151 let st = set_status;
152 let h = holder.clone();
153 node.on_load(move |el: web_sys::HtmlDivElement| {
154 let t = t.clone();
155 let st = st;
156 let h = h.clone();
157 spawn_local(async move {
158 match api::fetch_content(t.root_id, &t.path).await {
159 Ok(text) => match cm::create(&el, &text, &t.name, false, None) {
160 Ok(c) => {
161 let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
162 }
163 Err(e) => st.set(Some(e)),
164 },
165 Err(e) => st.set(Some(e.to_string())),
166 }
167 });
168 });
169 }
170
171 let h = holder.clone();
172 on_cleanup(move || {
173 if let Ok(mut g) = h.lock() {
174 if let Some(v) = g.take() {
175 cm::destroy(&v);
176 }
177 }
178 });
179
180 view! {
181 <div class="cm-wrap">
182 <div node_ref=node class="cm-container"></div>
183 {move || {
184 let err = status.get();
185 match err {
186 Some(e) => view! { <div class="cm-error muted">{e}</div> }
187 .into_view()
188 .into_any(),
189 None => {
190 view! {}.into_any()
191 },
192 }
193 }}
194 </div>
195 }
196}
197