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