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 api_types::FileKind;
7use leptos::prelude::*;
8use wasm_bindgen::JsValue;
9use wasm_bindgen_futures::spawn_local;
10use web_sys::MouseEvent;
11
12use crate::api;
13use crate::cm;
14
15/// What kind of preview a file gets.
16#[derive(Clone, Copy, PartialEq, Eq)]
17pub enum PreviewKind {
18 Image,
19 Pdf,
20 Video,
21 Audio,
22 Text,
23}
24
25/// Which viewer opens this entry, from the server's sniffed [`FileKind`].
26/// `None` = nothing we can show in the browser (download only).
27pub fn preview_kind(kind: FileKind) -> Option<PreviewKind> {
28 match kind {
29 FileKind::Image => Some(PreviewKind::Image),
30 FileKind::Pdf => Some(PreviewKind::Pdf),
31 FileKind::Video => Some(PreviewKind::Video),
32 FileKind::Audio => Some(PreviewKind::Audio),
33 FileKind::Text => Some(PreviewKind::Text),
34 FileKind::Dir | FileKind::Archive | FileKind::Binary => None,
35 }
36}
37
38/// A file the user wants to preview.
39#[derive(Clone)]
40pub struct PreviewTarget {
41 pub root_id: i64,
42 /// Path relative to the root (e.g. "docs/a.txt").
43 pub path: String,
44 /// Display name (used for the title + language detection).
45 pub name: String,
46 #[allow(dead_code)]
47 pub size: u64,
48}
49
50/// The preview overlay.
51#[component]
52pub fn PreviewModal(
53 target: PreviewTarget,
54 kind: PreviewKind,
55 close: Callback<()>,
56) -> impl IntoView {
57 let t = target;
58 let url = match kind {
59 PreviewKind::Text => String::new(),
60 _ => api::preview_url(t.root_id, &t.path),
61 };
62 let name = t.name.clone();
63 // Escape closes the preview.
64 {
65 let c = close;
66 crate::util::owned_window_listener(
67 leptos::ev::keydown,
68 move |ev: web_sys::KeyboardEvent| {
69 if ev.key() == "Escape" {
70 c.run(());
71 }
72 },
73 );
74 }
75 view! {
76 <div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
77 <div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
78 <div class="preview-head">
79 <span class="preview-title">{name}</span>
80 <button class="preview-close" on:click=move |_| close.run(())>
81 "×"
82 </button>
83 </div>
84 <div class="preview-body">
85 {move || match kind {
86 PreviewKind::Image => {
87 view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
88 .into_view()
89 .into_any()
90 }
91 PreviewKind::Pdf => {
92 view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
93 .into_view()
94 .into_any()
95 }
96 PreviewKind::Video => {
97 view! { <video class="preview-video" src=url.clone() controls=true/> }
98 .into_view()
99 .into_any()
100 }
101 PreviewKind::Audio => {
102 view! {
103 <div class="preview-audio">
104 <audio src=url.clone() controls=true/>
105 </div>
106 }
107 .into_view()
108 .into_any()
109 }
110 PreviewKind::Text => view! {
111 <TextPreview target=t.clone()/>
112 }
113 .into_view()
114 .into_any()
115 }}
116 </div>
117 </div>
118 </div>
119 }
120}
121
122// ---------------------------------------------------------------------------
123// Text preview (read-only CodeMirror)
124// ---------------------------------------------------------------------------
125
126#[component]
127fn TextPreview(target: PreviewTarget) -> impl IntoView {
128 let node = NodeRef::<leptos::html::Div>::new();
129 // Some(err) when the file couldn't be loaded.
130 let (status, set_status) = signal(Option::<String>::None);
131 // Holds the live CodeMirror view so we can destroy it on close.
132 let holder: Arc<Mutex<Option<JsValue>>> = Arc::new(Mutex::new(None));
133
134 {
135 let t = target.clone();
136 let st = set_status;
137 let h = holder.clone();
138 node.on_load(move |el: web_sys::HtmlDivElement| {
139 let t = t.clone();
140 let st = st;
141 let h = h.clone();
142 spawn_local(async move {
143 match api::fetch_content(t.root_id, &t.path).await {
144 Ok(text) => match cm::create(&el, &text, &t.name, false, None) {
145 Ok(c) => {
146 let _ = h.lock().map(|mut g| *g = Some(c.view.clone()));
147 }
148 Err(e) => st.set(Some(e)),
149 },
150 Err(e) => st.set(Some(e.to_string())),
151 }
152 });
153 });
154 }
155
156 let h = holder.clone();
157 on_cleanup(move || {
158 if let Ok(mut g) = h.lock()
159 && let Some(v) = g.take()
160 {
161 cm::destroy(&v);
162 }
163 });
164
165 view! {
166 <div class="cm-wrap">
167 <div node_ref=node class="cm-container"></div>
168 {move || {
169 let err = status.get();
170 match err {
171 Some(e) => view! { <div class="cm-error muted">{e}</div> }
172 .into_view()
173 .into_any(),
174 None => {
175 view! {}.into_any()
176 },
177 }
178 }}
179 </div>
180 }
181}
182