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