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;
12use crate::components::icon::Icon;
13use crate::icons::IconName;
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}
47
48/// The preview overlay.
49#[component]
50pub fn PreviewModal(
51 target: PreviewTarget,
52 kind: PreviewKind,
53 close: Callback<()>,
54) -> impl IntoView {
55 let t = target;
56 let url = match kind {
57 PreviewKind::Text => String::new(),
58 _ => api::preview_url(t.root_id, &t.path),
59 };
60 let name = t.name.clone();
61 // Escape closes the preview.
62 {
63 let c = close;
64 crate::util::owned_window_listener(
65 leptos::ev::keydown,
66 move |ev: web_sys::KeyboardEvent| {
67 if ev.key() == "Escape" {
68 c.run(());
69 }
70 },
71 );
72 }
73 view! {
74 <div class="modal-overlay preview-overlay" on:click=move |_| close.run(())>
75 <div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
76 <div class="preview-head">
77 <span class="preview-title">{name}</span>
78 <button class="preview-close" title="Close" aria-label="Close" on:click=move |_| close.run(())>
79 <Icon name=IconName::Close class="ic-close".to_string()/>
80 </button>
81 </div>
82 <div class="preview-body">
83 {move || match kind {
84 PreviewKind::Image => {
85 view! { <img class="preview-img" src=url.clone() alt=t.name.clone()/> }
86 .into_view()
87 .into_any()
88 }
89 PreviewKind::Pdf => {
90 view! { <iframe class="preview-pdf" src=url.clone() title=t.name.clone()/> }
91 .into_view()
92 .into_any()
93 }
94 PreviewKind::Video => {
95 view! { <video class="preview-video" src=url.clone() controls=true/> }
96 .into_view()
97 .into_any()
98 }
99 PreviewKind::Audio => {
100 view! {
101 <div class="preview-audio">
102 <audio src=url.clone() controls=true/>
103 </div>
104 }
105 .into_view()
106 .into_any()
107 }
108 PreviewKind::Text => view! {
109 <TextPreview target=t.clone()/>
110 }
111 .into_view()
112 .into_any()
113 }}
114 </div>
115 </div>
116 </div>
117 }
118}
119
120// ---------------------------------------------------------------------------
121// Text preview (read-only CodeMirror)
122// ---------------------------------------------------------------------------
123
124#[component]
125fn TextPreview(target: PreviewTarget) -> impl IntoView {
126 let node = NodeRef::<leptos::html::Div>::new();
127 // Some(err) when the file couldn't be loaded.
128 let (status, set_status) = signal(Option::<String>::None);
129 // Holds the live CodeMirror view so we can destroy it on close.
130 let holder = StoredValue::new_local(Option::<JsValue>::None);
131
132 {
133 let t = target.clone();
134 let st = set_status;
135 node.on_load(move |el: web_sys::HtmlDivElement| {
136 let t = t.clone();
137 spawn_local(async move {
138 // The mtime only matters for the editor's conflict check.
139 match api::fetch_content_meta(t.root_id, &t.path).await {
140 Ok((text, _)) => match cm::create(&el, &text, &t.name, false, None) {
141 Ok(c) => holder.set_value(Some(c.view.clone())),
142 Err(e) => st.set(Some(e)),
143 },
144 Err(e) => st.set(Some(e.to_string())),
145 }
146 });
147 });
148 }
149
150 on_cleanup(move || {
151 if let Some(v) = holder.try_update_value(Option::take).flatten() {
152 cm::destroy(&v);
153 }
154 });
155
156 view! {
157 <div class="cm-wrap">
158 <div node_ref=node class="cm-container"></div>
159 {move || {
160 let err = status.get();
161 match err {
162 Some(e) => view! { <div class="cm-error muted">{e}</div> }
163 .into_view()
164 .into_any(),
165 None => {
166 view! {}.into_any()
167 },
168 }
169 }}
170 </div>
171 }
172}
173