logicaffeine_web/ui/components/
file_browser.rs1use dioxus::prelude::*;
23use crate::ui::state::FileNode;
24use crate::ui::components::icon::{Icon, IconVariant, IconSize};
25
26const FILE_BROWSER_STYLE: &str = r#"
27.file-browser {
28 display: flex;
29 flex-direction: column;
30 height: 100%;
31 width: 100%;
32 background: #12161c;
33 overflow: hidden;
34}
35
36.file-browser-header {
37 display: flex;
38 justify-content: space-between;
39 align-items: center;
40 padding: 0 14px;
41 height: 52px;
42 border-bottom: 1px solid rgba(255, 255, 255, 0.08);
43 background: rgba(255, 255, 255, 0.02);
44 flex-shrink: 0;
45}
46
47.file-browser-title {
48 font-size: 12px;
49 font-weight: 600;
50 text-transform: uppercase;
51 letter-spacing: 0.5px;
52 color: rgba(255, 255, 255, 0.6);
53}
54
55.file-browser-actions {
56 display: flex;
57 gap: 4px;
58}
59
60.file-browser-btn {
61 padding: 4px 8px;
62 border: none;
63 background: transparent;
64 color: rgba(255, 255, 255, 0.5);
65 font-size: 14px;
66 cursor: pointer;
67 border-radius: 4px;
68 transition: all 0.15s ease;
69}
70
71.file-browser-btn:hover {
72 background: rgba(255, 255, 255, 0.08);
73 color: rgba(255, 255, 255, 0.9);
74}
75
76.file-browser-btn.coming-soon {
77 position: relative;
78}
79
80.coming-soon-toast {
81 position: fixed;
82 top: 80px;
83 left: 50%;
84 transform: translateX(-50%);
85 background: linear-gradient(135deg, #00d4ff 0%, #818cf8 100%);
86 color: white;
87 padding: 12px 20px;
88 border-radius: 8px;
89 font-size: 14px;
90 font-weight: 500;
91 box-shadow: 0 4px 20px rgba(0, 212, 255, 0.3);
92 z-index: 1000;
93 animation: toast-fade 2.5s ease-out forwards;
94}
95
96@keyframes toast-fade {
97 0% { opacity: 0; transform: translateX(-50%) translateY(-10px); }
98 10% { opacity: 1; transform: translateX(-50%) translateY(0); }
99 80% { opacity: 1; }
100 100% { opacity: 0; }
101}
102
103.file-tree {
104 flex: 1;
105 overflow: auto;
106 padding: 8px 0;
107}
108
109.file-tree-node {
110 -webkit-user-select: none;
111 user-select: none;
112}
113
114.file-tree-item {
115 display: flex;
116 align-items: center;
117 gap: 6px;
118 padding: 6px 14px;
119 cursor: pointer;
120 color: rgba(255, 255, 255, 0.8);
121 font-size: 13px;
122 transition: background 0.1s ease;
123}
124
125.file-tree-item:hover {
126 background: rgba(255, 255, 255, 0.04);
127}
128
129.file-tree-item.selected {
130 background: rgba(0, 212, 255, 0.12);
131 color: #00d4ff;
132}
133
134.file-tree-item .icon {
135 font-size: 14px;
136 opacity: 0.7;
137 flex-shrink: 0;
138}
139
140.file-tree-item .name {
141 flex: 1;
142 overflow: hidden;
143 text-overflow: ellipsis;
144 white-space: nowrap;
145}
146
147.file-tree-item .chevron {
148 font-size: 10px;
149 opacity: 0.5;
150 transition: transform 0.15s ease;
151}
152
153.file-tree-item .chevron.expanded {
154 transform: rotate(90deg);
155}
156
157.file-tree-children {
158 padding-left: 16px;
159}
160
161.file-tree-empty {
162 padding: 20px;
163 text-align: center;
164 color: rgba(255, 255, 255, 0.4);
165 font-size: 13px;
166}
167
168/* Mobile: fill the wrapper, which handles positioning */
169@media (max-width: 768px) {
170 .file-browser {
171 width: 100%;
172 min-width: 100%;
173 max-width: 100%;
174 }
175
176 .file-tree-item {
177 color: rgba(255, 255, 255, 0.9);
178 -webkit-text-fill-color: rgba(255, 255, 255, 0.9);
179 }
180
181 .file-tree-item.selected {
182 color: #00d4ff;
183 -webkit-text-fill-color: #00d4ff;
184 }
185
186 .file-tree-item .name {
187 color: inherit;
188 -webkit-text-fill-color: inherit;
189 }
190}
191"#;
192
193#[component]
194pub fn FileBrowser(
195 tree: FileNode,
196 selected_path: Option<String>,
197 on_select: EventHandler<String>,
198 on_toggle_dir: EventHandler<String>,
199 on_new_file: EventHandler<()>,
200 #[props(default = false)] show_private_mode: bool,
201) -> Element {
202 let mut show_toast = use_signal(|| false);
203 let mut toast_key = use_signal(|| 0u32);
204
205 rsx! {
206 style { "{FILE_BROWSER_STYLE}" }
207
208 if *show_toast.read() {
210 div {
211 key: "{toast_key}",
212 class: "coming-soon-toast",
213 "Coming Soon!"
214 }
215 }
216
217 aside { class: "file-browser",
218 div { class: "file-browser-header",
220 span { class: "file-browser-title", "Files" }
221 div { class: "file-browser-actions",
222 button {
223 class: "file-browser-btn coming-soon",
224 onclick: move |_| {
225 toast_key.set(toast_key() + 1);
227 show_toast.set(true);
228 },
229 title: "New file (Coming Soon)",
230 "+"
231 }
232 }
233 }
234
235 if show_private_mode {
237 div {
238 style: "background: rgba(37, 99, 235, 0.12); color: rgba(147, 197, 253, 0.85); padding: 5px 14px; font-size: 10px; border-bottom: 1px solid rgba(37, 99, 235, 0.2); flex-shrink: 0;",
239 "⚡ Private mode – temporary storage"
240 }
241 }
242
243 div { class: "file-tree",
245 if tree.children.is_empty() {
246 div { class: "file-tree-empty",
247 "No files yet"
248 }
249 } else {
250 for child in tree.children.iter() {
251 FileTreeNode {
252 key: "{child.path}",
253 node: child.clone(),
254 selected_path: selected_path.clone(),
255 depth: 0,
256 on_select: on_select.clone(),
257 on_toggle_dir: on_toggle_dir.clone(),
258 }
259 }
260 }
261 }
262 }
263 }
264}
265
266#[component]
267fn FileTreeNode(
268 node: FileNode,
269 selected_path: Option<String>,
270 depth: usize,
271 on_select: EventHandler<String>,
272 on_toggle_dir: EventHandler<String>,
273) -> Element {
274 let is_selected = selected_path.as_ref() == Some(&node.path);
275 let item_class = if is_selected {
276 "file-tree-item selected"
277 } else {
278 "file-tree-item"
279 };
280
281 let icon_variant = if node.is_directory {
282 if node.expanded { IconVariant::FolderOpen } else { IconVariant::Folder }
283 } else {
284 IconVariant::File
285 };
286
287 let file_symbol: Option<&'static str> = if !node.is_directory {
289 match node.name.rsplit('.').next() {
290 Some("logic") => Some("∀"),
291 Some("logos") => Some("λ"),
292 Some("math") => Some("π"),
293 _ => None,
294 }
295 } else {
296 None
297 };
298
299 let chevron_class = if node.expanded {
300 "chevron expanded"
301 } else {
302 "chevron"
303 };
304
305 let path = node.path.clone();
306 let path_for_toggle = node.path.clone();
307
308 rsx! {
309 div { class: "file-tree-node",
310 div {
311 class: "{item_class}",
312 style: "padding-left: {14 + depth * 16}px;",
313 onclick: move |_| {
314 if node.is_directory {
315 on_toggle_dir.call(path_for_toggle.clone());
316 } else {
317 on_select.call(path.clone());
318 }
319 },
320
321 if node.is_directory {
323 span { class: "{chevron_class}", "\u{276F}" }
324 }
325
326 span { class: "icon",
327 if let Some(sym) = file_symbol {
328 span { "{sym}" }
329 } else {
330 Icon { variant: icon_variant, size: IconSize::Small }
331 }
332 }
333 span { class: "name", "{node.name}" }
334 }
335
336 if node.is_directory && node.expanded {
338 div { class: "file-tree-children",
339 for child in node.children.iter() {
340 FileTreeNode {
341 key: "{child.path}",
342 node: child.clone(),
343 selected_path: selected_path.clone(),
344 depth: depth + 1,
345 on_select: on_select.clone(),
346 on_toggle_dir: on_toggle_dir.clone(),
347 }
348 }
349 }
350 }
351 }
352 }
353}