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::toast::{ToastMsg, show};
9use crate::router::share_url;
10use crate::util::format_date;
11
12// ---------------------------------------------------------------------------
13// Expiry helpers (compute the RFC 3339 expiry client-side)
14// ---------------------------------------------------------------------------
15
16/// Format a millisecond timestamp as an RFC 3339 UTC string (ISO 8601).
17fn ms_to_rfc3339(ms: f64) -> Option<String> {
18 if ms.is_nan() {
19 return None;
20 }
21 Some(
22 js_sys::Date::new(&wasm_bindgen::JsValue::from_f64(ms))
23 .to_iso_string()
24 .into(),
25 )
26}
27
28/// Convert the user's expiry choice into an RFC 3339 timestamp, or `None` for
29/// "never". Presets are computed from "now"; "custom" parses the
30/// datetime-local value as the browser's local time.
31fn expiry_to_rfc3339(choice: &str, custom: &str) -> Option<String> {
32 let now = js_sys::Date::now();
33 let h = 60.0 * 60.0 * 1000.0;
34 let target_ms: Option<f64> = match choice {
35 "never" => return None,
36 "1h" => Some(now + h),
37 "1d" => Some(now + 24.0 * h),
38 "1w" => Some(now + 7.0 * 24.0 * h),
39 "1mo" => Some(now + 30.0 * 24.0 * h),
40 "custom" => {
41 if custom.is_empty() {
42 None
43 } else {
44 // `Date.parse` treats a bare datetime as local time, so the
45 // result is already the correct UTC instant.
46 Some(js_sys::Date::parse(custom))
47 }
48 }
49 _ => return None,
50 };
51 target_ms.and_then(ms_to_rfc3339)
52}
53
54fn copy_to_clipboard(text: &str, toast: ToastMsg) {
55 let Some(win) = web_sys::window() else {
56 show(toast, "Copy the link manually".to_string());
57 return;
58 };
59 let cb = win.navigator().clipboard();
60 let t = text.to_string();
61 spawn_local(async move {
62 let _ = wasm_bindgen_futures::JsFuture::from(cb.write_text(&t)).await;
63 show(toast, "Link copied to clipboard".to_string());
64 });
65}
66
67fn select_value(ev: &web_sys::Event) -> Option<String> {
68 ev.target()
69 .and_then(|t| t.dyn_into::<web_sys::HtmlSelectElement>().ok())
70 .map(|s| s.value())
71}
72
73// ---------------------------------------------------------------------------
74// Share creation dialog (two-phase: form -> link)
75// ---------------------------------------------------------------------------
76
77#[component]
78pub fn ShareDialog(
79 name: String,
80 root_id: i64,
81 path: String,
82 allow_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
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(toast, format!("Could not create share: {e}"));
106 set_busy.set(false);
107 }
108 }
109 });
110 };
111
112 view! {
113 <div class="modal-overlay" on:click=move |_| close.run(())>
114 <div
115 class="modal share-modal"
116 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
117 >
118 {move || match created.get() {
119 Some(ref info) => {
120 let link = share_url(&info.token);
121 view! {
122 <h2 class="modal-title">"Share link"</h2>
123 <p class="modal-message">
124 "Anyone with this link can "
125 {if writable.get() { "read and write " } else { "read " }}
126 {format!("“{name}”.")}
127 </p>
128 <div class="share-link-row">
129 <input
130 class="share-link-input"
131 readonly=true
132 value=link.clone()
133 on:click=move |ev: web_sys::MouseEvent| {
134 if let Some(i) = ev
135 .target()
136 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
137 {
138 i.select();
139 }
140 }
141 />
142 <button class="btn" on:click=move |_| copy_to_clipboard(&link, toast)>"Copy"</button>
143 </div>
144 <p class="muted share-expiry-note">
145 {match info.expires_at {
146 Some(ref e) => format!("Expires {0}", format_date(e)),
147 None => "Never expires".to_string(),
148 }}
149 </p>
150 <div class="modal-actions">
151 <button class="btn btn-primary" on:click=move |_| close.run(())>"Done"</button>
152 </div>
153 }
154 .into_view()
155 .into_any()
156 }
157 None => view! {
158 <h2 class="modal-title">{format!("Share {name}")}</h2>
159 <p class="modal-message">"Create a link for this item. Writable links let viewers upload and edit."</p>
160 {move || if allow_writable {
161 view! {
162 <label class="share-check">
163 <input
164 type="checkbox"
165 checked=move || writable.get()
166 on:change=move |ev: web_sys::Event| {
167 if let Some(t) = ev
168 .target()
169 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
170 {
171 set_writable.set(t.checked());
172 }
173 }
174 />
175 "Allow editing (read-write)"
176 </label>
177 }
178 .into_view()
179 .into_any()
180 } else {
181 view! {
182 <p class="muted">"Writable shares are disabled by the server."</p>
183 }
184 .into_view()
185 .into_any()
186 }}
187 <div class="share-expiry-row">
188 <label for="share-expiry">"Expires"</label>
189 <select
190 id="share-expiry"
191 on:change=move |ev: web_sys::Event| {
192 if let Some(v) = select_value(&ev) {
193 set_choice.set(v);
194 }
195 }
196 >
197 <option value="never">"Never"</option>
198 <option value="1h">"1 hour"</option>
199 <option value="1d">"1 day"</option>
200 <option value="1w">"1 week"</option>
201 <option value="1mo">"1 month"</option>
202 <option value="custom">"Custom…"</option>
203 </select>
204 </div>
205 {move || if choice.get() == "custom" {
206 view! {
207 <div class="share-expiry-row">
208 <label for="share-custom-dt">"Until"</label>
209 <input
210 type="datetime-local"
211 id="share-custom-dt"
212 value=move || custom.get()
213 on:change=move |ev: web_sys::Event| {
214 if let Some(t) = ev
215 .target()
216 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
217 {
218 set_custom.set(t.value());
219 }
220 }
221 />
222 </div>
223 }
224 .into_view()
225 .into_any()
226 } else {
227 view! {}.into_any()
228 }}
229 <div class="modal-actions">
230 <button class="btn" on:click=move |_| close.run(())>"Cancel"</button>
231 <button
232 class="btn btn-primary"
233 disabled=move || busy.get()
234 on:click=do_create.clone()
235 >
236 {move || if busy.get() { "Creating…" } else { "Create link" }}
237 </button>
238 </div>
239 }
240 .into_view()
241 .into_any(),
242 }}
243 </div>
244 </div>
245 }
246}
247
248// ---------------------------------------------------------------------------
249// "Your shares" management panel
250// ---------------------------------------------------------------------------
251
252#[component]
253pub fn SharesPanel(close: Callback<()>) -> impl IntoView {
254 let toast = use_context::<ToastMsg>().expect("toast context");
255 let (shares, set_shares) = signal(Option::<Vec<ShareInfo>>::None);
256 {
257 let set = set_shares;
258 let toast2 = toast;
259 spawn_local(async move {
260 match api::list_shares().await {
261 Ok(s) => set.set(Some(s)),
262 Err(e) => show(toast2, e.to_string()),
263 }
264 });
265 }
266
267 // Escape closes the panel.
268 {
269 let c = close;
270 crate::util::owned_window_listener(
271 leptos::ev::keydown,
272 move |ev: web_sys::KeyboardEvent| {
273 if ev.key() == "Escape" {
274 c.run(());
275 }
276 },
277 );
278 }
279
280 view! {
281 <div class="modal-overlay" on:click=move |_| close.run(())>
282 <div
283 class="modal shares-panel"
284 on:click=move |ev: web_sys::MouseEvent| ev.stop_propagation()
285 >
286 <h2 class="modal-title">"Your shares"</h2>
287 {move || match shares.get() {
288 None => view! {
289 <p class="muted">"Loading…"</p>
290 }
291 .into_view()
292 .into_any(),
293 Some(ref list) if list.is_empty() => view! {
294 <p class="muted">"No shares yet. Right-click a file or folder and choose “Share”."</p>
295 }
296 .into_view()
297 .into_any(),
298 Some(ref list) => {
299 let items = list
300 .iter()
301 .map(|s| {
302 let s2 = s.clone();
303 let link = share_url(&s.token);
304 let mut meta = format!(
305 "{} · {}",
306 s.target,
307 if s.writable { "read-write" } else { "read-only" }
308 );
309 if let Some(e) = &s.expires_at {
310 meta.push_str(&format!(" · expires {}", format_date(e)));
311 }
312 meta.push_str(&format!(" · created {}", format_date(&s.created_at)));
313 view! {
314 <div class="share-item">
315 <div class="share-item-main">
316 <div class="share-item-name">
317 {if s.is_file { "📄" } else { "📁" }}
318 {" "}{s.name.clone()}
319 </div>
320 <div class="share-item-meta muted">{meta}</div>
321 </div>
322 <div class="share-item-actions">
323 <button
324 class="btn btn-sm"
325 title="Copy link"
326 on:click=move |_| copy_to_clipboard(&link, toast)
327 >"Copy"</button>
328 <button
329 class="btn btn-sm btn-danger"
330 title="Delete share"
331 on:click=move |_| {
332 let id = s2.id;
333 let read = shares;
334 let write = set_shares;
335 let toast2 = toast;
336 spawn_local(async move {
337 if api::delete_share(id).await.is_ok() {
338 if let Some(mut cur) = read.get() {
339 cur.retain(|x| x.id != id);
340 write.set(Some(cur));
341 }
342 show(toast2, "Share deleted");
343 } else {
344 show(toast2, "Could not delete share".to_string());
345 }
346 });
347 }
348 >"Delete"</button>
349 </div>
350 </div>
351 }
352 })
353 .collect::<Vec<_>>();
354 view! {
355 <div class="share-list">{items}</div>
356 }
357 }
358 .into_view()
359 .into_any(),
360 }}
361 <div class="modal-actions">
362 <button class="btn" on:click=move |_| close.run(())>"Close"</button>
363 </div>
364 </div>
365 </div>
366 }
367}
368