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 // Set when the list could not be loaded; the error card offers a retry.
281 let (load_err, set_load_err) = signal(Option::<String>::None);
282 let (confirm_all, set_confirm_all) = signal(false);
283 let (busy_all, set_busy_all) = signal(false);
284 let load = move || {
285 set_load_err.set(None);
286 spawn_local(async move {
287 match api::list_shares().await {
288 Ok(s) => set_shares.set(Some(s)),
289 Err(e) => set_load_err.set(Some(e.to_string())),
290 }
291 });
292 };
293 load();
294
295 // Delete every share of the current user (no server-side bulk endpoint).
296 let delete_all = move |_| {
297 set_confirm_all.set(false);
298 let Some(list) = shares.get() else {
299 return;
300 };
301 if list.is_empty() {
302 return;
303 }
304 let ids: Vec<i64> = list.iter().map(|s| s.id).collect();
305 set_busy_all.set(true);
306 let write = set_shares;
307 let toast2 = toast;
308 spawn_local(async move {
309 let mut failed = 0usize;
310 for id in &ids {
311 if api::delete_share(*id).await.is_err() {
312 failed += 1;
313 }
314 }
315 if failed == 0 {
316 write.set(Some(Vec::new()));
317 show(
318 toast2,
319 i18n::t(i18n::k::DELETED_N_SHARES)
320 .to_string()
321 .replace("{}", &ids.len().to_string()),
322 );
323 } else if failed < ids.len() {
324 // Partial failure: re-read what is actually left.
325 if let Ok(s) = api::list_shares().await {
326 write.set(Some(s));
327 }
328 show(
329 toast2,
330 i18n::t(i18n::k::DELETE_PARTIAL)
331 .replace("{}", &failed.to_string())
332 .replace("{}", &ids.len().to_string()),
333 );
334 } else {
335 show_error(toast2, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
336 }
337 set_busy_all.set(false);
338 });
339 };
340
341 view! {
342 <div class="shares-view">
343 <div class="view-header">
344 <div class="users-header">
345 <h3 class="admin-section-title">{i18n::tr(i18n::k::YOUR_SHARES)}</h3>
346 {move || {
347 let n = shares.get().map(|s| s.len()).unwrap_or(0);
348 if n > 0 {
349 view! {
350 <button
351 class="btn btn-sm btn-danger"
352 disabled=move || busy_all.get()
353 on:click=move |_| set_confirm_all.set(true)
354 >
355 <Icon name=IconName::Delete class="ic-btn".to_string()/>
356 {i18n::tr(i18n::k::DELETE_ALL)}
357 </button>
358 }
359 .into_view()
360 .into_any()
361 } else {
362 view! {}.into_any()
363 }
364 }}
365 </div>
366 <p class="muted view-hint">{i18n::tr(i18n::k::SHARES_VIEW_HINT)}</p>
367 </div>
368 {move || match shares.get() {
369 None => match load_err.get() {
370 Some(msg) => view! {
371 <div class="card error-card">
372 <span>{msg}</span>
373 <button class="btn" on:click=move |_| load()>
374 {i18n::tr(i18n::k::RETRY)}
375 </button>
376 </div>
377 }
378 .into_view()
379 .into_any(),
380 None => view! {
381 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
382 }
383 .into_view()
384 .into_any(),
385 },
386 Some(ref list) if list.is_empty() => view! {
387 <div class="empty-state">
388 <Icon name=IconName::Share class="empty-glyph".to_string()/>
389 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
390 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
391 </div>
392 }
393 .into_view()
394 .into_any(),
395 Some(ref list) => {
396 let items = list
397 .iter()
398 .map(|s| {
399 let s2 = s.clone();
400 let link = share_url(&s.token);
401 let glyph = if s.is_file {
402 IconName::File
403 } else {
404 IconName::Folder
405 };
406 let mut meta = format!(
407 "{} · {}",
408 s.target,
409 if s.writable {
410 i18n::t(i18n::k::MODE_RW)
411 } else {
412 i18n::t(i18n::k::MODE_RO)
413 }
414 );
415 if let Some(e) = &s.expires_at {
416 meta.push_str(&format!(
417 " · {}",
418 i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
419 ));
420 }
421 meta.push_str(&format!(
422 " · {}",
423 i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
424 ));
425 view! {
426 <div class="share-item">
427 <div class="share-item-main">
428 <div class="share-item-name">
429 <Icon name=glyph class="ic-row".to_string()/>
430 <span>{s.name.clone()}</span>
431 </div>
432 <div class="share-item-meta muted">{meta}</div>
433 </div>
434 <div class="share-item-actions">
435 <button
436 class="btn btn-sm"
437 title={i18n::tr(i18n::k::COPY_LINK)}
438 on:click=move |_| copy_to_clipboard(&link, toast)
439 >
440 <Icon name=IconName::Copy class="ic-btn".to_string()/>
441 {i18n::tr(i18n::k::COPY)}
442 </button>
443 <button
444 class="btn btn-sm btn-danger"
445 title={i18n::tr(i18n::k::DELETE_SHARE)}
446 on:click=move |_| {
447 let id = s2.id;
448 let read = shares;
449 let write = set_shares;
450 let toast2 = toast;
451 spawn_local(async move {
452 if api::delete_share(id).await.is_ok() {
453 if let Some(mut cur) = read.get() {
454 cur.retain(|x| x.id != id);
455 write.set(Some(cur));
456 }
457 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
458 } else {
459 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
460 }
461 });
462 }
463 >
464 <Icon name=IconName::Delete class="ic-btn".to_string()/>
465 {i18n::tr(i18n::k::DELETE)}
466 </button>
467 </div>
468 </div>
469 }
470 })
471 .collect::<Vec<_>>();
472 view! {
473 <div class="share-list">{items}</div>
474 }
475 .into_view()
476 .into_any()
477 }
478 }}
479
480 // Delete-all confirmation.
481 {move || {
482 if confirm_all.get() {
483 let n = shares.get().map(|s| s.len()).unwrap_or(0);
484 view! {
485 <div class="modal-overlay" on:click=move |_| set_confirm_all.set(false)>
486 <div class="modal card" on:click=move |e: web_sys::MouseEvent| e.stop_propagation()>
487 <h2 class="modal-title">{i18n::tr(i18n::k::DELETE_ALL_TITLE)}</h2>
488 <p class="modal-message">
489 {i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &n.to_string())}
490 </p>
491 <div class="modal-actions">
492 <button class="btn" on:click=move |_| set_confirm_all.set(false)>
493 {i18n::tr(i18n::k::CANCEL)}
494 </button>
495 <button class="btn btn-danger" disabled=move || busy_all.get() on:click=delete_all>
496 {i18n::tr(i18n::k::DELETE_ALL)}
497 </button>
498 </div>
499 </div>
500 </div>
501 }
502 .into_view()
503 .into_any()
504 } else {
505 view! {}.into_any()
506 }
507 }}
508 </div>
509 }
510}
511