366 lines
15 KiB
Rust
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>
|
|
}
|
|
}
|