Rustelo/client/src/components/admin/AdminLayout.rs
2025-07-07 23:05:46 +01:00

366 lines
15 KiB
Rust

use crate::i18n::use_i18n;
use crate::pages::admin::{AdminContent, AdminDashboard, AdminRoles, AdminUsers};
use leptos::prelude::*;
#[derive(Clone, Debug, PartialEq)]
pub enum AdminSection {
Dashboard,
Users,
Roles,
Content,
}
impl AdminSection {
pub fn route(&self) -> &'static str {
match self {
AdminSection::Dashboard => "/admin",
AdminSection::Users => "/admin/users",
AdminSection::Roles => "/admin/roles",
AdminSection::Content => "/admin/content",
}
}
pub fn title(&self, i18n: &crate::i18n::UseI18n) -> String {
match self {
AdminSection::Dashboard => i18n.t("admin.dashboard.title"),
AdminSection::Users => i18n.t("admin.users.title"),
AdminSection::Roles => i18n.t("admin.roles.title"),
AdminSection::Content => i18n.t("admin.content.title"),
}
}
pub fn icon(&self) -> &'static str {
match self {
AdminSection::Dashboard => {
"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586l-2 2V5H5v14h7v2H4a1 1 0 01-1-1V4z"
}
AdminSection::Users => {
"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"
}
AdminSection::Roles => {
"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
}
AdminSection::Content => {
"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
}
}
}
}
#[component]
pub fn AdminLayout(
current_path: ReadSignal<String>,
#[prop(optional)] children: Option<Children>,
) -> impl IntoView {
let i18n = use_i18n();
let current_section = Memo::new(move |_| {
let pathname = current_path.get();
match pathname.as_str() {
"/admin/users" => AdminSection::Users,
"/admin/roles" => AdminSection::Roles,
"/admin/content" => AdminSection::Content,
_ => AdminSection::Dashboard,
}
});
view! {
<div class="min-h-screen bg-gray-50">
<div class="flex">
// Sidebar
<div class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg border-r border-gray-200 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0">
<div class="flex items-center justify-center h-16 px-4 bg-indigo-600">
<h1 class="text-xl font-bold text-white">
"Admin Dashboard"
</h1>
</div>
<nav class="mt-8 px-4">
<AdminNavItem
section=AdminSection::Dashboard
current_section=current_section
i18n=i18n.clone()
/>
<AdminNavItem
section=AdminSection::Users
current_section=current_section
i18n=i18n.clone()
/>
<AdminNavItem
section=AdminSection::Roles
current_section=current_section
i18n=i18n.clone()
/>
<AdminNavItem
section=AdminSection::Content
current_section=current_section
i18n=i18n.clone()
/>
</nav>
// User info at bottom
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
<div class="ml-3 flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">
"Admin User"
</p>
<p class="text-xs text-gray-500 truncate">
"admin@example.com"
</p>
</div>
<div class="ml-2">
<button class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
// Main content
<div class="flex-1 lg:ml-64">
<main class="flex-1">
{match current_section.get() {
AdminSection::Dashboard => view! { <AdminDashboard /> }.into_any(),
AdminSection::Users => view! { <AdminUsers /> }.into_any(),
AdminSection::Roles => view! { <AdminRoles /> }.into_any(),
AdminSection::Content => view! { <AdminContent /> }.into_any(),
}}
{children.map(|c| c()).unwrap_or_else(|| view! {}.into_any())}
</main>
</div>
</div>
</div>
}
}
#[component]
fn AdminNavItem(
section: AdminSection,
current_section: Memo<AdminSection>,
i18n: crate::i18n::UseI18n,
) -> impl IntoView {
let section_route = section.route();
let section_icon = section.icon();
let section_title = section.title(&i18n);
let is_current = Memo::new(move |_| current_section.get() == section);
view! {
<a
href=section_route
class=move || {
let base_classes = "group flex items-center px-2 py-2 text-sm font-medium rounded-md transition-colors duration-150 ease-in-out mb-1";
if is_current.get() {
format!("{} bg-indigo-100 text-indigo-700", base_classes)
} else {
format!("{} text-gray-600 hover:bg-gray-50 hover:text-gray-900", base_classes)
}
}
>
<svg
class=move || {
let base_classes = "mr-3 flex-shrink-0 h-6 w-6";
if is_current.get() {
format!("{} text-indigo-500", base_classes)
} else {
format!("{} text-gray-400 group-hover:text-gray-500", base_classes)
}
}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d=section_icon
></path>
</svg>
{section_title}
</a>
}
}
#[component]
pub fn AdminBreadcrumb(current_path: ReadSignal<String>) -> impl IntoView {
let i18n = use_i18n();
let breadcrumb_items = Memo::new(move |_| {
let pathname = current_path.get();
let mut items = vec![("Admin".to_string(), "/admin".to_string())];
match pathname.as_str() {
"/admin/users" => items.push((
i18n.clone().t("admin.users.title"),
"/admin/users".to_string(),
)),
"/admin/roles" => items.push((
i18n.clone().t("admin.roles.title"),
"/admin/roles".to_string(),
)),
"/admin/content" => items.push((
i18n.clone().t("admin.content.title"),
"/admin/content".to_string(),
)),
_ => {}
}
items
});
view! {
<nav class="flex mb-4" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<For
each=move || breadcrumb_items.get()
key=|(title, _)| title.clone()
children=move |(title, href)| {
let items = breadcrumb_items.get();
let is_last = items.last().map(|(t, _)| t.as_str()) == Some(&title);
view! {
<li class="inline-flex items-center">
{if is_last {
view! {
<span class="ml-1 text-sm font-medium text-gray-500 md:ml-2">
{title}
</span>
}.into_any()
} else {
view! {
<a
href=href
class="inline-flex items-center text-sm font-medium text-gray-700 hover:text-blue-600"
>
{title}
</a>
<svg class="w-6 h-6 text-gray-400 ml-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
}.into_any()
}}
</li>
}
}
/>
</ol>
</nav>
}
}
#[component]
pub fn AdminHeader(
#[prop(optional)] title: Option<String>,
#[prop(optional)] subtitle: Option<String>,
#[prop(optional)] actions: Option<Children>,
) -> impl IntoView {
let title_text = title.unwrap_or_else(|| "Admin".to_string());
let subtitle_text = subtitle.unwrap_or_default();
let has_subtitle = !subtitle_text.is_empty();
view! {
<div class="bg-white shadow">
<div class="px-4 sm:px-6 lg:max-w-6xl lg:mx-auto lg:px-8">
<div class="py-6 md:flex md:items-center md:justify-between lg:border-t lg:border-gray-200">
<div class="flex-1 min-w-0">
<div class="flex items-center">
<div>
<div class="flex items-center">
<h1 class="ml-3 text-2xl font-bold leading-7 text-gray-900 sm:leading-9 sm:truncate">
{title_text}
</h1>
</div>
<Show when=move || has_subtitle>
<dl class="mt-6 flex flex-col sm:ml-3 sm:mt-1 sm:flex-row sm:flex-wrap">
<dd class="text-sm text-gray-500 sm:mr-6">
{subtitle_text.clone()}
</dd>
</dl>
</Show>
</div>
</div>
</div>
<div class="mt-6 flex space-x-3 md:mt-0 md:ml-4">
{actions.map(|a| a()).unwrap_or_else(|| view! {}.into_any())}
</div>
</div>
</div>
</div>
}
}
#[component]
pub fn AdminCard(
#[prop(optional)] title: Option<String>,
#[prop(optional)] class: Option<String>,
children: Children,
) -> impl IntoView {
let class_str = class.unwrap_or_default();
let title_str = title.unwrap_or_default();
let has_title = !title_str.is_empty();
view! {
<div class=format!(
"bg-white overflow-hidden shadow rounded-lg {}",
class_str
)>
<Show when=move || has_title>
<div class="px-4 py-5 sm:p-6 border-b border-gray-200">
<h3 class="text-lg leading-6 font-medium text-gray-900">
{title_str.clone()}
</h3>
</div>
</Show>
<div class="px-4 py-5 sm:p-6">
{children()}
</div>
</div>
}
}
#[component]
pub fn AdminEmptyState(
#[prop(optional)] icon: Option<String>,
#[prop(optional)] title: Option<String>,
#[prop(optional)] description: Option<String>,
#[prop(optional)] action: Option<Children>,
) -> impl IntoView {
let icon_str = icon.unwrap_or_default();
let title_str = title.unwrap_or_else(|| "No items".to_string());
let description_str = description.unwrap_or_default();
let has_icon = !icon_str.is_empty();
let has_description = !description_str.is_empty();
view! {
<div class="text-center py-12">
<Show when=move || has_icon>
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=icon_str.clone()></path>
</svg>
</Show>
<h3 class="mt-2 text-sm font-medium text-gray-900">
{title_str}
</h3>
<Show when=move || has_description>
<p class="mt-1 text-sm text-gray-500">
{description_str.clone()}
</p>
</Show>
<div class="mt-6">
{action.map(|a| a()).unwrap_or_else(|| view! {}.into_any())}
</div>
</div>
}
}