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