router.rs
⎇
Raw
1//! Tiny hash-based router.
2//!
3//! - `#/` — root picker / single-root browser
4//! - `#/files/{root_id}/{segment/...}` — a user's folder
5//! - `#/shares/{token}/{segment/...}` — a public share (no login)
6//! - `#/shares`, `#/users`, `#/settings` — the signed-in shell's manage views
7//!
8//! (`#/shares` with a token is the public share; without one it is the
9//! manage view that lists your own shares.)
10//!
11//! The URL hash is the single source of truth; the `Location` signal in the
12//! app mirrors it.
13
14/// Which section of the signed-in shell a location points at. `Files` is the
15/// browser; the rest are the manage views, each with its own URL.
16#[derive(Clone, Copy, PartialEq, Eq, Debug)]
17pub enum Section {
18 Files,
19 /// The search view (`#/search`); query params live in the hash query.
20 Search,
21 Shares,
22 Users,
23 Settings,
24}
25
26impl Section {
27 /// The URL segment for this section, or `None` for `Files` (which has no
28 /// segment of its own — the browser is the default view).
29 const fn segment(self) -> Option<&'static str> {
30 match self {
31 Section::Files => None,
32 Section::Search => Some("search"),
33 Section::Shares => Some("shares"),
34 Section::Users => Some("users"),
35 Section::Settings => Some("settings"),
36 }
37 }
38}
39
40#[derive(Clone, Debug, PartialEq, Eq)]
41pub struct Location {
42 /// None = the top-level "folders" view (root picker).
43 pub root_id: Option<i64>,
44 /// Path segments relative to the current root (or share target).
45 pub path: Vec<String>,
46 /// Present when the location is a public share (`#/s/{token}/...`).
47 pub share_token: Option<String>,
48 /// Which shell section the location points at.
49 pub section: Section,
50}
51
52impl Location {
53 pub const fn root() -> Self {
54 Self {
55 root_id: None,
56 path: Vec::new(),
57 share_token: None,
58 section: Section::Files,
59 }
60 }
61}
62
63/// Percent-encode one path segment for the URL hash. Without this a name
64/// containing `#`, `%` or `/` would break the round trip.
65fn encode_segment(seg: &str) -> String {
66 js_sys::encode_uri_component(seg).into()
67}
68
69fn decode_segment(seg: &str) -> String {
70 // A hand-typed hash can hold invalid escapes; keep it verbatim then.
71 js_sys::decode_uri_component(seg)
72 .map(String::from)
73 .unwrap_or_else(|_| seg.to_string())
74}
75
76pub fn parse_location() -> Location {
77 let hash = web_sys::window()
78 .and_then(|w| w.location().hash().ok())
79 .unwrap_or_default();
80 let h = hash.trim_start_matches('#');
81 let h = h.trim_start_matches('/');
82 // Hash query (e.g. `#/search?q=…`) is not part of the path; the search
83 // view parses it itself.
84 let h = h.split_once('?').map(|(p, _)| p).unwrap_or(h);
85 let mut parts = h.split('/').filter(|s| !s.is_empty());
86 match parts.next() {
87 Some("files") => match parts.next().and_then(|s| s.parse().ok()) {
88 Some(root_id) => Location {
89 root_id: Some(root_id),
90 path: parts.map(decode_segment).collect(),
91 share_token: None,
92 section: Section::Files,
93 },
94 None => Location::root(),
95 },
96 Some("search") => Location {
97 root_id: None,
98 path: Vec::new(),
99 share_token: None,
100 section: Section::Search,
101 },
102 Some("shares") => match parts.next() {
103 // `#/shares/{token}/...` — public share page.
104 Some(token) => Location {
105 root_id: None,
106 path: parts.map(decode_segment).collect(),
107 share_token: Some(token.to_string()),
108 section: Section::Files,
109 },
110 // `#/shares` — the signed-in shares manage view.
111 None => Location {
112 root_id: None,
113 path: Vec::new(),
114 share_token: None,
115 section: Section::Shares,
116 },
117 },
118 Some("users") => Location {
119 root_id: None,
120 path: Vec::new(),
121 share_token: None,
122 section: Section::Users,
123 },
124 Some("settings") => Location {
125 root_id: None,
126 path: Vec::new(),
127 share_token: None,
128 section: Section::Settings,
129 },
130 _ => Location::root(),
131 }
132}
133
134pub fn location_to_hash(loc: &Location) -> String {
135 if let Some(token) = &loc.share_token {
136 let mut s = format!("#/shares/{token}");
137 for seg in &loc.path {
138 s.push('/');
139 s.push_str(&encode_segment(seg));
140 }
141 return s;
142 }
143 if let Some(segment) = loc.section.segment() {
144 return format!("#/{segment}");
145 }
146 match loc.root_id {
147 None => "#/".to_string(),
148 Some(id) => {
149 let mut s = format!("#/files/{id}");
150 for seg in &loc.path {
151 s.push('/');
152 s.push_str(&encode_segment(seg));
153 }
154 s
155 }
156 }
157}
158
159/// Change the app location (updates the hash; the app listens for hashchange).
160pub fn navigate(loc: &Location) {
161 if let Some(w) = web_sys::window() {
162 let _ = w.location().set_hash(&location_to_hash(loc));
163 }
164}
165
166/// Build the share link for a token, relative to the current page origin.
167pub fn share_url(token: &str) -> String {
168 let base = web_sys::window()
169 .and_then(|w| w.location().href().ok())
170 .unwrap_or_default();
171 // Cut off any existing hash, then append the share hash.
172 let cut = base.find('#').unwrap_or(base.len());
173 format!("{}#/shares/{token}", &base[..cut])
174}
175