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