Rewrite Admin dashboard with tabbed layout
cgen-b3adf2085f4148f693748703c1e60577
This commit is contained in:
parent
6fea8ffe8c
commit
14a2f34976
1 changed files with 474 additions and 404 deletions
|
|
@ -111,17 +111,16 @@ export default function Admin() {
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="min-h-screen bg-aethex-gradient py-12">
|
<div className="min-h-screen bg-aethex-gradient py-12">
|
||||||
<div className="container mx-auto px-4 max-w-3xl">
|
<div className="container mx-auto px-4 max-w-3xl">
|
||||||
<Card className="bg-red-500/10 border-red-500/30">
|
<Card className="bg-red-500/10 border-red-500/30 backdrop-blur">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-red-400">Access Denied</CardTitle>
|
<CardTitle className="text-red-400">Access denied</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
You don't have permission to access the admin panel.
|
This panel is restricted to {ownerEmail}. If you need access, contact the site owner.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex gap-2">
|
||||||
<Button onClick={() => navigate("/dashboard")}>
|
<Button onClick={() => navigate("/dashboard")}>Go to dashboard</Button>
|
||||||
Go to Dashboard
|
<Button variant="outline" onClick={() => navigate("/support")}>Contact support</Button>
|
||||||
</Button>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -287,447 +286,518 @@ export default function Admin() {
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="min-h-screen bg-aethex-gradient py-12">
|
<div className="min-h-screen bg-aethex-gradient py-12">
|
||||||
<div className="container mx-auto px-4 max-w-6xl space-y-8">
|
<div className="container mx-auto px-4 max-w-6xl space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<h1 className="text-3xl font-bold text-gradient">Admin Panel</h1>
|
<h1 className="text-3xl font-bold text-gradient">Admin Control Center</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Site Owner • Admin • Founder
|
Unified oversight for AeThex operations, content, and community.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 mt-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge
|
<Badge variant="outline" className="border-green-500/50 text-green-300">
|
||||||
variant="outline"
|
Owner
|
||||||
className="border-green-500/50 text-green-400"
|
|
||||||
>
|
|
||||||
Site Owner
|
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge variant="outline" className="border-blue-500/50 text-blue-300">
|
||||||
variant="outline"
|
|
||||||
className="border-blue-500/50 text-blue-400"
|
|
||||||
>
|
|
||||||
Admin
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge
|
<Badge variant="outline" className="border-purple-500/50 text-purple-300">
|
||||||
variant="outline"
|
|
||||||
className="border-purple-500/50 text-purple-400"
|
|
||||||
>
|
|
||||||
Founder
|
Founder
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Signed in as <span className="text-foreground">{normalizedEmail || ownerEmail}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate("/dashboard")}>
|
<Button variant="outline" onClick={() => navigate("/dashboard")}>
|
||||||
Dashboard
|
Dashboard
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => navigate("/profile")}>
|
<Button variant="outline" onClick={() => navigate("/profile")}>
|
||||||
Profile
|
Profile
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={() => setActiveTab("content")}>Create update</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<Card className="bg-card/50 border-border/50">
|
<TabsList className="w-full justify-start gap-2 overflow-x-auto border border-border/40 bg-background/40 px-1 py-1 backdrop-blur">
|
||||||
<CardHeader>
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
<div className="flex items-center gap-2">
|
<TabsTrigger value="content">Content</TabsTrigger>
|
||||||
<Shield className="h-5 w-5 text-green-400" />
|
<TabsTrigger value="community">Community</TabsTrigger>
|
||||||
<CardTitle className="text-lg">Access Control</CardTitle>
|
<TabsTrigger value="operations">Operations</TabsTrigger>
|
||||||
</div>
|
</TabsList>
|
||||||
<CardDescription>
|
|
||||||
Owner-only access is enforced by email
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="text-sm space-y-1 text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
Owner:{" "}
|
|
||||||
<span className="text-foreground">mrpiglr@gmail.com</span>
|
|
||||||
</li>
|
|
||||||
<li>All other users are denied access</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border/50">
|
<TabsContent value="overview" className="space-y-6">
|
||||||
<CardHeader>
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div className="flex items-center gap-2">
|
{overviewStats.map((stat) => (
|
||||||
<Users className="h-5 w-5 text-blue-400" />
|
<AdminStatCard
|
||||||
<CardTitle className="text-lg">Users & Roles</CardTitle>
|
key={stat.title}
|
||||||
</div>
|
title={stat.title}
|
||||||
<CardDescription>
|
value={stat.value}
|
||||||
Future: manage roles, invitations, and status
|
description={stat.description}
|
||||||
</CardDescription>
|
trend={stat.trend}
|
||||||
</CardHeader>
|
icon={stat.icon}
|
||||||
<CardContent>
|
tone={stat.tone}
|
||||||
<p className="text-sm text-muted-foreground">Coming soon</p>
|
/>
|
||||||
</CardContent>
|
))}
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{/* Blog Posts Management */}
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card className="bg-card/50 border-border/50 md:col-span-2 lg:col-span-3">
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<PenTool className="h-5 w-5 text-aethex-400" />
|
<Command className="h-5 w-5 text-aethex-300" />
|
||||||
<CardTitle className="text-lg">Blog Posts</CardTitle>
|
<CardTitle>Quick actions</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>
|
<CardDescription>Launch frequent administrative workflows.</CardDescription>
|
||||||
Manage blog content stored in Supabase
|
</CardHeader>
|
||||||
</CardDescription>
|
<CardContent className="grid gap-3">
|
||||||
</CardHeader>
|
{quickActions.map(({ label, description, icon: ActionIcon, action }) => (
|
||||||
<CardContent className="space-y-3">
|
<button
|
||||||
<div className="flex justify-between">
|
key={label}
|
||||||
<Button
|
type="button"
|
||||||
size="sm"
|
onClick={action}
|
||||||
variant="outline"
|
className="group flex items-start gap-3 rounded-lg border border-border/30 bg-background/40 px-4 py-3 text-left transition hover:border-aethex-400/60 hover:bg-background/60"
|
||||||
disabled={loadingPosts}
|
>
|
||||||
onClick={async () => {
|
<ActionIcon className="mt-0.5 h-5 w-5 text-aethex-400 transition group-hover:text-aethex-200" />
|
||||||
try {
|
<div className="space-y-1">
|
||||||
setLoadingPosts(true);
|
<p className="font-medium text-foreground">{label}</p>
|
||||||
const res = await fetch("/api/blog?limit=100");
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
const data = res.ok ? await res.json() : [];
|
</div>
|
||||||
if (Array.isArray(data)) setBlogPosts(data);
|
</button>
|
||||||
} finally {
|
))}
|
||||||
setLoadingPosts(false);
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-green-400" />
|
||||||
|
<CardTitle>Access control</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Owner-only access enforced via Supabase roles.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<ul className="space-y-2 leading-relaxed">
|
||||||
|
<li>
|
||||||
|
Owner email: <span className="text-foreground">{ownerEmail}</span>
|
||||||
|
</li>
|
||||||
|
<li>Roles are provisioned automatically on owner sign-in.</li>
|
||||||
|
<li>Grant additional admins by updating Supabase role assignments.</li>
|
||||||
|
</ul>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setActiveTab("community")}>
|
||||||
|
View members
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate("/support")}>
|
||||||
|
Contact support
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="content" className="space-y-6">
|
||||||
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PenTool className="h-5 w-5 text-aethex-300" />
|
||||||
|
<CardTitle>Content overview</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>
|
||||||
|
{publishedPosts} published {publishedPosts === 1 ? "post" : "posts"} · {loadingPosts ? "refreshing content…" : "latest Supabase sync"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>
|
||||||
|
Drafts and announcements appear instantly on the public blog after saving. Use scheduled releases for major updates and keep thumbnails optimised for 1200×630.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PenTool className="h-5 w-5 text-aethex-400" />
|
||||||
|
<CardTitle className="text-lg">Blog posts</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Manage blog content stored in Supabase</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loadingPosts}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
setLoadingPosts(true);
|
||||||
|
const res = await fetch("/api/blog?limit=100");
|
||||||
|
const data = res.ok ? await res.json() : [];
|
||||||
|
if (Array.isArray(data)) setBlogPosts(data);
|
||||||
|
} finally {
|
||||||
|
setLoadingPosts(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingPosts ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setBlogPosts([
|
||||||
|
{
|
||||||
|
title: "New Post",
|
||||||
|
slug: "new-post",
|
||||||
|
category: "General",
|
||||||
|
},
|
||||||
|
...blogPosts,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
>
|
Add post
|
||||||
Refresh
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setBlogPosts([
|
|
||||||
{
|
|
||||||
title: "New Post",
|
|
||||||
slug: "new-post",
|
|
||||||
category: "General",
|
|
||||||
},
|
|
||||||
...blogPosts,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add Post
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{blogPosts.map((p, i) => (
|
{blogPosts.length === 0 && (
|
||||||
<div
|
<p className="text-sm text-muted-foreground">
|
||||||
key={p.id || p.slug || i}
|
No posts loaded yet. Use “Refresh” or “Add post” to start managing content.
|
||||||
className="p-3 rounded border border-border/40 space-y-2"
|
</p>
|
||||||
>
|
)}
|
||||||
<div className="grid md:grid-cols-2 gap-2">
|
|
||||||
<input
|
{blogPosts.map((p, i) => (
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
<div
|
||||||
placeholder="Title"
|
key={p.id || p.slug || i}
|
||||||
value={p.title || ""}
|
className="space-y-2 rounded border border-border/40 bg-background/40 p-3"
|
||||||
|
>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Title"
|
||||||
|
value={p.title || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = blogPosts.slice();
|
||||||
|
next[i] = { ...next[i], title: e.target.value };
|
||||||
|
setBlogPosts(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Slug"
|
||||||
|
value={p.slug || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = blogPosts.slice();
|
||||||
|
next[i] = { ...next[i], slug: e.target.value };
|
||||||
|
setBlogPosts(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Author"
|
||||||
|
value={p.author || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = blogPosts.slice();
|
||||||
|
n[i] = { ...n[i], author: e.target.value };
|
||||||
|
setBlogPosts(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Date"
|
||||||
|
value={p.date || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = blogPosts.slice();
|
||||||
|
n[i] = { ...n[i], date: e.target.value };
|
||||||
|
setBlogPosts(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Read time (e.g., 8 min read)"
|
||||||
|
value={p.read_time || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = blogPosts.slice();
|
||||||
|
n[i] = { ...n[i], read_time: e.target.value };
|
||||||
|
setBlogPosts(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Category"
|
||||||
|
value={p.category || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = blogPosts.slice();
|
||||||
|
n[i] = { ...n[i], category: e.target.value };
|
||||||
|
setBlogPosts(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
placeholder="Image URL"
|
||||||
|
value={p.image || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = blogPosts.slice();
|
||||||
|
n[i] = { ...n[i], image: e.target.value };
|
||||||
|
setBlogPosts(n);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="w-full bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Excerpt"
|
||||||
|
value={p.excerpt || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = blogPosts.slice();
|
const n = blogPosts.slice();
|
||||||
next[i] = { ...next[i], title: e.target.value };
|
n[i] = { ...n[i], excerpt: e.target.value };
|
||||||
setBlogPosts(next);
|
setBlogPosts(n);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
<textarea
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
className="w-full bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
placeholder="Slug"
|
rows={6}
|
||||||
value={p.slug || ""}
|
placeholder="Body HTML"
|
||||||
|
value={p.body_html || ""}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = blogPosts.slice();
|
const n = blogPosts.slice();
|
||||||
next[i] = { ...next[i], slug: e.target.value };
|
n[i] = { ...n[i], body_html: e.target.value };
|
||||||
setBlogPosts(next);
|
setBlogPosts(n);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => deletePost(i)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => savePost(i)}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-2 gap-2">
|
))}
|
||||||
<input
|
</CardContent>
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
</Card>
|
||||||
placeholder="Author"
|
</TabsContent>
|
||||||
value={p.author || ""}
|
|
||||||
onChange={(e) => {
|
<TabsContent value="community" className="space-y-6">
|
||||||
const n = blogPosts.slice();
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
n[i] = { ...n[i], author: e.target.value };
|
<CardHeader>
|
||||||
setBlogPosts(n);
|
<div className="flex items-center gap-2">
|
||||||
}}
|
<Users className="h-5 w-5 text-cyan-300" />
|
||||||
/>
|
<CardTitle>Member directory</CardTitle>
|
||||||
<input
|
</div>
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
<CardDescription>
|
||||||
placeholder="Date"
|
Showing {displayProfiles.length} of {totalMembers} profiles.
|
||||||
value={p.date || ""}
|
</CardDescription>
|
||||||
onChange={(e) => {
|
</CardHeader>
|
||||||
const n = blogPosts.slice();
|
<CardContent>
|
||||||
n[i] = { ...n[i], date: e.target.value };
|
{displayProfiles.length === 0 ? (
|
||||||
setBlogPosts(n);
|
<p className="text-sm text-muted-foreground">
|
||||||
}}
|
No profiles were returned from the identity service. Trigger a refresh or invite teammates to join AeThex.
|
||||||
/>
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<th className="pb-2 pr-4 font-medium">Name</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Email</th>
|
||||||
|
<th className="pb-2 pr-4 font-medium">Role</th>
|
||||||
|
<th className="pb-2 font-medium">Loyalty</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/30">
|
||||||
|
{displayProfiles.map((profile) => (
|
||||||
|
<tr key={profile.id} className="text-foreground/90">
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
{profile.full_name || profile.username || "Unknown"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground">
|
||||||
|
{profile.email || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<Badge variant="outline">{profile.role || "member"}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-muted-foreground">
|
||||||
|
{profile.loyalty_points ?? 0}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid md:grid-cols-3 gap-2">
|
)}
|
||||||
<input
|
</CardContent>
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
</Card>
|
||||||
placeholder="Read time (e.g., 8 min read)"
|
|
||||||
value={p.read_time || ""}
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
onChange={(e) => {
|
<CardHeader>
|
||||||
const n = blogPosts.slice();
|
<div className="flex items-center gap-2">
|
||||||
n[i] = { ...n[i], read_time: e.target.value };
|
<UserCog className="h-5 w-5 text-teal-300" />
|
||||||
setBlogPosts(n);
|
<CardTitle>Community actions</CardTitle>
|
||||||
}}
|
</div>
|
||||||
/>
|
<CardDescription>Grow the network and celebrate contributors.</CardDescription>
|
||||||
<input
|
</CardHeader>
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
placeholder="Category"
|
<Button size="sm" onClick={() => navigate("/community")}>Open community hub</Button>
|
||||||
value={p.category || ""}
|
<Button size="sm" variant="outline" onClick={() => navigate("/mentorship")}>
|
||||||
onChange={(e) => {
|
Manage mentorships
|
||||||
const n = blogPosts.slice();
|
</Button>
|
||||||
n[i] = { ...n[i], category: e.target.value };
|
<Button size="sm" variant="outline" onClick={() => navigate("/support")}>Support queue</Button>
|
||||||
setBlogPosts(n);
|
</CardContent>
|
||||||
}}
|
</Card>
|
||||||
/>
|
</TabsContent>
|
||||||
<input
|
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
<TabsContent value="operations" className="space-y-6">
|
||||||
placeholder="Image URL"
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
value={p.image || ""}
|
<Card className="bg-card/60 border-border/40 backdrop-blur lg:col-span-2">
|
||||||
onChange={(e) => {
|
<CardHeader>
|
||||||
const n = blogPosts.slice();
|
<div className="flex items-center gap-2">
|
||||||
n[i] = { ...n[i], image: e.target.value };
|
<Settings className="h-5 w-5 text-yellow-300" />
|
||||||
setBlogPosts(n);
|
<CardTitle>Featured studios</CardTitle>
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<CardDescription>Control studios highlighted across AeThex experiences.</CardDescription>
|
||||||
className="w-full bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
</CardHeader>
|
||||||
rows={2}
|
<CardContent className="space-y-3">
|
||||||
placeholder="Excerpt"
|
{studios.map((s, i) => (
|
||||||
value={p.excerpt || ""}
|
<div
|
||||||
onChange={(e) => {
|
key={`${s.name}-${i}`}
|
||||||
const n = blogPosts.slice();
|
className="space-y-2 rounded border border-border/40 bg-background/40 p-3"
|
||||||
n[i] = { ...n[i], excerpt: e.target.value };
|
>
|
||||||
setBlogPosts(n);
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
}}
|
<input
|
||||||
/>
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
<textarea
|
value={s.name}
|
||||||
className="w-full bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
onChange={(e) => {
|
||||||
rows={6}
|
const next = studios.slice();
|
||||||
placeholder="Body HTML"
|
next[i] = { ...next[i], name: e.target.value };
|
||||||
value={p.body_html || ""}
|
setStudios(next);
|
||||||
onChange={(e) => {
|
}}
|
||||||
const n = blogPosts.slice();
|
placeholder="Studio name"
|
||||||
n[i] = { ...n[i], body_html: e.target.value };
|
/>
|
||||||
setBlogPosts(n);
|
<input
|
||||||
}}
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
/>
|
value={s.tagline || ""}
|
||||||
<div className="flex justify-end gap-2">
|
onChange={(e) => {
|
||||||
|
const next = studios.slice();
|
||||||
|
next[i] = { ...next[i], tagline: e.target.value };
|
||||||
|
setStudios(next);
|
||||||
|
}}
|
||||||
|
placeholder="Tagline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
value={s.metrics || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = studios.slice();
|
||||||
|
next[i] = { ...next[i], metrics: e.target.value };
|
||||||
|
setStudios(next);
|
||||||
|
}}
|
||||||
|
placeholder="Metrics (e.g., 1B+ sessions)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
||||||
|
value={(s.specialties || []).join(", ")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = studios.slice();
|
||||||
|
next[i] = {
|
||||||
|
...next[i],
|
||||||
|
specialties: e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
};
|
||||||
|
setStudios(next);
|
||||||
|
}}
|
||||||
|
placeholder="Specialties (comma separated)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setStudios(studios.filter((_, idx) => idx !== i))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex flex-wrap justify-between gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => deletePost(i)}
|
onClick={() => setStudios([...studios, { name: "New Studio" }])}
|
||||||
>
|
>
|
||||||
Delete
|
Add studio
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={() => savePost(i)}>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border/50">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Settings className="h-5 w-5 text-yellow-400" />
|
|
||||||
<CardTitle className="text-lg">Featured Studios</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
Manage studios shown on Game Development page
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{studios.map((s, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="p-3 rounded border border-border/40 space-y-2"
|
|
||||||
>
|
|
||||||
<div className="grid md:grid-cols-2 gap-2">
|
|
||||||
<input
|
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
|
||||||
value={s.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = studios.slice();
|
|
||||||
next[i] = { ...next[i], name: e.target.value };
|
|
||||||
setStudios(next);
|
|
||||||
}}
|
|
||||||
placeholder="Studio name"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
|
||||||
value={s.tagline || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = studios.slice();
|
|
||||||
next[i] = { ...next[i], tagline: e.target.value };
|
|
||||||
setStudios(next);
|
|
||||||
}}
|
|
||||||
placeholder="Tagline"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid md:grid-cols-2 gap-2">
|
|
||||||
<input
|
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
|
||||||
value={s.metrics || ""}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = studios.slice();
|
|
||||||
next[i] = { ...next[i], metrics: e.target.value };
|
|
||||||
setStudios(next);
|
|
||||||
}}
|
|
||||||
placeholder="Metrics (e.g., 1B+ sessions)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="bg-background/50 border border-border/40 rounded px-2 py-1 text-sm"
|
|
||||||
value={(s.specialties || []).join(", ")}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = studios.slice();
|
|
||||||
next[i] = {
|
|
||||||
...next[i],
|
|
||||||
specialties: e.target.value
|
|
||||||
.split(",")
|
|
||||||
.map((v) => v.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
};
|
|
||||||
setStudios(next);
|
|
||||||
}}
|
|
||||||
placeholder="Specialties (comma separated)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
onClick={async () => {
|
||||||
onClick={() => {
|
const resp = await fetch("/api/featured-studios", {
|
||||||
const next = studios.filter((_, idx) => idx !== i);
|
method: "POST",
|
||||||
setStudios(next);
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ studios }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
aethexToast.error({
|
||||||
|
title: "Save failed",
|
||||||
|
description: "Unable to persist featured studios.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
aethexToast.success({
|
||||||
|
title: "Studios saved",
|
||||||
|
description: "Featured studios updated successfully.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Remove
|
Save studios
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
<div className="flex justify-between">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setStudios([...studios, { name: "New Studio" }])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add Studio
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
const resp = await fetch("/api/featured-studios", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ studios }),
|
|
||||||
});
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert("Failed to save studios");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border/50">
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Activity className="h-5 w-5 text-orange-400" />
|
<Activity className="h-5 w-5 text-orange-300" />
|
||||||
<CardTitle className="text-lg">System Status</CardTitle>
|
<CardTitle>System status</CardTitle>
|
||||||
</div>
|
|
||||||
<CardDescription>Auth, database, and services</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<ul className="text-sm text-muted-foreground space-y-1">
|
|
||||||
<li>Auth: Operational</li>
|
|
||||||
<li>Database: Operational (mock fallback available)</li>
|
|
||||||
<li>Realtime: Operational</li>
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border/50">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Rocket className="h-5 w-5 text-purple-400" />
|
|
||||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>Common admin operations</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
|
||||||
<Button size="sm" onClick={() => navigate("/dashboard")}>
|
|
||||||
View Dashboard
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate("/onboarding")}
|
|
||||||
>
|
|
||||||
Run Onboarding
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border/50">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<UserCog className="h-5 w-5 text-teal-400" />
|
|
||||||
<CardTitle className="text-lg">Your Account</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>Signed in as {user.email}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
You have full administrative access.
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="bg-card/50 border-border/50">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Users className="h-5 w-5 text-cyan-400" />
|
|
||||||
<CardTitle className="text-lg">Demo Accounts</CardTitle>
|
|
||||||
</div>
|
|
||||||
<CardDescription>
|
|
||||||
Managed by{" "}
|
|
||||||
<span className="text-foreground">mrpiglr@gmail.com</span>
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
{managedProfiles.length === 0 && (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
No developer profiles found yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{managedProfiles.map((p) => (
|
|
||||||
<div
|
|
||||||
key={p.id}
|
|
||||||
className="flex items-center justify-between p-2 rounded border border-border/40"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">
|
|
||||||
{p.full_name || p.username}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{p.email}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">Managed</Badge>
|
<CardDescription>Auth, database, and realtime services.</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
))}
|
<CardContent className="text-sm text-muted-foreground space-y-1">
|
||||||
</CardContent>
|
<p>Auth: Operational</p>
|
||||||
</Card>
|
<p>Database: Operational (mock fallback available)</p>
|
||||||
</div>
|
<p>Realtime: Operational</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-card/60 border-border/40 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserCog className="h-5 w-5 text-teal-300" />
|
||||||
|
<CardTitle>Your account</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Owner privileges are active.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm text-muted-foreground space-y-2">
|
||||||
|
<p>Signed in as {user.email}.</p>
|
||||||
|
<p>You have full administrative access across AeThex services.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue