Redesign UI: Material Symbols icons, full-width topbar + sidebar, themes
Icons: - Custom SVG favicon (blue square, folder with chevron cut-out), reused as the inline Logo component in topbar/auth cards/share page - Central icon registry (web/src/icons.rs): all glyphs are Material Symbols (filled, 24x24) from the iconify.design API, each variant's doc comment names the source glyph so any swap is a one-file change - Replaces every hand-drawn SVG and emoji (including the old admin crown) Layout: - Sharp squares everywhere (global border-radius: 0) - Full-width sticky topbar (logo left, username + theme toggle + logout right); sidebar (FILES root tabs + MANAGE: Shares/Users/Settings) is docked beneath it on wide screens and an off-canvas drawer under the topbar on narrow ones, so the topbar stays interactable - Accessible roots are sidebar tabs; the virtual root-picker view is gone - Shares is a full view (no overlay); user rows no longer show accessible folders (edit modal only); admin badge removed - Context menu items get icons; Refresh moves from the menu to a toolbar icon button Themes: - Tri-state toggle next to logout: auto (default, follows the OS) / light / dark, persisted in localStorage, applied as a class on <html> (app.css resolves the palettes); a pre-paint script in index.html avoids flashing the wrong palette; color-scheme set per theme Fixes: - .center-screen was a row flex, rendering the share error heading and message side by side; now a column - just dev-web: trunk strips the /api rewrite prefix, so the proxy backend URL now carries it (login was broken in dev) - embedded frontend e2e: whitelist the svg favicon content-type Co-Authored-By: Qwen3.8 27b
Mjustfile
@@ -20,8 +20,9 @@ dev-server:
cargo run -p server -- --root {{dev-root}} --db {{dev-db}} --port 8081
# Frontend dev server on :8080 (Trunk with HMR; proxies /api to :8081).
# Trunk strips the /api rewrite prefix, so the backend URL carries it back.
dev-web: build-cm
cd web && trunk serve --proxy-rewrite /api --proxy-backend http://127.0.0.1:8081
cd web && trunk serve --proxy-rewrite /api --proxy-backend http://127.0.0.1:8081/api
# Build the frontend and stage it into server/dist for the embedded build.
frontend: build-cm
Mserver/tests/api_embedded.rs
@@ -72,6 +72,7 @@ async fn embedded_frontend_is_complete() {
let expected = match ref_.rsplit('.').next().unwrap_or("") {
"css" => "text/css",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
other => panic!("unexpected asset type in index.html: {other}"),
};
assert_eq!(ct, expected, "content-type for {ref_}");
Mweb/Cargo.toml
@@ -39,5 +39,6 @@ web-sys = { version = "0.3", features = [
"Response",
"Storage",
"SubmitEvent",
"SvgElement",
"Window",
] }
Mweb/app.css
@@ -1,4 +1,9 @@
/* Theme palettes. The base is light; dark is applied either by the OS
preference (theme = auto, the default) or forced by the theme toggle,
which puts `light`/`dark` on <html> (see web/src/theme.rs). Keep the two
dark blocks in sync. */
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--text: #1a1d21;
@@ -7,11 +12,24 @@
--accent-hover: #2563eb;
--danger: #ef4444;
--border: #e5e7eb;
--radius: 12px;
--topbar-h: 48px;
}
:root.dark {
color-scheme: dark;
--bg: #0f1115;
--panel: #171a20;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #60a5fa;
--accent-hover: #3b82f6;
--danger: #f87171;
--border: #2a2f3a;
}
@media (prefers-color-scheme: dark) {
:root {
:root:not(.light) {
color-scheme: dark;
--bg: #0f1115;
--panel: #171a20;
--text: #e5e7eb;
@@ -25,6 +43,8 @@
* {
box-sizing: border-box;
/* The design language is sharp: everything stays square. */
border-radius: 0;
}
body {
@@ -42,16 +62,17 @@ body {
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
.center-screen {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 16px;
text-align: center;
}
.auth-card {
@@ -64,6 +85,12 @@ body {
font-size: 22px;
}
.auth-logo {
width: 44px;
height: 44px;
margin-bottom: 12px;
}
.auth-card p {
margin: 0 0 20px;
font-size: 13px;
@@ -84,7 +111,6 @@ body {
input {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
@@ -99,7 +125,10 @@ input:focus {
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
@@ -135,7 +164,7 @@ button:disabled {
margin: 0 0 10px;
}
/* layout (signed-in) */
/* layout (signed-in): full-width topbar, then sidebar + content below */
.layout {
min-height: 100vh;
@@ -144,16 +173,29 @@ button:disabled {
}
.topbar {
flex: none;
position: sticky;
top: 0;
z-index: 130;
height: var(--topbar-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
gap: 12px;
padding: 0 16px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.brand {
font-weight: 700;
.topbar-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.icon-btn.nav-toggle {
display: none;
}
.topbar-right {
@@ -162,17 +204,135 @@ button:disabled {
gap: 12px;
}
.user-badge {
font-size: 14px;
.body-row {
flex: 1;
display: flex;
align-items: stretch;
}
/* ---- sidebar ---- */
.sidebar {
flex: none;
width: 224px;
display: flex;
flex-direction: column;
background: var(--panel);
border-right: 1px solid var(--border);
position: sticky;
top: var(--topbar-h);
height: calc(100vh - var(--topbar-h));
}
.brand {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
font-weight: 700;
white-space: nowrap;
}
.brand-name {
overflow: hidden;
text-overflow: ellipsis;
}
.logo {
width: 26px;
height: 26px;
flex: none;
display: block;
}
.nav {
flex: 1;
overflow-y: auto;
padding: 14px 8px 16px;
display: flex;
flex-direction: column;
gap: 20px;
}
.nav-section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
padding: 0 10px 6px;
}
.nav-tab {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
margin-bottom: 2px;
padding: 8px 10px;
text-align: left;
background: none;
border: none;
color: var(--muted);
font-size: 14px;
}
.nav-tab:hover {
background: var(--bg);
color: var(--text);
}
.nav-tab.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.ic-tab {
width: 18px;
height: 18px;
flex: none;
}
.nav-tab.active .icon {
color: #fff;
}
.nav-tab-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nav-tab-note {
flex: none;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--muted);
}
.nav-tab.active .nav-tab-note {
background: rgb(255 255 255 / 18%);
border-color: rgb(255 255 255 / 30%);
color: #fff;
}
.nav-backdrop {
display: none;
}
.user-badge {
font-size: 14px;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: var(--bg);
border: 1px solid var(--border);
color: var(--muted);
@@ -180,11 +340,52 @@ button:disabled {
.content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
padding: 20px;
}
/* Narrow screens: the sidebar becomes an off-canvas drawer that only
covers the content, below the (always visible) topbar. */
@media (max-width: 860px) {
.sidebar {
position: fixed;
top: var(--topbar-h);
bottom: 0;
left: 0;
right: auto;
height: auto;
z-index: 120;
transform: translateX(-100%);
transition: transform 0.15s ease;
}
.sidebar.open {
transform: translateX(0);
box-shadow: 0 0 48px rgb(0 0 0 / 35%);
}
.nav-backdrop {
display: block;
position: fixed;
top: var(--topbar-h);
bottom: 0;
left: 0;
right: 0;
z-index: 110;
background: rgb(0 0 0 / 40%);
}
.icon-btn.nav-toggle {
display: inline-flex;
}
.content {
padding: 14px;
}
}
/* ---- file browser ---- */
.browser {
@@ -225,7 +426,6 @@ button:disabled {
cursor: pointer;
color: var(--muted);
padding: 3px 6px;
border-radius: 6px;
}
.crumb:hover {
@@ -247,14 +447,19 @@ button:disabled {
.toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
gap: 6px;
padding-bottom: 12px;
}
.toolbar-right {
display: flex;
gap: 6px;
}
.icon-btn {
padding: 7px 9px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--muted);
@@ -297,44 +502,7 @@ button:disabled {
font-size: 14px;
}
/* root picker */
.root-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.root-tile {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.root-tile:hover {
border-color: var(--muted);
}
.icon-big {
width: 44px;
height: 44px;
}
.root-tile-name {
font-weight: 600;
font-size: 15px;
}
.root-tile-path {
font-size: 13px;
flex: 1;
}
/* root tiles were replaced by the sidebar's folder tabs */
/* grid view */
@@ -347,7 +515,6 @@ button:disabled {
.tile {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 10px 12px;
text-align: center;
cursor: pointer;
@@ -375,7 +542,6 @@ button:disabled {
.entries-list {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
@@ -433,18 +599,25 @@ button:disabled {
min-width: 190px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 5px;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
}
.ctx-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 7px;
font-size: 14px;
cursor: pointer;
}
.ic-ctx {
width: 18px;
height: 18px;
flex: none;
}
.ctx-item:hover {
background: var(--bg);
}
@@ -468,7 +641,6 @@ button:disabled {
background: var(--panel);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 11px 16px;
font-size: 14px;
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
@@ -494,7 +666,6 @@ button:disabled {
padding: 20px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 12px 44px rgb(0 0 0 / 35%);
}
@@ -586,7 +757,6 @@ button:disabled {
max-height: 240px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.picker-row {
@@ -629,19 +799,25 @@ button:disabled {
}
.preview-close {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--muted);
font-size: 24px;
line-height: 1;
cursor: pointer;
padding: 0 8px;
border-radius: 8px;
padding: 4px;
}
.preview-close:hover {
background: var(--bg);
color: var(--text);
}
.ic-close {
width: 20px;
height: 20px;
}
.preview-body {
flex: 1;
min-height: 0;
@@ -759,7 +935,7 @@ button:disabled {
margin-bottom: 8px;
}
.dl-fmt {
text-align: left;
justify-content: flex-start;
}
/* ---------------------------------------------------------------------------
@@ -800,7 +976,6 @@ button:disabled {
font-size: 12.5px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
color: var(--text);
min-width: 0;
@@ -837,7 +1012,6 @@ button:disabled {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
@@ -853,19 +1027,36 @@ button:disabled {
font-size: 12.5px;
}
/* "Your shares" panel */
.icon-btn-sm {
padding: 4px 6px;
}
/* "Your shares" manage view */
.shares-panel {
width: 560px;
.shares-view {
width: 100%;
max-width: 960px;
margin: 0 auto;
}
.view-header {
margin-bottom: 16px;
}
.view-header .admin-section-title {
margin-bottom: 0;
}
.view-hint {
margin: 4px 0 0;
font-size: 13px;
max-width: 64ch;
}
.share-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 60vh;
overflow-y: auto;
margin-bottom: 4px;
}
.share-item {
@@ -875,7 +1066,7 @@ button:disabled {
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
}
.share-item-main {
@@ -883,6 +1074,9 @@ button:disabled {
}
.share-item-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 14px;
overflow: hidden;
@@ -890,6 +1084,12 @@ button:disabled {
white-space: nowrap;
}
.ic-row {
width: 18px;
height: 18px;
flex: none;
}
.share-item-meta {
font-size: 12px;
margin-top: 2px;
@@ -907,12 +1107,20 @@ button:disabled {
/* Public share page */
.share-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 999px;
}
.ic-badge {
width: 14px;
height: 14px;
flex: none;
}
.share-footer {
@@ -932,8 +1140,9 @@ button:disabled {
text-align: center;
}
.share-file-icon {
font-size: 64px;
.share-file-glyph {
width: 64px;
height: 64px;
}
.share-file-page h2 {
@@ -955,32 +1164,8 @@ button:disabled {
flex-direction: column;
gap: 16px;
width: 100%;
}
.admin-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
}
.admin-tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 8px 14px;
color: var(--muted);
font-weight: 600;
}
.admin-tab:hover {
color: var(--text);
border-color: var(--muted);
}
.admin-tab.active {
color: var(--text);
border-bottom-color: var(--accent);
max-width: 960px;
margin: 0 auto;
}
.admin-section-title {
@@ -1016,7 +1201,6 @@ button:disabled {
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 10px;
}
.user-row-main {
@@ -1032,12 +1216,10 @@ button:disabled {
flex-wrap: wrap;
}
.user-row-meta {
font-size: 12px;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.ic-admin {
width: 16px;
height: 16px;
flex: none;
}
.user-row-actions {
@@ -1058,7 +1240,6 @@ button:disabled {
gap: 10px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
}
@@ -1146,13 +1327,6 @@ button:disabled {
margin-top: 4px;
}
/* Active state for the topbar "Admin" toggle */
.btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* ------------------------------------------------------------------ */
/* Empty state + keyboard focus (milestone 8) */
/* ------------------------------------------------------------------ */
@@ -1167,9 +1341,9 @@ button:disabled {
text-align: center;
}
.empty-icon {
font-size: 48px;
line-height: 1;
.empty-glyph {
width: 52px;
height: 52px;
}
.empty-state h3 {
Aweb/favicon.svg
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<!-- filebrowser-ng mark: a folder with files dropping in. Keep in sync
with the Logo component in web/src/components/logo.rs. -->
<rect width="32" height="32" fill="#2563eb"/>
<path d="M6 8h7l3 4h10v14H6z" fill="#fff"/>
<path d="M11.5 17.5 16 22l4.5-4.5" fill="none" stroke="#2563eb" stroke-width="3"/>
</svg>
Mweb/index.html
@@ -4,10 +4,24 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>filebrowser-ng</title>
<link
data-trunk
rel="icon"
type="image/svg+xml"
href="favicon.svg"
/>
<link data-trunk rel="css" href="app.css" />
<script data-trunk src="cm6.js"></script>
</head>
<body>
<script>
// Apply a forced theme before first paint so light/dark users don't
// flash the opposite palette ("auto" stores nothing, like most sites).
try {
var t = localStorage.getItem("fb-theme");
if (t === "light" || t === "dark") document.documentElement.classList.add(t);
} catch (e) {}
</script>
<link
data-trunk
rel="rust"
Mweb/src/app.rs
@@ -18,6 +18,12 @@ pub fn App() -> impl IntoView {
let (me, set_me) = signal(Option::<Me>::None);
let (loc, set_loc) = signal(Location::root());
// Tri-state theme (auto/light/dark): restore the persisted choice and
// apply it before first paint of the app UI.
let initial_theme = crate::theme::Theme::load();
initial_theme.apply();
let (theme, set_theme) = signal(initial_theme);
// The URL hash is the source of truth for the location.
{
set_loc.set(parse_location());
@@ -77,6 +83,8 @@ pub fn App() -> impl IntoView {
set_me=set_me
set_phase=set_phase
loc=loc
theme=theme
set_theme=set_theme
/>
}
.into_view()
Mweb/src/components/icon.rs
@@ -1,110 +1,29 @@
use api_types::FileKind;
use leptos::prelude::*;
#[derive(Clone, Copy, PartialEq, Debug)]
// Several variants are used from later milestones (context menu icons).
#[allow(dead_code)]
pub enum IconName {
Folder,
File,
Image,
Video,
Audio,
Text,
Code,
Archive,
Pdf,
Grid,
List,
Refresh,
Upload,
FolderPlus,
Download,
Pencil,
Move,
Copy,
Share,
Info,
Trash,
}
//! The `Icon` component: renders a glyph from the central [`crate::icons`]
//! registry (Material Symbols via Iconify) as inline SVG.
/// Extensions that get the "code" icon instead of the plain-text one. Both are
/// [`FileKind::Text`] to the server — code-vs-prose is a naming question, not a
/// content one, so it stays here.
const CODE_EXTS: &[&str] = &[
"rs", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "rb", "go", "c", "cc", "cpp", "h", "hpp",
"java", "kt", "cs", "php", "swift", "lua", "sh", "bash", "fish", "zsh", "ps1", "json", "toml",
"yaml", "yml", "html", "htm", "css", "scss", "sql", "xml", "vue", "svelte",
];
/// Pick the icon for an entry from its server-sniffed [`FileKind`].
///
/// `name` is used only to split [`FileKind::Text`] into prose and code.
pub fn icon_for(kind: FileKind, name: &str) -> IconName {
match kind {
FileKind::Dir => IconName::Folder,
FileKind::Image => IconName::Image,
FileKind::Video => IconName::Video,
FileKind::Audio => IconName::Audio,
FileKind::Pdf => IconName::Pdf,
FileKind::Archive => IconName::Archive,
FileKind::Text => {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if CODE_EXTS.contains(&ext.as_str()) {
IconName::Code
} else {
IconName::Text
}
}
FileKind::Binary => IconName::File,
}
}
use leptos::prelude::*;
fn icon_d(name: IconName) -> &'static str {
match name {
IconName::Folder => {
"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
}
IconName::File => "M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6",
IconName::Image => {
"M3 5h18v14H3z M10 10a1.5 1.5 0 1 1-3 0a1.5 1.5 0 0 1 3 0z M21 15l-5-5-9 9"
}
IconName::Video => "M3 5h18v14H3z M10 9l5 3-5 3z",
IconName::Audio => {
"M9 18V6l10-2v10 M9 18a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z M19 15a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"
}
IconName::Text => "M5 3h14v18H5z M9 8h6 M9 12h6 M9 16h4",
IconName::Code => "M8 8l-4 4 4 4 M16 8l4 4-4 4",
IconName::Archive => "M4 4h16v16H4z M12 4v2 M12 8v2 M12 12v2 M9 16h6v4H9z",
IconName::Pdf => {
"M6 2h8l5 5v13a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z M13 2v6h6 M8 13h8 M8 17h8"
}
IconName::Grid => "M4 4h7v7H4z M13 4h7v7h-7z M4 13h7v7H4z M13 13h7v7h-7z",
IconName::List => "M4 6h2 M9 6h11 M4 12h2 M9 12h11 M4 18h2 M9 18h11",
IconName::Refresh => "M21 12a9 9 0 1 1-3-6.7 M21 3v6h-6",
IconName::Upload => "M12 16V4 M7 9l5-5 5 5 M4 20h16",
IconName::FolderPlus => {
"M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z M12 11v6 M9 14h6"
}
IconName::Download => "M12 4v12 M7 11l5 5 5-5 M4 20h16",
IconName::Pencil => "M4 20l1-4L16 5l3 3L8 19z M13 8l3 3",
IconName::Move => "M20 7H6 M9 4L6 7l3 3 M4 17h14 M15 14l3 3-3 3",
IconName::Copy => "M8 8h12v12H8z M4 16V4h12",
IconName::Share => "M4 13v6a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-6 M12 3v11 M8 7l4-4 4 4",
IconName::Info => "M12 3a9 9 0 1 0 .01 0z M12 11v5 M12 8h.01",
IconName::Trash => "M4 7h16 M9 7V4h6v3 M6 7l1 13h10l1-13 M10 11v6 M14 11v6",
}
}
use crate::icons::IconName;
/// Folder-shaped glyphs get the accent color; everything else stays muted.
fn icon_class(name: IconName) -> &'static str {
match name {
IconName::Folder | IconName::FolderPlus => "ic-folder",
_ => "ic-muted",
if name.is_folder() {
"ic-folder"
} else {
"ic-muted"
}
}
#[component]
pub fn Icon(name: IconName, class: Option<String>) -> impl IntoView {
pub fn Icon(name: IconName, #[prop(optional)] class: Option<String>) -> impl IntoView {
let node = NodeRef::<leptos::svg::Svg>::new();
let body = name.body().to_string();
node.on_load(move |el: web_sys::SvgElement| {
// The registry bodies are raw SVG markup (`<path …/>`), so they are
// injected as-is. The strings are static and fetched from the
// Iconify API — never user input.
el.set_inner_html(&body);
});
let mut cls = String::from("icon ");
cls.push_str(icon_class(name));
if let Some(extra) = class {
@@ -113,16 +32,11 @@ pub fn Icon(name: IconName, class: Option<String>) -> impl IntoView {
}
view! {
<svg
node_ref=node
class=cls
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke_width="1.8"
stroke_linecap="round"
stroke_linejoin="round"
fill="currentColor"
aria-hidden="true"
>
<path d=icon_d(name)/>
</svg>
/>
}
}
Aweb/src/components/logo.rs
@@ -0,0 +1,24 @@
//! The brand mark. Same artwork as `web/favicon.svg` — keep the two in sync.
use leptos::prelude::*;
#[component]
pub fn Logo(#[prop(optional)] class: Option<String>) -> impl IntoView {
let mut cls = String::from("logo");
if let Some(extra) = class {
cls.push(' ');
cls.push_str(&extra);
}
view! {
<svg class=cls viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" fill="#2563eb"/>
<path d="M6 8h7l3 4h10v14H6z" fill="#fff"/>
<path
d="M11.5 17.5 16 22l4.5-4.5"
fill="none"
stroke="#2563eb"
stroke-width="3"
/>
</svg>
}
}
Mweb/src/components/mod.rs
@@ -1,2 +1,3 @@
pub mod icon;
pub mod logo;
pub mod toast;
Mweb/src/editor.rs
@@ -10,7 +10,9 @@ use web_sys::MouseEvent;
use crate::api::{self, ApiError};
use crate::cm;
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::icons::IconName;
/// A text file the user wants to edit.
#[derive(Clone)]
@@ -256,9 +258,10 @@ pub fn EditorModal(
<button
class="preview-close"
title="Close"
aria-label="Close"
on:click=move |_| request_close.run(())
>
"×"
<Icon name=IconName::Close class="ic-close".to_string()/>
</button>
</div>
</div>
Aweb/src/icons.rs
@@ -0,0 +1,240 @@
//! Central icon registry — every glyph in the app comes from here.
//!
//! All icons are Material Symbols (filled, 24x24) fetched from
//! <https://iconify.design> (set `material-symbols`) via the API at
//! <https://api.iconify.design>. Each variant's doc comment names the
//! source glyph, so swapping any icon is a one-file change: replace the
//! body (raw SVG inner markup of a 24x24 grid, filled with
//! `currentColor`) with the new one from the same API.
use api_types::FileKind;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum IconName {
/// material-symbols:folder
Folder,
/// material-symbols:folder-open
FolderOpen,
/// material-symbols:folder-off
FolderOff,
/// material-symbols:description
File,
/// material-symbols:image
Image,
/// material-symbols:video-file
Video,
/// material-symbols:audio-file
Audio,
/// material-symbols:article
Text,
/// material-symbols:code
Code,
/// material-symbols:folder-zip
Archive,
/// material-symbols:picture-as-pdf
Pdf,
/// material-symbols:grid-view
Grid,
/// material-symbols:view-list
List,
/// material-symbols:refresh
Refresh,
/// material-symbols:upload-file
Upload,
/// material-symbols:drive-folder-upload
UploadFolder,
/// material-symbols:create-new-folder
NewFolder,
/// material-symbols:download
Download,
/// material-symbols:edit
Edit,
/// material-symbols:drive-file-rename
Rename,
/// material-symbols:drive-file-move
Move,
/// material-symbols:content-copy
Copy,
/// material-symbols:link
Share,
/// material-symbols:info
Info,
/// material-symbols:delete
Delete,
/// material-symbols:open-in-new
OpenInNew,
/// material-symbols:logout
Logout,
/// material-symbols:menu
Menu,
/// material-symbols:close
Close,
/// material-symbols:settings
Settings,
/// material-symbols:person
User,
/// material-symbols:admin-panel-settings
Admin,
/// material-symbols:add
Add,
/// material-symbols:brightness-auto
ThemeAuto,
/// material-symbols:light-mode
ThemeLight,
/// material-symbols:dark-mode
ThemeDark,
}
impl IconName {
/// Raw SVG body for the 24x24 grid, rendered inside
/// `<svg viewBox="0 0 24 24" fill="currentColor">`.
pub const fn body(self) -> &'static str {
match self {
Self::Folder => {
r#"<path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z"/>"#
}
Self::FolderOpen => {
r#"<path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8H4v10l2.4-8h17.1l-2.575 8.575q-.2.65-.737 1.038T19 20z"/>"#
}
Self::FolderOff => {
r#"<path fill="currentColor" d="M21.775 18.95L6.85 4H9.7l2 2H20q.825 0 1.413.588T22 8v10q0 .275-.05.513t-.175.437m-1.3 4.35l-3.3-3.3H4q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4l2 2H3.2L.7 3.5l1.4-1.4l19.8 19.8z"/>"#
}
Self::File => {
r#"<path fill="currentColor" d="M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z"/>"#
}
Self::Image => {
r#"<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13z"/>"#
}
Self::Video => {
r#"<path fill="currentColor" d="M9 18h4q.425 0 .713-.288T14 17v-1l2 1.05v-4.1L14 14v-1q0-.425-.288-.712T13 12H9q-.425 0-.712.288T8 13v4q0 .425.288.713T9 18m-3 4q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z"/>"#
}
Self::Audio => {
r#"<path fill="currentColor" d="M10.75 19q.95 0 1.6-.65t.65-1.6V13h3v-2h-4v3.875q-.275-.2-.587-.288t-.663-.087q-.95 0-1.6.65t-.65 1.6t.65 1.6t1.6.65M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z"/>"#
}
Self::Text => {
r#"<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm2-4h7v-2H7zm0-4h10v-2H7zm0-4h10V7H7z"/>"#
}
Self::Code => {
r#"<path fill="currentColor" d="m8 18l-6-6l6-6l1.425 1.425l-4.6 4.6L9.4 16.6zm8 0l-1.425-1.425l4.6-4.6L14.6 7.4L16 6l6 6z"/>"#
}
Self::Archive => {
r#"<path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20zm10-2h2v-2h2v-2h-2v-2h2v-2h-2V8h-2v2h2v2h-2v2h2v2h-2z"/>"#
}
Self::Pdf => {
r#"<path fill="currentColor" d="M9 12.5h1v-2h1q.425 0 .713-.288T12 9.5v-1q0-.425-.288-.712T11 7.5H9zm1-3v-1h1v1zm3 3h2q.425 0 .713-.288T16 11.5v-3q0-.425-.288-.712T15 7.5h-2zm1-1v-3h1v3zm3 1h1v-2h1v-1h-1v-1h1v-1h-2zM8 18q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>"#
}
Self::Grid => {
r#"<path fill="currentColor" d="M3 11V3h8v8zm0 10v-8h8v8zm10-10V3h8v8zm0 10v-8h8v8z"/>"#
}
Self::List => {
r#"<path fill="currentColor" d="M9 20h11q.825 0 1.413-.587T22 18v-2H9zM2 8h5V4H4q-.825 0-1.412.588T2 6zm0 6h5v-4H2zm2 6h3v-4H2v2q0 .825.588 1.413T4 20m5-6h13v-4H9zm0-6h13V6q0-.825-.587-1.412T20 4H9z"/>"#
}
Self::Refresh => {
r#"<path fill="currentColor" d="M12 20q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4q1.725 0 3.3.712T18 6.75V4h2v7h-7V9h4.2q-.8-1.4-2.187-2.2T12 6Q9.5 6 7.75 7.75T6 12t1.75 4.25T12 18q1.925 0 3.475-1.1T17.65 14h2.1q-.7 2.65-2.85 4.325T12 20"/>"#
}
Self::Upload => {
r#"<path fill="currentColor" d="M11 19h2v-4.175l1.6 1.6L16 15l-4-4l-4 4l1.425 1.4L11 14.825zm-5 3q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z"/>"#
}
Self::UploadFolder => {
r#"<path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20zm7-3h2v-4.2l1.6 1.6L16 13l-4-4l-4 4l1.4 1.4l1.6-1.6z"/>"#
}
Self::NewFolder => {
r#"<path fill="currentColor" d="M14 16h2v-2h2v-2h-2v-2h-2v2h-2v2h2zM4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z"/>"#
}
Self::Download => {
r#"<path fill="currentColor" d="m12 16l-5-5l1.4-1.45l2.6 2.6V4h2v8.15l2.6-2.6L17 11zm-6 4q-.825 0-1.412-.587T4 18v-3h2v3h12v-3h2v3q0 .825-.587 1.413T18 20z"/>"#
}
Self::Edit => {
r#"<path fill="currentColor" d="M3 21v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM17.6 7.8L19 6.4L17.6 5l-1.4 1.4z"/>"#
}
Self::Rename => {
r#"<path fill="currentColor" d="M11.225 17H18v-2h-4.775zM6 17h3.075l6.5-6.5q.225-.225.325-.513t.1-.562t-.112-.55t-.313-.5l-.925-.925q-.225-.225-.5-.337T13.575 7q-.275 0-.55.1t-.5.325L6 13.925zm7.575-6.6l-.95-.95l.95-.95l.925.925zM5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21z"/>"#
}
Self::Move => {
r#"<path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h6l2 2h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20zm8.2-6l-1.625 1.625l1.4 1.4L16 13l-4.025-4.025l-1.4 1.4L12.2 12H8v2z"/>"#
}
Self::Copy => {
r#"<path fill="currentColor" d="M9 18q-.825 0-1.412-.587T7 16V4q0-.825.588-1.412T9 2h9q.825 0 1.413.588T20 4v12q0 .825-.587 1.413T18 18zm-4 4q-.825 0-1.412-.587T3 20V6h2v14h11v2z"/>"#
}
Self::Share => {
r#"<path fill="currentColor" d="M11 17H7q-2.075 0-3.537-1.463T2 12t1.463-3.537T7 7h4v2H7q-1.25 0-2.125.875T4 12t.875 2.125T7 15h4zm-3-4v-2h8v2zm5 4v-2h4q1.25 0 2.125-.875T20 12t-.875-2.125T17 9h-4V7h4q2.075 0 3.538 1.463T22 12t-1.463 3.538T17 17z"/>"#
}
Self::Info => {
r#"<path fill="currentColor" d="M11 17h2v-6h-2zm1.713-8.287Q13 8.425 13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9t.713-.288M12 22q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/>"#
}
Self::Delete => {
r#"<path fill="currentColor" d="M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zm2-4h2V8H9zm4 0h2V8h-2z"/>"#
}
Self::OpenInNew => {
r#"<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h14v-7h2v7q0 .825-.587 1.413T19 21zm4.7-5.3l-1.4-1.4L17.6 5H14V3h7v7h-2V6.4z"/>"#
}
Self::Logout => {
r#"<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h7v2zm11-4l-1.375-1.45l2.55-2.55H9v-2h8.175l-2.55-2.55L16 7l5 5z"/>"#
}
Self::Menu => {
r#"<path fill="currentColor" d="M3 18v-2h18v2zm0-5v-2h18v2zm0-5V6h18v2z"/>"#
}
Self::Close => {
r#"<path fill="currentColor" d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z"/>"#
}
Self::Settings => {
r#"<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>"#
}
Self::User => {
r#"<path fill="currentColor" d="M9.175 10.825Q8 9.65 8 8t1.175-2.825T12 4t2.825 1.175T16 8t-1.175 2.825T12 12t-2.825-1.175M4 20v-2.8q0-.85.438-1.562T5.6 14.55q1.55-.775 3.15-1.162T12 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T20 17.2V20z"/>"#
}
Self::Admin => {
r#"<path fill="currentColor" d="M17 22q-2.075 0-3.537-1.463T12 17t1.463-3.537T17 12t3.538 1.463T22 17t-1.463 3.538T17 22m-5 0q-3.475-.875-5.738-3.988T4 11.1V5l8-3l8 3v5.675q-.65-.325-1.463-.5T17 10q-2.9 0-4.95 2.05T10 17q0 1.55.588 2.8t1.487 2.175q-.025 0-.037.013T12 22m6.063-5.437q.437-.438.437-1.063t-.437-1.062T17 14t-1.062.438T15.5 15.5t.438 1.063T17 17t1.063-.437M17 20q.775 0 1.425-.363t1.05-.962q-.55-.325-1.175-.5T17 18t-1.3.175t-1.175.5q.4.6 1.05.963T17 20"/>"#
}
Self::Add => r#"<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z"/>"#,
Self::ThemeAuto => {
r#"<path fill="currentColor" d="M7.8 16h1.6l.8-2.3h3.65l.8 2.3h1.55l-3.4-9h-1.6zm2.85-3.6l1.3-3.75h.1l1.3 3.75zM12 23.3L8.65 20H4v-4.65L.7 12L4 8.65V4h4.65L12 .7L15.35 4H20v4.65L23.3 12L20 15.35V20h-4.65z"/>"#
}
Self::ThemeLight => {
r#"<path fill="currentColor" d="M8.463 15.538Q7 14.075 7 12t1.463-3.537T12 7t3.538 1.463T17 12t-1.463 3.538T12 17t-3.537-1.463M5 13H1v-2h4zm18 0h-4v-2h4zM11 5V1h2v4zm0 18v-4h2v4zM6.4 7.75L3.875 5.325L5.3 3.85l2.4 2.5zm12.3 12.4l-2.425-2.525L17.6 16.25l2.525 2.425zM16.25 6.4l2.425-2.525L20.15 5.3l-2.5 2.4zM3.85 18.7l2.525-2.425L7.75 17.6l-2.425 2.525z"/>"#
}
Self::ThemeDark => {
r#"<path fill="currentColor" d="M12 21q-3.75 0-6.375-2.625T3 12t2.625-6.375T12 3q.35 0 .688.025t.662.075q-1.025.725-1.638 1.888T11.1 7.5q0 2.25 1.575 3.825T16.5 12.9q1.375 0 2.525-.613T20.9 10.65q.05.325.075.662T21 12q0 3.75-2.625 6.375T12 21"/>"#
}
}
}
}
/// File types that get the "code" icon instead of the plain-text one. Both are
/// [`FileKind::Text`] to the server — code-vs-prose is a naming question, not
/// a content one, so it stays here.
const CODE_EXTS: &[&str] = &[
"rs", "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "rb", "go", "c", "cc", "cpp", "h", "hpp",
"java", "kt", "cs", "php", "swift", "lua", "sh", "bash", "fish", "zsh", "ps1", "json", "toml",
"yaml", "yml", "html", "htm", "css", "scss", "sql", "xml", "vue", "svelte",
];
/// Pick the icon for an entry from its server-sniffed [`FileKind`].
///
/// `name` is used only to split [`FileKind::Text`] into prose and code.
pub fn icon_for(kind: FileKind, name: &str) -> IconName {
match kind {
FileKind::Dir => IconName::Folder,
FileKind::Image => IconName::Image,
FileKind::Video => IconName::Video,
FileKind::Audio => IconName::Audio,
FileKind::Pdf => IconName::Pdf,
FileKind::Archive => IconName::Archive,
FileKind::Text => {
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if CODE_EXTS.contains(&ext.as_str()) {
IconName::Code
} else {
IconName::Text
}
}
FileKind::Binary => IconName::File,
}
}
impl IconName {
/// Folder-shaped glyphs are tinted with the accent color in the browser.
pub const fn is_folder(self) -> bool {
matches!(self, Self::Folder | Self::FolderOpen)
}
}
Mweb/src/main.rs
@@ -7,8 +7,10 @@ mod app;
mod cm;
mod components;
mod editor;
mod icons;
mod preview;
mod router;
mod theme;
mod util;
mod views;
Mweb/src/preview.rs
@@ -9,6 +9,8 @@ use web_sys::MouseEvent;
use crate::api;
use crate::cm;
use crate::components::icon::Icon;
use crate::icons::IconName;
/// What kind of preview a file gets.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -73,8 +75,8 @@ pub fn PreviewModal(
<div class="modal preview-modal" on:click=move |e: MouseEvent| e.stop_propagation()>
<div class="preview-head">
<span class="preview-title">{name}</span>
<button class="preview-close" on:click=move |_| close.run(())>
"×"
<button class="preview-close" title="Close" aria-label="Close" on:click=move |_| close.run(())>
<Icon name=IconName::Close class="ic-close".to_string()/>
</button>
</div>
<div class="preview-body">
Aweb/src/theme.rs
@@ -0,0 +1,98 @@
//! Tri-state color theme: auto (follow the OS), light, dark.
//!
//! The choice is persisted in localStorage and applied as a class on
//! `<html>`; `app.css` resolves the palette variables accordingly.
//! "Auto" means no class at all, so the stylesheet falls back to
//! `prefers-color-scheme`.
use wasm_bindgen::JsCast;
use crate::icons::IconName;
const STORAGE_KEY: &str = "fb-theme";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Theme {
/// Follow the OS `prefers-color-scheme` (the default).
Auto,
Light,
Dark,
}
impl Theme {
/// The next state in the toggle cycle: auto → light → dark → auto.
pub const fn next(self) -> Theme {
match self {
Theme::Auto => Theme::Light,
Theme::Light => Theme::Dark,
Theme::Dark => Theme::Auto,
}
}
/// Lowercase name, used in tooltips and as the localStorage value.
pub const fn label(self) -> &'static str {
match self {
Theme::Auto => "auto",
Theme::Light => "light",
Theme::Dark => "dark",
}
}
/// The glyph the toggle button shows for this state.
pub const fn icon(self) -> IconName {
match self {
Theme::Auto => IconName::ThemeAuto,
Theme::Light => IconName::ThemeLight,
Theme::Dark => IconName::ThemeDark,
}
}
/// Persist the choice. Auto stores nothing (like most sites: "no entry"
/// means "follow the OS"), so clearing the key returns to auto.
pub fn store(self) {
let Some(store) = local_storage() else {
return;
};
match self {
Theme::Auto => {
let _ = store.remove_item(STORAGE_KEY);
}
t => {
let _ = store.set_item(STORAGE_KEY, t.label());
}
}
}
/// The persisted choice; anything missing or invalid is
/// [`Theme::Auto`].
pub fn load() -> Theme {
let value = local_storage().and_then(|s| s.get_item(STORAGE_KEY).ok().flatten());
match value.as_deref() {
Some("light") => Theme::Light,
Some("dark") => Theme::Dark,
_ => Theme::Auto,
}
}
/// Apply to `<html>`: auto clears the class (the stylesheet falls back
/// to `prefers-color-scheme`), light/dark force a palette.
pub fn apply(self) {
let Some(html) = web_sys::window()
.and_then(|w| w.document())
.and_then(|d| d.document_element())
.and_then(|e| e.dyn_into::<web_sys::HtmlElement>().ok())
else {
return;
};
let classes = match self {
Theme::Auto => "",
t => t.label(),
};
html.set_class_name(classes);
}
}
/// localStorage, when the browser exposes it (private mode can deny it).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window()?.local_storage().ok()?
}
Mweb/src/views/admin.rs
@@ -1,61 +1,21 @@
//! Admin UI (milestone 7): user management and server settings.
//! Rendered in place of the browser when an admin opens the "Admin" panel.
//! Rendered as full views from the shell's "manage" section.
use leptos::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, AdminUser, Me, Mode};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::icons::IconName;
// ---------------------------------------------------------------------------
// Top-level admin view with tabs
// Settings view
// ---------------------------------------------------------------------------
#[component]
pub fn AdminView(me: ReadSignal<Option<Me>>) -> impl IntoView {
let (tab, set_tab) = signal("users".to_string());
view! {
<div class="admin-view">
<div class="admin-tabs">
<button
class="admin-tab"
class:active=move || tab.get() == "users"
on:click=move |_| set_tab.set("users".to_string())
>
"Users"
</button>
<button
class="admin-tab"
class:active=move || tab.get() == "settings"
on:click=move |_| set_tab.set("settings".to_string())
>
"Settings"
</button>
</div>
<div class="admin-body">
{move || {
if tab.get() == "users" {
view! { <UsersTab me=me/> }
.into_view()
.into_any()
} else {
view! { <SettingsTab/> }
.into_view()
.into_any()
}
}}
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Settings tab
// ---------------------------------------------------------------------------
#[component]
fn SettingsTab() -> impl IntoView {
pub fn SettingsView() -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (value, set_value) = signal(Option::<bool>::None);
let (busy, set_busy) = signal(false);
@@ -85,8 +45,9 @@ fn SettingsTab() -> impl IntoView {
});
};
view! {
<div class="settings-tab">
<h3 class="admin-section-title">"Server settings"</h3>
<div class="admin-view">
<div class="settings-tab">
<h3 class="admin-section-title">"Server settings"</h3>
{move || match value.get() {
None => view! {
<p class="muted">"Loading…"</p>
@@ -123,12 +84,13 @@ fn SettingsTab() -> impl IntoView {
.into_view()
.into_any(),
}}
</div>
</div>
}
}
// ---------------------------------------------------------------------------
// Users tab
// Users view
// ---------------------------------------------------------------------------
/// What the user form is editing: a brand-new user, or an existing one.
@@ -139,7 +101,7 @@ enum Editing {
}
#[component]
fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
pub fn UsersView(me: ReadSignal<Option<Me>>) -> impl IntoView {
let toast = use_context::<ToastMsg>().expect("toast context");
let (users, set_users) = signal(Option::<Vec<AdminUser>>::None);
let (editing, set_editing) = signal(Option::<Editing>::None);
@@ -191,6 +153,7 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
<div class="users-header">
<h3 class="admin-section-title">"Users"</h3>
<button class="btn btn-primary" on:click=move |_| set_editing.set(Some(Editing::New))>
<Icon name=IconName::Add class="ic-btn".to_string()/>
"New user"
</button>
</div>
@@ -212,15 +175,7 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
.iter()
.map(|u| {
let is_self = sid == Some(u.id);
let roots_desc = if u.roots.is_empty() {
"(no folders)".to_string()
} else {
u.roots
.iter()
.map(|r| format!("{} ({})", r.path, r.mode.as_str()))
.collect::<Vec<_>>()
.join(", ")
};
let is_admin = u.is_admin;
let u_edit = u.clone();
let u_del = u.clone();
let del = delete;
@@ -229,7 +184,15 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
<div class="user-row">
<div class="user-row-main">
<div class="user-row-name">
{if u.is_admin { "👑 " } else { "" }}
{move || {
if is_admin {
view! { <span class="ic-admin" title="Administrator"><Icon name=IconName::Admin/></span> }
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
{u.name.clone()}
{if is_self {
view! { <span class="muted">" (you)"</span> }
@@ -240,15 +203,6 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
view! {}.into_view()
}.into_any()
}}
{if u.is_admin {
view! { <span class="badge">"admin"</span> }
.into_view()
.into_any()
} else {
{
view! {}.into_view()
}.into_any()
}}
{if !u.active {
view! { <span class="badge badge-off">"disabled"</span> }
.into_view()
@@ -259,7 +213,6 @@ fn UsersTab(me: ReadSignal<Option<Me>>) -> impl IntoView {
}.into_any()
}}
</div>
<div class="user-row-meta muted">{roots_desc.clone()}</div>
</div>
<div class="user-row-actions">
<button
@@ -594,7 +547,7 @@ fn UserForm(
<option value=Mode::Ro.as_str() selected=ro_selected>"read-only"</option>
</select>
<button
class="btn btn-sm"
class="icon-btn icon-btn-sm"
title="Remove folder"
on:click=move |_| {
let mut v = roots_c.get();
@@ -604,7 +557,7 @@ fn UserForm(
set_roots_c.set(v);
}
>
"✕"
<Icon name=IconName::Close class="ic-btn".to_string()/>
</button>
</div>
}
Mweb/src/views/browser.rs
@@ -6,9 +6,10 @@ use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, Me, RootInfo};
use crate::components::icon::{Icon, IconName, icon_for};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::editor::{EditTarget, EditorModal};
use crate::icons::{IconName, icon_for};
use crate::preview::{PreviewKind, PreviewModal, PreviewTarget, preview_kind};
use crate::router::{Location, navigate};
use crate::util::{ViewMode, format_date, format_size};
@@ -23,7 +24,7 @@ enum ListState {
/// The root that is actually shown: the one in the location, or — when the
/// user has exactly one root — that root directly.
fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
pub fn effective_root<'a>(roots: &'a [RootInfo], loc: &Location) -> Option<&'a RootInfo> {
match loc.root_id {
Some(id) => roots.iter().find(|r| r.id == id),
None if roots.len() == 1 => Some(&roots[0]),
@@ -84,7 +85,6 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
};
let loc_now = loc.get();
match effective_root(&me.roots, &loc_now) {
None => root_picker(&me.roots, set_ctx, &loc_now).into_any(),
Some(root) => file_browser(
root,
loc,
@@ -96,6 +96,7 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
set_preview,
)
.into_any(),
None => no_folder_view(&me.roots, &loc_now).into_any(),
}
}}
<CtxMenuView
@@ -150,72 +151,35 @@ pub fn Browser(me: ReadSignal<Option<Me>>, loc: ReadSignal<Location>) -> impl In
}
// ---------------------------------------------------------------------------
// Root picker (shown when the user has multiple roots and is at the top level)
// Empty states (no resolvable root)
// ---------------------------------------------------------------------------
fn root_picker(
roots: &[RootInfo],
set_ctx: WriteSignal<Option<CtxMenu>>,
loc: &Location,
) -> impl IntoView {
/// Shown when the browser can't resolve a root: the user has no roots at all,
/// hasn't picked one yet (they are sidebar tabs now), or the URL points at a
/// root that no longer exists.
fn no_folder_view(roots: &[RootInfo], loc: &Location) -> impl IntoView {
let (title, message) = if roots.is_empty() {
(
"No folders available",
"An administrator hasn't assigned you any folders yet.",
)
} else if loc.root_id.is_none() {
(
"Select a folder",
"Choose a folder from the sidebar to start browsing.",
)
} else {
(
"Folder not available",
"This folder is no longer accessible. Choose another one from the sidebar.",
)
};
view! {
<div class="crumbs">
<span class="crumb current">"Folders"</span>
<div class="empty-state">
<Icon name=IconName::FolderOff class="empty-glyph".to_string()/>
<h3>{title}</h3>
<p class="muted">{message}</p>
</div>
{if roots.is_empty() {
view! {
<div class="empty-state">
<div class="empty-icon">"📂"</div>
<h3>"No folders available"</h3>
<p class="muted">"An administrator hasn't assigned you any folders yet."</p>
</div>
}
.into_view()
.into_any()
} else {
view! {
<div class="root-picker">
{roots.iter().map(|r| {
let id = r.id;
let name = r.name.clone();
let path = r.path.clone();
let mode = r.mode;
let share_tok = loc.share_token.clone();
view! {
<div
class="root-tile"
on:click=move |_| {
navigate(&Location {
root_id: Some(id),
path: vec![],
share_token: share_tok.clone(),
});
}
on:contextmenu=move |ev: MouseEvent| {
ev.prevent_default();
set_ctx.set(Some(CtxMenu {
x: ev.client_x(),
y: ev.client_y(),
entry: None,
}));
}
>
<Icon name=IconName::Folder class=Some("icon-big".into())/>
<div class="root-tile-name">{name}</div>
<div class="root-tile-path muted">
{if path == "." { "/".to_string() } else { path.clone() }}
</div>
<span class="badge">
{if mode.is_writable() { "read-write" } else { "read-only" }}
</span>
</div>
}
}).collect::<Vec<_>>()}
</div>
}
.into_view()
.into_any()
}}
}
}
@@ -282,9 +246,18 @@ fn file_browser(
</div>
};
// Toolbar (view toggle)
// Toolbar: refresh on the left, view toggles on the right.
let toolbar = view! {
<div class="toolbar">
<button
class="icon-btn"
title="Refresh"
aria-label="Refresh"
on:click=move |_| fetch.run(())
>
<Icon name=IconName::Refresh class="ic-btn".to_string()/>
</button>
<div class="toolbar-right">
<button
class=move || {
if view_mode.get() == ViewMode::Grid {
@@ -299,7 +272,7 @@ fn file_browser(
ViewMode::Grid.save();
}
>
<Icon name=IconName::Grid class=Some("ic-btn".into())/>
<Icon name=IconName::Grid class="ic-btn".to_string()/>
</button>
<button
class=move || {
@@ -315,8 +288,9 @@ fn file_browser(
ViewMode::List.save();
}
>
<Icon name=IconName::List class=Some("ic-btn".into())/>
<Icon name=IconName::List class="ic-btn".to_string()/>
</button>
</div>
</div>
};
@@ -468,7 +442,7 @@ fn grid_view(
on:contextmenu=on_ctx
title=title
>
<Icon name=icon class=Some("tile-icon".into())/>
<Icon name=icon class="tile-icon".to_string()/>
<div class="tile-name">{name}</div>
</div>
}
@@ -510,7 +484,7 @@ fn list_view(
on:contextmenu=on_ctx
title=title
>
<Icon name=icon class=Some("row-icon".into())/>
<Icon name=icon class="row-icon".to_string()/>
<span class="row-name">{name}</span>
<span class="row-size muted">{if is_dir { "—".to_string() } else { size }}</span>
<span class="row-date muted">{date}</span>
@@ -590,34 +564,43 @@ fn CtxMenuView(
Some(e) => {
let mut v = vec![];
if e.is_dir {
v.push(MenuItem::new("Open", action_open_dir(e, loc, own())));
v.push(MenuItem::new(
IconName::FolderOpen,
"Open",
action_open_dir(e, loc, own()),
));
} else {
// Serve the file itself in a new tab. HTML and SVG
// render as real pages there (sandboxed by the server),
// which is how you share a small site or an HTML report.
v.push(MenuItem::new(
IconName::OpenInNew,
"Open in new tab",
action_open_tab(e, root_id, loc, own()),
));
}
v.push(MenuItem::new(
IconName::Download,
"Download",
action_download(e, root_id, loc, set_dialog, toast, own()),
));
// Editing is only offered for files that decode as text.
if e.kind == FileKind::Text {
v.push(MenuItem::rw(
IconName::Edit,
"Edit",
is_rw,
action_edit(e, root_id, loc, set_editor, own()),
));
}
v.push(MenuItem::rw(
IconName::Rename,
"Rename",
is_rw,
action_rename(e, root_id, loc, refresh, toast, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Move,
"Move",
is_rw,
action_move_copy(Op::Move, e, root_id, me, loc, refresh, toast, set_dialog, own()),
@@ -625,20 +608,24 @@ fn CtxMenuView(
// Copying only writes at the destination, so a read-only
// source folder is fine.
v.push(MenuItem::new(
IconName::Copy,
"Copy",
action_move_copy(Op::Copy, e, root_id, me, loc, refresh, toast, set_dialog, own()),
));
if loc_now.share_token.is_none() {
v.push(MenuItem::new(
IconName::Share,
"Share",
action_share(e, root_id, loc, allow_writable_shares, set_dialog, own()),
));
}
v.push(MenuItem::new(
IconName::Info,
"Info",
action_info(e, loc, root_name, set_dialog, own()),
));
v.push(MenuItem::rw(
IconName::Delete,
"Delete",
is_rw,
action_delete(e, root_id, loc, refresh, toast, set_dialog, own()),
@@ -647,21 +634,23 @@ fn CtxMenuView(
}
None => vec![
MenuItem::rw(
IconName::NewFolder,
"New folder",
is_rw,
action_new_folder(root_id, loc, refresh, toast, set_dialog, own()),
),
MenuItem::rw(
IconName::Upload,
"Upload files",
is_rw,
action_upload(true, false, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::rw(
IconName::UploadFolder,
"Upload folder",
is_rw,
action_upload(true, true, root_id, loc, refresh, toast, set_dialog),
),
MenuItem::new("Refresh", Callback::new(move |_| refresh.run(()))),
],
};
@@ -682,23 +671,26 @@ fn CtxMenuView(
/// `hint` then says why it is disabled.
struct MenuItem {
label: &'static str,
icon: IconName,
action: Option<Callback<()>>,
hint: Option<&'static str>,
}
impl MenuItem {
fn new(label: &'static str, action: Callback<()>) -> Self {
fn new(icon: IconName, label: &'static str, action: Callback<()>) -> Self {
Self {
label,
icon,
action: Some(action),
hint: None,
}
}
/// An item that only works in a writable folder.
fn rw(label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
fn rw(icon: IconName, label: &'static str, is_rw: bool, action: Callback<()>) -> Self {
Self {
label,
icon,
action: is_rw.then_some(action),
hint: Some("Read-only folder"),
}
@@ -709,6 +701,7 @@ impl MenuItem {
fn CtxItem(item: MenuItem) -> impl IntoView {
let MenuItem {
label,
icon,
action,
hint,
} = item;
@@ -716,15 +709,16 @@ fn CtxItem(item: MenuItem) -> impl IntoView {
let hint = hint.unwrap_or_default();
view! {
<div
class=move || if enabled { "ctx-item" } else { "ctx-item disabled" }
title=move || if enabled { "" } else { hint }
class=move || if enabled { "ctx-item".to_string() } else { "ctx-item disabled".to_string() }
title=move || if enabled { "".to_string() } else { hint.to_string() }
on:click=move |_| {
if let Some(a) = &action {
a.run(());
}
}
>
{label}
<Icon name=icon class="ic-ctx".to_string()/>
<span>{label}</span>
</div>
}
}
Mweb/src/views/dialogs.rs
@@ -6,8 +6,9 @@ use wasm_bindgen_futures::spawn_local;
use web_sys::MouseEvent;
use crate::api::{self, Entry, RootInfo};
use crate::components::icon::{Icon, IconName};
use crate::components::icon::Icon;
use crate::components::toast::{ToastMsg, show};
use crate::icons::IconName;
/// Which mutating operation the destination picker is for.
#[derive(Clone, Copy, PartialEq)]
@@ -601,7 +602,7 @@ fn PickerDialog(
set_dir.set(newdir);
}
>
<Icon name=IconName::Folder class=Some("row-icon".into())/>
<Icon name=IconName::Folder class="row-icon".to_string()/>
<span class="row-name">{name}</span>
</div>
}
Mweb/src/views/login.rs
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me, input_value};
use crate::app::AuthPhase;
use crate::components::logo::Logo;
use crate::router::{Location, navigate};
#[component]
@@ -48,6 +49,7 @@ pub fn LoginView(
view! {
<div class="center-screen">
<div class="card auth-card">
<Logo class="auth-logo".to_string()/>
<h1>"filebrowser-ng"</h1>
<p class="muted">"Sign in to continue."</p>
<form on:submit=on_submit>
Mweb/src/views/setup.rs
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me, input_value};
use crate::app::AuthPhase;
use crate::components::logo::Logo;
use crate::router::{Location, navigate};
#[component]
@@ -60,6 +61,7 @@ pub fn SetupView(
view! {
<div class="center-screen">
<div class="card auth-card">
<Logo class="auth-logo".to_string()/>
<h1>"filebrowser-ng"</h1>
<p class="muted">
"First boot: create the admin account. This user can manage
Mweb/src/views/shell.rs
@@ -1,12 +1,35 @@
//! The signed-in shell: left sidebar (folders + manage tabs) and the topbar.
use leptos::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api::{self, Me};
use crate::components::icon::Icon;
use crate::components::logo::Logo;
use crate::components::toast::{ToastView, provide_toast};
use crate::router::Location;
use crate::views::admin::AdminView;
use crate::views::browser::Browser;
use crate::views::shares::SharesPanel;
use crate::icons::IconName;
use crate::router::{Location, navigate};
use crate::views::admin::{SettingsView, UsersView};
use crate::views::browser::{Browser, effective_root};
use crate::views::shares::SharesView;
/// Whether the signed-in user has admin rights.
fn is_admin_of(me: &Option<Me>) -> bool {
me.as_ref()
.and_then(|m| m.user.as_ref())
.is_some_and(|u| u.is_admin)
}
/// Which section of the shell is showing. `Files` is the browser; the rest are
/// the "manage" sections. The file location lives in the URL hash, the manage
/// panel is in-memory state.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Panel {
Files,
Shares,
Users,
Settings,
}
#[component]
pub fn ShellView(
@@ -14,10 +37,13 @@ pub fn ShellView(
set_me: WriteSignal<Option<Me>>,
set_phase: WriteSignal<crate::app::AuthPhase>,
loc: ReadSignal<Location>,
theme: ReadSignal<crate::theme::Theme>,
set_theme: WriteSignal<crate::theme::Theme>,
) -> impl IntoView {
let toast = provide_toast();
let (show_shares, set_show_shares) = signal(false);
let (show_admin, set_show_admin) = signal(false);
let (panel, set_panel) = signal(Panel::Files);
// True while the off-canvas sidebar is open (narrow screens only).
let (nav_open, set_nav_open) = signal(false);
let on_logout = move |_| {
spawn_local(async move {
@@ -27,68 +53,205 @@ pub fn ShellView(
});
};
// Select a manage section; on narrow screens the sidebar closes.
let open_panel = Callback::new(move |p: Panel| {
set_panel.set(p);
set_nav_open.set(false);
});
view! {
<div class="layout">
<header class="topbar">
<div class="brand">"📂 filebrowser-ng"</div>
<div class="topbar-left">
<button
class="icon-btn nav-toggle"
title="Menu"
aria-label="Open menu"
on:click=move |_| set_nav_open.update(|v| *v = !*v)
>
<Icon name=IconName::Menu class="ic-btn".to_string()/>
</button>
<div class="brand">
<Logo/>
<span class="brand-name">"filebrowser-ng"</span>
</div>
</div>
<div class="topbar-right">
{move || {
let m = me.get()?;
let u = m.user?;
let is_admin = u.is_admin;
Some(
let Some(u) = me.get().and_then(|m| m.user) else {
return {
view! {}.into_any()
};
};
view! {
<span class="user-badge">{u.name.clone()}</span>
}
.into_view()
.into_any()
}}
<button
class="icon-btn"
title=move || format!("Theme: {} (click to switch)", theme.get().label())
aria-label="Toggle theme"
on:click=move |_| {
let next = theme.get().next();
set_theme.set(next);
next.store();
next.apply();
}
>
{move || {
view! {
<span class="user-badge">
{u.name}
<Show when=move || is_admin>
<span class="badge">"admin"</span>
</Show>
</span>
<Icon name=theme.get().icon() class="ic-btn".to_string()/>
}
.into_view()
.into_any(),
)
}}
<Show when=move || me.get().and_then(|m| m.user).map(|u| u.is_admin).unwrap_or(false)>
<button
class="btn"
class:active=move || show_admin.get()
on:click=move |_| set_show_admin.update(|v| *v = !*v)
>
"Admin"
</button>
</Show>
<button class="btn" on:click=move |_| set_show_shares.set(true)>"Shares"</button>
<button class="btn" on:click=on_logout>"Log out"</button>
}}
</button>
<button
class="icon-btn"
title="Log out"
aria-label="Log out"
on:click=move |_| on_logout(())
>
<Icon name=IconName::Logout class="ic-btn".to_string()/>
</button>
</div>
</header>
<main class="content">
{move || {
let is_admin = me
.get()
.and_then(|m| m.user)
.map(|u| u.is_admin)
.unwrap_or(false);
if show_admin.get() && is_admin {
view! { <AdminView me=me/> }
.into_view()
.into_any()
} else {
view! { <Browser me=me loc=loc/> }
.into_view()
.into_any()
{move || {
if nav_open.get() {
view! {
<div class="nav-backdrop" on:click=move |_| set_nav_open.set(false)></div>
}
}}
</main>
{move || if show_shares.get() {
view! {
<SharesPanel close=Callback::new(move |_| set_show_shares.set(false))/>
.into_view()
.into_any()
} else {
view! {}.into_any()
}
.into_view()
.into_any()
} else {
view! {}.into_any()
}}
<div class="body-row">
<aside
class=move || {
if nav_open.get() {
"sidebar open".to_string()
} else {
"sidebar".to_string()
}
}
>
<nav class="nav">
<div class="nav-section">
<div class="nav-section-label">"Files"</div>
{move || {
let Some(m) = me.get() else {
return {
view! {}.into_any()
};
};
let cur_root = effective_root(&m.roots, &loc.get()).map(|r| r.id);
m.roots
.iter()
.map(|r| {
let id = r.id;
let name = r.name.clone();
let path = r.path.clone();
let ro = !r.mode.is_writable();
view! {
<button
class="nav-tab"
class:active=move || {
panel.get() == Panel::Files && cur_root == Some(id)
}
title=move || {
if path == "." {
"/".to_string()
} else {
path.clone()
}
}
on:click=move |_| {
set_panel.set(Panel::Files);
set_nav_open.set(false);
navigate(&Location {
root_id: Some(id),
path: vec![],
share_token: None,
});
}
>
<Icon name=IconName::Folder class="ic-tab".to_string()/>
<span class="nav-tab-name">{name}</span>
{move || {
if ro {
view! { <span class="nav-tab-note">"ro"</span> }
.into_view()
.into_any()
} else {
view! {}.into_any()
}
}}
</button>
}
})
.collect::<Vec<_>>()
.into_view()
.into_any()
}}
</div>
<div class="nav-section">
<div class="nav-section-label">"Manage"</div>
<button
class="nav-tab"
class:active=move || panel.get() == Panel::Shares
on:click=move |_| open_panel.run(Panel::Shares)
>
<Icon name=IconName::Share class="ic-tab".to_string()/>
<span class="nav-tab-name">"Shares"</span>
</button>
<Show when=move || is_admin_of(&me.get())>
<button
class="nav-tab"
class:active=move || panel.get() == Panel::Users
on:click=move |_| open_panel.run(Panel::Users)
>
<Icon name=IconName::User class="ic-tab".to_string()/>
<span class="nav-tab-name">"Users"</span>
</button>
<button
class="nav-tab"
class:active=move || panel.get() == Panel::Settings
on:click=move |_| open_panel.run(Panel::Settings)
>
<Icon name=IconName::Settings class="ic-tab".to_string()/>
<span class="nav-tab-name">"Settings"</span>
</button>
</Show>
</div>
</nav>
</aside>
<main class="content">
{move || {
let admin = is_admin_of(&me.get());
match panel.get() {
Panel::Files => view! { <Browser me=me loc=loc/> }
.into_view()
.into_any(),
Panel::Shares => view! { <SharesView/> }
.into_view()
.into_any(),
Panel::Users if admin => {
view! { <UsersView me=me/> }.into_view().into_any()
}
Panel::Settings if admin => {
view! { <SettingsView/> }.into_view().into_any()
}
// Not an admin anymore (e.g. permissions changed):
// fall back to the files view.
_ => view! { <Browser me=me loc=loc/> }
.into_view()
.into_any(),
}
}}
</main>
</div>
<ToastView toast=toast/>
</div>
}