shares.rs
⎇
Raw
1//! Share creation dialog and the "Your shares" management panel (milestone 6).
2
3use leptos::prelude::*;
4use wasm_bindgen::JsCast;
5use wasm_bindgen_futures::spawn_local;
6
7use crate::api::{self, ShareInfo};
8use crate::components::toast::{show, ToastMsg};
9use crate::router::share_url;
10use crate::util::format_date;
11
12// ---------------------------------------------------------------------------
13// Expiry helpers (compute the RFC 3339 expiry client-side)
14// ---------------------------------------------------------------------------
15
16/// Format a millisecond timestamp as an RFC 3339 UTC string.
17fn ms_to_rfc3339(ms: f64) -> Option<String> {
18 if ms.is_nan() {
19 return None;
20 }
21 let secs = (ms / 1000.0) as i64;
22 chrono::DateTime::from_timestamp(secs, 0)
23 .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
24}
25
26/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
27/// "never". Presets are computed from "now"; "custom" parses the
28/// datetime-local value as the browser's local time.
29fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
30 let now = js_sys::Date::now();
31 let h = 60.0 * 60.0 * 1000.0;
32 let target_ms: Option<f64> = match choice {
33 "never" => return None,
34 "1h" => Some(now + h),
35 "1d" => Some(now + 24.0 * h),
36 "1w" => Some(now + 7.0 * 24.0 * h),
37 "1mo" => Some(now + 30.0 * 24.0 * h),
38 "custom" => {
39 if custom.is_empty() {
40 None
41 } else {
42 // `Date.parse` treats a bare datetime as local time, so the
43 // result is already the correct UTC instant.
44 Some(js_sys::Date::parse(custom))
45 }
46 }
47 _ => return None,
48 };
49 target_ms.and_then(ms_to_rfc3339)
50}
51
52fn copy_to_clipboard(text: &str, toast: ToastMsg) {
53 let Some(win) = web_sys::window() else {
54 show(toast, "Copy the link manually".to_string());
55 return;
56 };
57 let cb = win.navigator().clipboard();
58 let t = text.to_string();
59 spawn_local(async move {
60 let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await;
61 show(toast, "Link copied to clipboard".to_string());
62 });
63}
64
65fn select_value(ev: &web_sys::Event) -> Option<String> {
66 ev.target()
67 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
68 .map(|s| s.value())
69}
70
71// ---------------------------------------------------------------------------
72// Share creation dialog (two-phase: form -> link)
73// ---------------------------------------------------------------------------
74
75#[component]
76pub fn ShareDialog(
77 name: String,
78 root_id: i64,
79 path: String,
80 allow_writable: bool,
81 close: Callback<()>,
82) -> impl IntoView {
83 let toast = use_context::<ToastMsg>().expect("toast context");
84 let (writable, set_writable) = signal(false);
85 let (choice, set_choice) = signal("never".to_string());
86 let (custom, set_custom) = signal(String::new());
87 let (created, set_created) = signal(Option::<ShareInfo>::None);
88 let (busy, set_busy) = signal(false);
89
90 let do_create = move |_| {
91 if busy.get() {
92 return;
93 }
94 set_busy.set(true);
95 let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
96 let wr = writable.get();
97 let rid = root_id;
98 let p = path.clone();
99 spawn_local(async move {
100 match api::create_share(rid, &p, wr, exp.as_deref()).await {
101 Ok(info) => set_created.set(Some(info)),
102 Err(e) => {
103 show(toast, format!("Could not create share: {e}"));
104 set_busy.set(false);
105 }
106 }
107 });
108 };
109
110 view! {
111 <div class="modal-overlay" on:click=move |_| close.run(())>
112 <div
113 class="modal share-modal"
114 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
115 >
116 {move || match created.get() {
117 Some(ref info) => {
118 let link = share_url(&info.token);
119 view! {
120 <h2 class="modal-title">"Share link"</h2>
121 <p class="modal-message">
122 "Anyone with this link can "
123 {if writable.get() { "read and write " } else { "read " }}
124 {format!("“{name}”.")}
125 </p>
126 <div class="share-link-row">
127 <input
128 class="share-link-input"
129 readonly=true
130 value=link.clone()
131 on:click=move |ev: web_sys::MouseEvent| {
132 if let Some(i) = ev
133 .target()
134 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
135 {
136 i.select();
137 }
138 }
139 />
140 <button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>"Copy"</button>
141 </div>
142 <p class="muted share-expiry-note">
143 {match info.expires_at {
144 Some(ref e) => format!("Expires {0}", format_date(e)),
145 None => "Never expires".to_string(),
146 }}
147 </p>
148 <div class="modal-actions">
149 <button class="btn btn-primary" on:click=move |_| close.run(())>"Done"</button>
150 </div>
151 }
152 .into_view()
153 .into_any()
154 }
155 None => view! {
156 <h2 class="modal-title">{format!("Share {name}")}</h2>
157 <p class="modal-message">"Create a link for this item. Writable links let viewers upload and edit."</p>
158 {move || if allow_writable {
159 view! {
160 <label class="share-check">
161 <input
162 type="checkbox"
163 checked=move || writable.get()
164 on:change=move |ev: web_sys::Event| {
165 if let Some(t) = ev
166 .target()
167 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
168 {
169 set_writable.set(t.checked());
170 }
171 }
172 />
173 "Allow editing (read-write)"
174 </label>
175 }
176 .into_view()
177 .into_any()
178 } else {
179 view! {
180 <p class="muted">"Writable shares are disabled by the server."</p>
181 }
182 .into_view()
183 .into_any()
184 }}
185 <div class="share-expiry-row">
186 <label for="share-expiry">"Expires"</label>
187 <select
188 id="share-expiry"
189 on:change=move |ev: web_sys::Event| {
190 if let Some(v) = select_value(&ev) {
191 set_choice.set(v);
192 }
193 }
194 >
195 <option value="never">"Never"</option>
196 <option value="1h">"1 hour"</option>
197 <option value="1d">"1 day"</option>
198 <option value="1w">"1 week"</option>
199 <option value="1mo">"1 month"</option>
200 <option value="custom">"Custom…"</option>
201 </select>
202 </div>
203 {move || if choice.get() == "custom" {
204 view! {
205 <div class="share-expiry-row">
206 <label for="share-custom-dt">"Until"</label>
207 <input
208 type="datetime-local"
209 id="share-custom-dt"
210 value=move || custom.get()
211 on:change=move |ev: web_sys::Event| {
212 if let Some(t) = ev
213 .target()
214 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
215 {
216 set_custom.set(t.value());
217 }
218 }
219 />
220 </div>
221 }
222 .into_view()
223 .into_any()
224 } else {
225 view! {}.into_any()
226 }}
227 <div class="modal-actions">
228 <button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
229 <button
230 class="btn btn-primary"
231 disabled=move || busy.get()
232 on:click=do_create.clone()
233 >
234 {move || if busy.get() { "Creating…" } else { "Create link" }}
235 </button>
236 </div>
237 }
238 .into_view()
239 .into_any(),
240 }}
241 </div>
242 </div>
243 }
244}
245
246// ---------------------------------------------------------------------------
247// "Your shares" management panel
248// ---------------------------------------------------------------------------
249
250#[component]
251pub fn SharesPanel(close: Callback<()>) -> impl IntoView {
252 let toast = use_context::<ToastMsg>().expect("toast context");
253 let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
254 {
255 let set = set_shares;
256 let toast2 = toast;
257 spawn_local(async move {
258 match api::list_shares().await {
259 Ok(s) => set.set(Some(s)),
260 Err(e) => show(toast2, e.to_string()),
261 }
262 });
263 }
264
265 // Escape closes the panel.
266 {
267 let c = close;
268 let _h = window_event_listener(leptos::ev::keydown, move |ev: web_sys::KeyboardEvent| {
269 if ev.key() == "Escape" {
270 c.run(());
271 }
272 });
273 }
274
275 view! {
276 <div class="modal-overlay" on:click=move |_| close.run(())>
277 <div
278 class="modal shares-panel"
279 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
280 >
281 <h2 class="modal-title">"Your shares"</h2>
282 {move || match shares.get() {
283 None => view! {
284 <p class="muted">"Loading…"</p>
285 }
286 .into_view()
287 .into_any(),
288 Some(ref list) if list.is_empty() => view! {
289 <p class="muted">"No shares yet. Right-click a file or folder and choose “Share”."</p>
290 }
291 .into_view()
292 .into_any(),
293 Some(ref list) => {
294 let items = list
295 .iter()
296 .map(|s| {
297 let s2 = s.clone();
298 let link = share_url(&s.token);
299 let mut meta = format!(
300 "{} · {}",
301 s.target,
302 if s.writable { "read-write" } else { "read-only" }
303 );
304 if let Some(e) = &s.expires_at {
305 meta.push_str(&format!(" · expires {}", format_date(e)));
306 }
307 meta.push_str(&format!(" · created {}", format_date(&s.created_at)));
308 view! {
309 <div class="share-item">
310 <div class="share-item-main">
311 <div class="share-item-name">
312 {if s.is_file { "📄" } else { "📁" }}
313 {" "}{s.name.clone()}
314 </div>
315 <div class="share-item-meta muted">{meta}</div>
316 </div>
317 <div class="share-item-actions">
318 <button
319 class="btn btn-sm"
320 title="Copy link"
321 on:click=move |_| copy_to_clipboard(&link, toast)
322 >"Copy"</button>
323 <button
324 class="btn btn-sm btn-danger"
325 title="Delete share"
326 on:click=move |_| {
327 let id = s2.id;
328 let read = shares;
329 let write = set_shares;
330 let toast2 = toast;
331 spawn_local(async move {
332 if api::delete_share(id).await.is_ok() {
333 if let Some(mut cur) = read.get() {
334 cur.retain(|x| x.id != id);
335 write.set(Some(cur));
336 }
337 show(toast2, "Share deleted");
338 } else {
339 show(toast2, "Could not delete share".to_string());
340 }
341 });
342 }
343 >"Delete"</button>
344 </div>
345 </div>
346 }
347 })
348 .collect::<Vec<_>>();
349 view! {
350 <div class="share-list">{items}</div>
351 }
352 }
353 .into_view()
354 .into_any(),
355 }}
356 <div class="modal-actions">
357 <button class="btn" on:click=move |_| close.run(())>"Close"</button>
358 </div>
359 </div>
360 </div>
361 }
362}
363