Admin: replace top tabs with sidebar menu (RTL on right)
Task #9 — the admin page used a horizontal Tabs bar across the top. User wanted an OS-style admin shell with a vertical side menu that sits on the right in Arabic and on the left in English. Changes (artifacts/teaboy-os/src/pages/admin.tsx): - Removed the Tabs/TabsList/TabsTrigger/TabsContent layout - Added local section state ("dashboard" | "apps" | "services" | "users" | "settings"), defaulting to dashboard - Desktop (>=md): vertical sidebar (240px wide) with icon + label for each section, glass-style highlighted active item. Sidebar is the first DOM child of the flex row, so under dir="rtl" it naturally appears on the right; under dir="ltr" on the left. - Mobile (<md): sidebar hidden; hamburger button added in the header opens a Sheet drawer (right side in RTL, left in LTR) with the same nav items. Selecting an item closes the drawer. - Added a placeholder Dashboard panel ("Dashboard widgets coming soon.") so the new default section has content. - Added bilingual locale keys: admin.nav.{dashboard,apps,services, users,settings,menu} and admin.dashboardSoon in en.json/ar.json. - All existing CRUD (apps/services/users) and Site Settings panel preserved untouched inside their new section blocks. Verified: pnpm --filter @workspace/teaboy-os run typecheck passes.
This commit is contained in:
@@ -96,7 +96,16 @@
|
||||
"siteNameAr": "اسم الموقع (عربي)",
|
||||
"siteNameEn": "اسم الموقع (إنجليزي)",
|
||||
"registrationOpen": "السماح بإنشاء حسابات",
|
||||
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد."
|
||||
"registrationOpenHint": "عند الإيقاف، الأدمن فقط يقدر ينشئ مستخدمين جدد.",
|
||||
"nav": {
|
||||
"dashboard": "الرئيسية",
|
||||
"apps": "التطبيقات",
|
||||
"services": "الخدمات",
|
||||
"users": "المستخدمين",
|
||||
"settings": "الإعدادات",
|
||||
"menu": "القائمة"
|
||||
},
|
||||
"dashboardSoon": "إحصائيات اللوحة قريباً."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "404 — الصفحة غير موجودة",
|
||||
|
||||
@@ -96,7 +96,16 @@
|
||||
"siteNameAr": "Site Name (Arabic)",
|
||||
"siteNameEn": "Site Name (English)",
|
||||
"registrationOpen": "Allow public registration",
|
||||
"registrationOpenHint": "When off, only admins can create new users."
|
||||
"registrationOpenHint": "When off, only admins can create new users.",
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"apps": "Apps",
|
||||
"services": "Services",
|
||||
"users": "Users",
|
||||
"settings": "Settings",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"dashboardSoon": "Dashboard widgets coming soon."
|
||||
},
|
||||
"notFound": {
|
||||
"title": "404 — Page Not Found",
|
||||
|
||||
@@ -24,13 +24,16 @@ import {
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2 } from "lucide-react";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type AdminSection = "dashboard" | "apps" | "services" | "users" | "settings";
|
||||
|
||||
type AppForm = {
|
||||
nameAr: string;
|
||||
@@ -87,6 +90,42 @@ export default function AdminPage() {
|
||||
const [editingApp, setEditingApp] = useState<{ id?: number; form: AppForm } | null>(null);
|
||||
const [editingService, setEditingService] = useState<{ id?: number; form: ServiceForm } | null>(null);
|
||||
const [newUserForm, setNewUserForm] = useState<{ username: string; email: string; password: string } | null>(null);
|
||||
const [section, setSection] = useState<AdminSection>("dashboard");
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
|
||||
const navItems: { key: AdminSection; label: string; Icon: typeof LayoutDashboard }[] = [
|
||||
{ key: "dashboard", label: t("admin.nav.dashboard"), Icon: LayoutDashboard },
|
||||
{ key: "apps", label: t("admin.nav.apps"), Icon: Grid2X2 },
|
||||
{ key: "services", label: t("admin.nav.services"), Icon: Coffee },
|
||||
{ key: "users", label: t("admin.nav.users"), Icon: UsersIcon },
|
||||
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
|
||||
];
|
||||
|
||||
const renderNavList = (onSelect?: () => void) => (
|
||||
<nav className="flex flex-col gap-1">
|
||||
{navItems.map(({ key, label, Icon }) => {
|
||||
const active = section === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setSection(key);
|
||||
onSelect?.();
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-3 w-full px-3 py-2.5 rounded-xl text-sm transition-colors text-start",
|
||||
active
|
||||
? "bg-primary/15 text-primary font-medium shadow-sm"
|
||||
: "text-muted-foreground hover:bg-slate-100 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon size={18} className="shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
|
||||
const emptyAppForm: AppForm = { nameAr: "", nameEn: "", slug: "", iconName: "Grid2X2", route: "/", color: "#6366f1", sortOrder: 0 };
|
||||
const emptyServiceForm: ServiceForm = { nameAr: "", nameEn: "", descriptionAr: "", descriptionEn: "", price: "0.00", imageUrl: "", isAvailable: true };
|
||||
@@ -171,10 +210,27 @@ export default function AdminPage() {
|
||||
>
|
||||
<BackIcon size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings size={20} className="text-red-400" />
|
||||
<h1 className="text-lg font-semibold text-foreground">{t("admin.title")}</h1>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Settings size={20} className="text-red-400 shrink-0" />
|
||||
<h1 className="text-lg font-semibold text-foreground truncate">{t("admin.title")}</h1>
|
||||
</div>
|
||||
<Sheet open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<button
|
||||
className="md:hidden p-2 rounded-xl hover:bg-slate-100 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label={t("admin.nav.menu")}
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side={isRtl ? "right" : "left"} className="w-72 p-4">
|
||||
<div className="flex items-center gap-2 mb-4 mt-2">
|
||||
<Settings size={18} className="text-red-400" />
|
||||
<h2 className="font-semibold text-foreground">{t("admin.title")}</h2>
|
||||
</div>
|
||||
{renderNavList(() => setMobileNavOpen(false))}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Edit App Modal */}
|
||||
@@ -303,17 +359,24 @@ export default function AdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 p-4 pb-8">
|
||||
<Tabs defaultValue="apps">
|
||||
<TabsList className="glass-panel w-full mb-4">
|
||||
<TabsTrigger value="apps" className="flex-1">{t("admin.manageApps")}</TabsTrigger>
|
||||
<TabsTrigger value="services" className="flex-1">{t("admin.manageServices")}</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex-1">{t("admin.manageUsers")}</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">{t("admin.siteSettings")}</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex-1 flex w-full">
|
||||
{/* Sidebar (desktop) */}
|
||||
<aside className="hidden md:block w-60 shrink-0 border-e border-slate-200/70 bg-white/40 backdrop-blur-sm p-3">
|
||||
<div className="sticky top-20">
|
||||
{renderNavList()}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Apps Tab */}
|
||||
<TabsContent value="apps" className="space-y-3">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0 p-4 pb-8">
|
||||
{section === "dashboard" && (
|
||||
<div className="glass-panel rounded-2xl p-8 text-center text-muted-foreground">
|
||||
{t("admin.dashboardSoon")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "apps" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setEditingApp({ form: emptyAppForm })}>
|
||||
<Plus size={14} className="me-1" />
|
||||
@@ -377,10 +440,11 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Tab */}
|
||||
<TabsContent value="services" className="space-y-3">
|
||||
{section === "services" && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => setEditingService({ form: emptyServiceForm })}>
|
||||
<Plus size={14} className="me-1" />
|
||||
@@ -438,10 +502,11 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Users Tab */}
|
||||
<TabsContent value="users" className="space-y-3">
|
||||
{section === "users" && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
@@ -491,12 +556,15 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TabsContent value="settings" className="space-y-3">
|
||||
<SiteSettingsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{section === "settings" && (
|
||||
<div className="space-y-3">
|
||||
<SiteSettingsPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user