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