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