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 (created, set_created) = signal(Option::<ShareInfo>::None);
90 let (busy, set_busy) = signal(false);
91 let name_msg = name.clone();
92
93 let do_create = move |_| {
94 if busy.get() {
95 return;
96 }
97 set_busy.set(true);
98 let exp = expiry_to_rfc3339(&choice.get(), &custom.get());
99 let wr = writable.get();
100 let rid = root_id;
101 let p = path.clone();
102 spawn_local(async move {
103 match api::create_share(rid, &p, wr, exp.as_deref()).await {
104 Ok(info) => set_created.set(Some(info)),
105 Err(e) => {
106 show(
107 toast,
108 i18n::t_fmt(i18n::k::SHARE_CREATE_ERR, &e.to_string()),
109 );
110 set_busy.set(false);
111 }
112 }
113 });
114 };
115
116 view! {
117 <Modal class="share-modal" on_close=close>
118 {move || match created.get() {
119 Some(ref info) => {
120 let link = share_url(&info.token);
121 view! {
122 <h2 class="modal-title">{i18n::tr(i18n::k::SHARE_LINK)}</h2>
123 <p class="modal-message">
124 {if writable.get() {
125 i18n::t_fmt(i18n::k::SHARE_PERM_RW, &name_msg)
126 } else {
127 i18n::t_fmt(i18n::k::SHARE_PERM_RO, &name_msg)
128 }}
129 </p>
130 <div class="share-link-row">
131 <input
132 class="share-link-input"
133 readonly=true
134 value=link.clone()
135 on:click=move |ev: web_sys::MouseEvent| {
136 if let Some(i) = ev
137 .target()
138 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
139 {
140 i.select();
141 }
142 }
143 />
144 <button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>{i18n::t(i18n::k::COPY)}</button>
145 </div>
146 <p class="muted share-expiry-note">
147 {match info.expires_at {
148 Some(ref e) => i18n::t_fmt(i18n::k::EXPIRES_AT, &format_date(e)),
149 None => i18n::t(i18n::k::NEVER_EXPIRES).to_string(),
150 }}
151 </p>
152 <div class="modal-actions">
153 <button class="btn btn-primary" on:click=move |_| close.run(())>{i18n::t(i18n::k::DONE)}</button>
154 </div>
155 }
156 .into_view()
157 .into_any()
158 }
159 None => view! {
160 <h2 class="modal-title">{i18n::t_fmt(i18n::k::SHARE_TITLE, &name)}</h2>
161 <p class="modal-message">{i18n::tr(i18n::k::SHARE_DIALOG_HINT)}</p>
162 {move || if allow_writable && root_writable {
163 view! {
164 <label class="share-check">
165 <input
166 type="checkbox"
167 checked=move || writable.get()
168 on:change=move |ev: web_sys::Event| {
169 if let Some(t) = ev
170 .target()
171 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
172 {
173 set_writable.set(t.checked());
174 }
175 }
176 />
177 {i18n::tr(i18n::k::ALLOW_EDITING)}
178 </label>
179 }
180 .into_view()
181 .into_any()
182 } else {
183 view! {
184 <p class="muted">
185 {move || {
186 if root_writable {
187 i18n::t(i18n::k::RW_DISABLED).to_string()
188 } else {
189 i18n::t(i18n::k::RW_RO_FOLDER).to_string()
190 }
191 }}
192 </p>
193 }
194 .into_view()
195 .into_any()
196 }}
197 <div class="share-expiry-row">
198 <label for="share-expiry">{i18n::tr(i18n::k::EXPIRES)}</label>
199 <select
200 id="share-expiry"
201 on:change=move |ev: web_sys::Event| {
202 if let Some(v) = select_value(&ev) {
203 set_choice.set(v);
204 }
205 }
206 >
207 <option value="never">{i18n::tr(i18n::k::NEVER)}</option>
208 <option value="1h">{i18n::t(i18n::k::EXPIRY_1H)}</option>
209 <option value="1d">{i18n::t(i18n::k::EXPIRY_1D)}</option>
210 <option value="1w">{i18n::t(i18n::k::EXPIRY_1W)}</option>
211 <option value="1mo">{i18n::t(i18n::k::EXPIRY_1MO)}</option>
212 <option value="custom">{i18n::tr(i18n::k::CUSTOM)}</option>
213 </select>
214 </div>
215 {move || if choice.get() == "custom" {
216 view! {
217 <div class="share-expiry-row">
218 <label for="share-custom-dt">{i18n::tr(i18n::k::UNTIL)}</label>
219 <input
220 type="datetime-local"
221 id="share-custom-dt"
222 value=move || custom.get()
223 on:change=move |ev: web_sys::Event| {
224 if let Some(t) = ev
225 .target()
226 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
227 {
228 set_custom.set(t.value());
229 }
230 }
231 />
232 </div>
233 }
234 .into_view()
235 .into_any()
236 } else {
237 view! {}.into_any()
238 }}
239 <div class="modal-actions">
240 <button class="btn" on:click=move |_| close.run(())>{i18n::tr(i18n::k::CANCEL)}</button>
241 <button
242 class="btn btn-primary"
243 disabled=move || busy.get()
244 on:click=do_create.clone()
245 >
246 {move || {
247 if busy.get() {
248 i18n::t(i18n::k::CREATING).to_string()
249 } else {
250 i18n::t(i18n::k::CREATE_LINK).to_string()
251 }
252 }}
253 </button>
254 </div>
255 }
256 .into_view()
257 .into_any(),
258 }}
259 </Modal>
260 }
261}
262
263// ---------------------------------------------------------------------------
264// "Your shares" manage view
265// ---------------------------------------------------------------------------
266
267#[component]
268pub fn SharesView() -> impl IntoView {
269 let toast = use_context::<ToastMsg>().expect("toast context");
270 let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
271 // Set when the list could not be loaded; the error card offers a retry.
272 let (load_err, set_load_err) = signal(Option::<String>::None);
273 let (confirm_all, set_confirm_all) = signal(false);
274 let (busy_all, set_busy_all) = signal(false);
275 let load = move || {
276 set_load_err.set(None);
277 spawn_local(async move {
278 match api::list_shares().await {
279 Ok(s) => set_shares.set(Some(s)),
280 Err(e) => set_load_err.set(Some(e.to_string())),
281 }
282 });
283 };
284 load();
285
286 // Delete every share of the current user (no server-side bulk endpoint).
287 let delete_all = move |_| {
288 set_confirm_all.set(false);
289 let Some(list) = shares.get() else {
290 return;
291 };
292 if list.is_empty() {
293 return;
294 }
295 let ids: Vec<i64> = list.iter().map(|s| s.id).collect();
296 set_busy_all.set(true);
297 let write = set_shares;
298 let toast2 = toast;
299 spawn_local(async move {
300 let mut failed = 0usize;
301 for id in &ids {
302 if api::delete_share(*id).await.is_err() {
303 failed += 1;
304 }
305 }
306 if failed == 0 {
307 write.set(Some(Vec::new()));
308 show(
309 toast2,
310 i18n::t(i18n::k::DELETED_N_SHARES)
311 .to_string()
312 .replace("{}", &ids.len().to_string()),
313 );
314 } else if failed < ids.len() {
315 // Partial failure: re-read what is actually left.
316 if let Ok(s) = api::list_shares().await {
317 write.set(Some(s));
318 }
319 show(
320 toast2,
321 i18n::t(i18n::k::DELETE_PARTIAL)
322 .replace("{}", &failed.to_string())
323 .replace("{}", &ids.len().to_string()),
324 );
325 } else {
326 show_error(toast2, i18n::t(i18n::k::SHARES_DELETE_ERR).to_string());
327 }
328 set_busy_all.set(false);
329 });
330 };
331
332 view! {
333 <div class="shares-view">
334 <div class="view-header">
335 <div class="users-header">
336 <h3 class="admin-section-title">{i18n::tr(i18n::k::YOUR_SHARES)}</h3>
337 {move || {
338 let n = shares.get().map(|s| s.len()).unwrap_or(0);
339 if n > 0 {
340 view! {
341 <button
342 class="btn btn-sm btn-danger"
343 disabled=move || busy_all.get()
344 on:click=move |_| set_confirm_all.set(true)
345 >
346 <Icon name=IconName::Delete class="ic-btn".to_string()/>
347 {i18n::tr(i18n::k::DELETE_ALL)}
348 </button>
349 }
350 .into_view()
351 .into_any()
352 } else {
353 view! {}.into_any()
354 }
355 }}
356 </div>
357 <p class="muted view-hint">{i18n::tr(i18n::k::SHARES_VIEW_HINT)}</p>
358 </div>
359 {move || match shares.get() {
360 None => match load_err.get() {
361 Some(msg) => view! {
362 <div class="card error-card">
363 <span>{msg}</span>
364 <button class="btn" on:click=move |_| load()>
365 {i18n::tr(i18n::k::RETRY)}
366 </button>
367 </div>
368 }
369 .into_view()
370 .into_any(),
371 None => view! {
372 <p class="muted">{i18n::tr(i18n::k::LOADING)}</p>
373 }
374 .into_view()
375 .into_any(),
376 },
377 Some(ref list) if list.is_empty() => view! {
378 <div class="empty-state">
379 <Icon name=IconName::Share class="empty-glyph".to_string()/>
380 <h3>{i18n::tr(i18n::k::NO_SHARES)}</h3>
381 <p class="muted">{i18n::tr(i18n::k::SHARES_EMPTY_HINT)}</p>
382 </div>
383 }
384 .into_view()
385 .into_any(),
386 Some(ref list) => {
387 let items = list
388 .iter()
389 .map(|s| {
390 let s2 = s.clone();
391 let link = share_url(&s.token);
392 let glyph = if s.is_file {
393 IconName::File
394 } else {
395 IconName::Folder
396 };
397 let mut meta = format!(
398 "{} · {}",
399 s.target,
400 if s.writable {
401 i18n::t(i18n::k::MODE_RW)
402 } else {
403 i18n::t(i18n::k::MODE_RO)
404 }
405 );
406 if let Some(e) = &s.expires_at {
407 meta.push_str(&format!(
408 " · {}",
409 i18n::t_fmt(i18n::k::META_EXPIRES, &format_date(e))
410 ));
411 }
412 meta.push_str(&format!(
413 " · {}",
414 i18n::t_fmt(i18n::k::META_CREATED, &format_date(&s.created_at))
415 ));
416 view! {
417 <div class="share-item">
418 <div class="share-item-main">
419 <div class="share-item-name">
420 <Icon name=glyph class="ic-row".to_string()/>
421 <span>{s.name.clone()}</span>
422 </div>
423 <div class="share-item-meta muted">{meta}</div>
424 </div>
425 <div class="share-item-actions">
426 <button
427 class="btn btn-sm"
428 title={i18n::tr(i18n::k::COPY_LINK)}
429 on:click=move |_| copy_to_clipboard(&link, toast)
430 >
431 <Icon name=IconName::Copy class="ic-btn".to_string()/>
432 {i18n::tr(i18n::k::COPY)}
433 </button>
434 <button
435 class="btn btn-sm btn-danger"
436 title={i18n::tr(i18n::k::DELETE_SHARE)}
437 on:click=move |_| {
438 let id = s2.id;
439 let read = shares;
440 let write = set_shares;
441 let toast2 = toast;
442 spawn_local(async move {
443 if api::delete_share(id).await.is_ok() {
444 if let Some(mut cur) = read.get() {
445 cur.retain(|x| x.id != id);
446 write.set(Some(cur));
447 }
448 show(toast2, i18n::t(i18n::k::SHARE_DELETED).to_string());
449 } else {
450 show_error(toast2, i18n::t(i18n::k::SHARE_DELETE_ERR).to_string());
451 }
452 });
453 }
454 >
455 <Icon name=IconName::Delete class="ic-btn".to_string()/>
456 {i18n::tr(i18n::k::DELETE)}
457 </button>
458 </div>
459 </div>
460 }
461 })
462 .collect::<Vec<_>>();
463 view! {
464 <div class="share-list">{items}</div>
465 }
466 .into_view()
467 .into_any()
468 }
469 }}
470
471 // Delete-all confirmation.
472 {move || {
473 if confirm_all.get() {
474 let n = shares.get().map(|s| s.len()).unwrap_or(0);
475 view! {
476 <Modal
477 class="card"
478 on_close=Callback::new(move |_| set_confirm_all.set(false))
479 >
480 <h2 class="modal-title">{i18n::tr(i18n::k::DELETE_ALL_TITLE)}</h2>
481 <p class="modal-message">
482 {i18n::t_fmt(i18n::k::DELETE_ALL_MSG, &n.to_string())}
483 </p>
484 <div class="modal-actions">
485 <button class="btn" on:click=move |_| set_confirm_all.set(false)>
486 {i18n::tr(i18n::k::CANCEL)}
487 </button>
488 <button class="btn btn-danger" disabled=move || busy_all.get() on:click=delete_all>
489 {i18n::tr(i18n::k::DELETE_ALL)}
490 </button>
491 </div>
492 </Modal>
493 }
494 .into_view()
495 .into_any()
496 } else {
497 view! {}.into_any()
498 }
499 }}
500 </div>
501 }
502}
503