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