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::icon::Icon;
9use crate::components::toast::{ToastMsg, show, show_error};
10use crate::i18n;
11use crate::icons::IconName;
12use crate::router::share_url;
13use crate::util::format_date;
14
15// ---------------------------------------------------------------------------
16// Expiry helpers (compute the RFC 3339 expiry client-side)
17// ---------------------------------------------------------------------------
18
19/// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601).
20fn ms_to_rfc3339(ms: f64) -> Option<String> {
21 if ms.is_nan() {
22 return None;
23 }
24 Some(
25 js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
26 .to_iso_string()
27 .into(),
28 )
29}
30
31/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
32/// "never". Presets are computed from "now"; "custom" parses the
33/// datetime-local value as the browser's local time.
34fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
35 let now = js_sys::Date::now();
36 let h = 60.0 * 60.0 * 1000.0;
37 let target_ms: Option<f64> = match choice {
38 "never" => return None,
39 "1h" => Some(now + h),
40 "1d" => Some(now + 24.0 * h),
41 "1w" => Some(now + 7.0 * 24.0 * h),
42 "1mo" => Some(now + 30.0 * 24.0 * h),
43 "custom" => {
44 if custom.is_empty() {
45 None
46 } else {
47 // `Date.parse` treats a bare datetime as local time, so the
48 // result is already the correct UTC instant.
49 Some(js_sys::Date::parse(custom))
50 }
51 }
52 _ => return None,
53 };
54 target_ms.and_then(ms_to_rfc3339)
55}
56
57fn copy_to_clipboard(text: &str, toast: ToastMsg) {
58 let Some(win) = web_sys::window() else {
59 show_error(toast, i18n::t(i18n::k::COPY_MANUAL).to_string());
60 return;
61 };
62 let cb = win.navigator().clipboard();
63 let t = text.to_string();
64 spawn_local(async move {
65 let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await;
66 show(toast, i18n::t(i18n::k::LINK_COPIED).to_string());
67 });
68}
69
70fn select_value(ev: &web_sys::Event) -> Option<String> {
71 ev.target()
72 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
73 .map(|s| s.value())
74}
75
76// ---------------------------------------------------------------------------
77// Share creation dialog (two-phase: form -> link)
78// ---------------------------------------------------------------------------
79
80#[component]
81pub fn ShareDialog(
82 name: String,
83 root_id: i64,
84 path: String,
85 allow_writable: bool,
86 root_writable: bool,
87 close: Callback<()>,
88) -> impl IntoView {
89 let toast = use_context::<ToastMsg>().expect("toast context");
90 let (writable, set_writable) = signal(false);
91 let (choice, set_choice) = signal("never".to_string());
92 let (custom, set_custom) = signal(String::new());
93 let (created, set_created) = signal(Option::<ShareInfo>::None);
94 let (busy, set_busy) = signal(false);
95 let name_msg = name.clone();
96
97 let do_create = move |_| {
98 if busy.get() {
99 return;
100 }
101 set_busy.set(true);
102 let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
103 let wr = writable.get();
104 let rid = root_id;
105 let p = path.clone();
106 spawn_local(async move {
107 match api::create_share(rid, &p, wr, exp.as_deref()).await {
108 Ok(info) => set_created.set(Some(info)),
109 Err(e) => {
110 show(
111 toast,
112 i18n::t_fmt(i18n::k::SHARE_CREATE_ERR, &e.to_string()),
113 );
114 set_busy.set(false);
115 }
116 }
117 });
118 };
119
120 view! {
121 <div class="modal-overlay" on:click=move |_| close.run(())>
122 <div
123 class="modal share-modal"
124 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
125 >
126 {move || match created.get() {
127 Some(ref info) => {
128 let link = share_url(&info.token);
129 view! {
130 <h2 class="modal-title">{i18n::tr(i18n::k::SHARE_LINK)}</h2>
131 <p class="modal-message">
132 {if writable.get() {
133 i18n::t_fmt(i18n::k::SHARE_PERM_RW, &name_msg)
134 } else {
135 i18n::t_fmt(i18n::k::SHARE_PERM_RO, &name_msg)
136 }}
137 </p>
138 <div class="share-link-row">
139 <input
140 class="share-link-input"
141 readonly=true
142 value=link.clone()
143 on:click=move |ev: web_sys::MouseEvent| {
144 if let Some(i) = ev
145 .target()
146 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
147 {
148 i.select();
149 }
150 }
151 />
152 <button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>{i18n::t(i18n::k::COPY)}</button>
153 </div>
154 <p class="muted share-expiry-note">
155 {match info.expires_at {
156 Some(ref e) => i18n::t_fmt(i18n::k::EXPIRES_AT, &format_date(e)),
157 None => i18n::t(i18n::k::NEVER_EXPIRES).to_string(),
158 }}
159 </p>
160 <div class="modal-actions">
161 <button class="btn btn-primary" on:click=move |_| close.run(())>{i18n::t(i18n::k::DONE)}</button>
162 </div>
163 }
164 .into_view()
165 .into_any()
166 }
167 None => view! {
168 <h2 class="modal-title">{i18n::t_fmt(i18n::k::SHARE_TITLE, &name)}</h2>
169 <p class="modal-message">{i18n::tr(i18n::k::SHARE_DIALOG_HINT)}</p>
170 {move || if allow_writable && root_writable {
171 view! {
172 <label class="share-check">
173 <input
174 type="checkbox"
175 checked=move || writable.get()
176 on:change=move |ev: web_sys::Event| {
177 if let Some(t) = ev
178 .target()
179 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
180 {
181 set_writable.set(t.checked());
182 }
183 }
184 />
185 {i18n::tr(i18n::k::ALLOW_EDITING)}
186 </label>
187 }
188 .into_view()
189 .into_any()
190 } else {
191 view! {
192 <p class="muted">
193 {move || {
194 if root_writable {
195 i18n::t(i18n::k::RW_DISABLED).to_string()
196 } else {
197 i18n::t(i18n::k::RW_RO_FOLDER).to_string()
198 }
199 }}
200 </p>
201 }
202 .into_view()
203 .into_any()
204 }}
205 <div class="share-expiry-row">
206 <label for="share-expiry">{i18n::tr(i18n::k::EXPIRES)}</label>
207 <select
208 id="share-expiry"
209 on:change=move |ev: web_sys::Event| {
210 if let Some(v) = select_value(&ev) {
211 set_choice.set(v);
212 }
213 }
214 >
215 <option value="never">{i18n::tr(i18n::k::NEVER)}</option>
216 <option value="1h">{i18n::t(i18n::k::EXPIRY_1H)}</option>
217 <option value="1d">{i18n::t(i18n::k::EXPIRY_1D)}</option>
218 <option value="1w">{i18n::t(i18n::k::EXPIRY_1W)}</option>
219 <option value="1mo">{i18n::t(i18n::k::EXPIRY_1MO)}</option>
220 <option value="custom">{i18n::tr(i18n::k::CUSTOM)}</option>
221 </select>
222 </div>
223 {move || if choice.get() == "custom" {
224 view! {
225 <div class="share-expiry-row">
226 <label for="share-custom-dt">{i18n::tr(i18n::k::UNTIL)}</label>
227 <input
228 type="datetime-local"
229 id="share-custom-dt"
230 value=move || custom.get()
231 on:change=move |ev: web_sys::Event| {
232 if let Some(t) = ev
233 .target()
234 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
235 {
236 set_custom.set(t.value());
237 }
238 }
239 />
240 </div>
241 }
242 .into_view()
243 .into_any()
244 } else {
245 view! {}.into_any()
246 }}
247 <div class="modal-actions">
248 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
249 <button
250 class="btn btn-primary"
251 disabled=move || busy.get()
252 on:click=do_create.clone()
253 >
254 {move || {
255 if busy.get() {
256 i18n::t(i18n::k::CREATING).to_string()
257 } else {
258 i18n::t(i18n::k::CREATE_LINK).to_string()
259 }
260 }}
261 </button>
262 </div>
263 }
264 .into_view()
265 .into_any(),
266 }}
267 </div>
268 </div>
269 }
270}
271
272// ---------------------------------------------------------------------------
273// "Your shares" manage view
274// ---------------------------------------------------------------------------
275
276#[component]
277pub fn SharesView() -> impl IntoView {
278 let toast = use_context::<ToastMsg>().expect("toast context");
279 let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
280 let (confirm_all, set_confirm_all) = signal(false);
281 let (busy_all, set_busy_all) = signal(false);
282 {
283 let set = set_shares;
284 let toast2 = toast;
285 spawn_local(async move {
286 match api::list_shares().await {
287 Ok(s) => set.set(Some(s)),
288 Err(e) => show_error(toast2, e.to_string()),
289 }
290 });
291 }
292
293 // Delete every share of the current user (no server-side bulk endpoint).
294 let delete_all = move |_| {
295 set_confirm_all.set(false);
296 let Some(list) = shares.get() else {
297 return;
298 };
299 if list.is_empty() {
300 return;
301 }
302 let ids: Vec<i64> = list.iter().map(|s| s.id).collect();
303 set_busy_all.set(true);
304 let write = set_shares;
305 let toast2 = toast;
306 spawn_local(async move {
307 let mut failed = 0usize;
308 for id in &ids {
309 if api::delete_share(*id).await.is_err() {
310 failed += 1;
311 }
312 }
313 if failed == 0 {
314 write.set(Some(Vec::new()));
315 show(
316 toast2,
317 i18n::t(i18n::k::DELETED_N_SHARES)
318 .to_string()
319 .replace("{}", &ids.len().to_string()),
320 );
321 } else if failed < ids.len() {
322 // Partial failure: re-read what is actually left.
323 if let Ok(s) = api::list_shares().await {
324 write.set(Some(s));
325 }
326 show(
327 toast2,
328 i18n::t(i18n::k::DELETE_PARTIAL)
329 .replace("{}", &failed.to_string())
330 .replace("{}", &ids.len().to_string()),
331 );
332 } else {
333 show_error(toast2, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
334 }
335 set_busy_all.set(false);
336 });
337 };
338
339 view! {
340 <div class="shares-view">
341 <div class="view-header">
342 <div class="users-header">
343 <h3 class="admin-section-title">{i18n::tr(i18n::k::YOUR_SHARES)}</h3>
344 {move || {
345 let n = shares.get().map(|s| s.len()).unwrap_or(0);
346 if n > 0 {
347 view! {
348 <button
349 class="btn btn-sm btn-danger"
350 disabled=move || busy_all.get()
351 on:click=move |_| set_confirm_all.set(true)
352 >
353 <Icon name=IconName::Delete class="ic-btn".to_string()/>
354 {i18n::tr(i18n::k::DELETE_ALL)}
355 </button>
356 }
357 .into_view()
358 .into_any()
359 } else {
360 view! {}.into_any()
361 }
362 }}
363 </div>
364 <p class="muted view-hint">{i18n::tr(i18n::k::SHARES_VIEW_HINT)}</p>
365 </div>
366 {move || match shares.get() {
367 None => view! {
368 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
369 }
370 .into_view()
371 .into_any(),
372 Some(ref list) if list.is_empty() => view! {
373 <div class="empty-state">
374 <Icon name=IconName::Share class="empty-glyph".to_string()/>
375 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
376 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
377 </div>
378 }
379 .into_view()
380 .into_any(),
381 Some(ref list) => {
382 let items = list
383 .iter()
384 .map(|s| {
385 let s2 = s.clone();
386 let link = share_url(&s.token);
387 let glyph = if s.is_file {
388 IconName::File
389 } else {
390 IconName::Folder
391 };
392 let mut meta = format!(
393 "{} · {}",
394 s.target,
395 if s.writable {
396 i18n::t(i18n::k::MODE_RW)
397 } else {
398 i18n::t(i18n::k::MODE_RO)
399 }
400 );
401 if let Some(e) = &s.expires_at {
402 meta.push_str(&format!(
403 " · {}",
404 i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
405 ));
406 }
407 meta.push_str(&format!(
408 " · {}",
409 i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
410 ));
411 view! {
412 <div class="share-item">
413 <div class="share-item-main">
414 <div class="share-item-name">
415 <Icon name=glyph class="ic-row".to_string()/>
416 <span>{s.name.clone()}</span>
417 </div>
418 <div class="share-item-meta muted">{meta}</div>
419 </div>
420 <div class="share-item-actions">
421 <button
422 class="btn btn-sm"
423 title={i18n::tr(i18n::k::COPY_LINK)}
424 on:click=move |_| copy_to_clipboard(&link, toast)
425 >
426 <Icon name=IconName::Copy class="ic-btn".to_string()/>
427 {i18n::tr(i18n::k::COPY)}
428 </button>
429 <button
430 class="btn btn-sm btn-danger"
431 title={i18n::tr(i18n::k::DELETE_SHARE)}
432 on:click=move |_| {
433 let id = s2.id;
434 let read = shares;
435 let write = set_shares;
436 let toast2 = toast;
437 spawn_local(async move {
438 if api::delete_share(id).await.is_ok() {
439 if let Some(mut cur) = read.get() {
440 cur.retain(|x| x.id != id);
441 write.set(Some(cur));
442 }
443 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
444 } else {
445 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
446 }
447 });
448 }
449 >
450 <Icon name=IconName::Delete class="ic-btn".to_string()/>
451 {i18n::tr(i18n::k::DELETE)}
452 </button>
453 </div>
454 </div>
455 }
456 })
457 .collect::<Vec<_>>();
458 view! {
459 <div class="share-list">{items}</div>
460 }
461 .into_view()
462 .into_any()
463 }
464 }}
465
466 // Delete-all confirmation.
467 {move || {
468 if confirm_all.get() {
469 let n = shares.get().map(|s| s.len()).unwrap_or(0);
470 view! {
471 <div class="modal-overlay" on:click=move |_| set_confirm_all.set(false)>
472 <div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
473 <h2 class="modal-title">{i18n::tr(i18n::k::DELETE_ALL_TITLE)}</h2>
474 <p class="modal-message">
475 {i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &n.to_string())}
476 </p>
477 <div class="modal-actions">
478 <button class="btn" on:click=move |_| set_confirm_all.set(false)>
479 {i18n::tr(i18n::k::CANCEL)}
480 </button>
481 <button class="btn btn-danger" disabled=move || busy_all.get() on:click=delete_all>
482 {i18n::tr(i18n::k::DELETE_ALL)}
483 </button>
484 </div>
485 </div>
486 </div>
487 }
488 .into_view()
489 .into_any()
490 } else {
491 view! {}.into_any()
492 }
493 }}
494 </div>
495 }
496}
497