feat: admin panel improvements (v0.34.0)
- Signups toggle was inverted (on = disabled) — flipped so on means open, matching how every other on/off toggle in the app reads. - Moved AI provider keys and default-model settings from Site Settings to AI Config, so all AI setup lives in one place instead of split across two pages with a cross-link. - Admin overview: added new users/recipes (7d), recipes cooked (7d), pending reports (linked, highlighted if > 0), storage used this month, active webhooks, and API keys issued — previously just 4 lifetime totals. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
|
||||||
|
|
||||||
|
## 0.34.0 — 2026-07-14 18:05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Admin overview now shows growth (new users/recipes this week), engagement (recipes cooked this week), a moderation queue count, storage used, and platform-integration counts (webhooks, API keys) — not just totals.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The admin Signups toggle was inverted — switching it "on" used to disable signups. It now reads naturally: on means open.
|
||||||
|
- AI provider keys and default-model settings have moved from Site Settings to AI Config, alongside the rest of the AI setup.
|
||||||
|
|
||||||
## 0.33.1 — 2026-07-14 17:45
|
## 0.33.1 — 2026-07-14 17:45
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -2,11 +2,25 @@ import type { Metadata } from "next";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CheckCircle, XCircle, Database, Cpu } from "lucide-react";
|
import { CheckCircle, XCircle, Database, Cpu } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { getAllSiteSettings } from "@/lib/site-settings";
|
import { getAllSiteSettings } from "@/lib/site-settings";
|
||||||
|
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
|
||||||
|
import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
|
const SETTING_GROUPS = [
|
||||||
|
{
|
||||||
|
title: "AI Provider Keys",
|
||||||
|
description: "Override the environment variable API keys at runtime. Values are encrypted at rest.",
|
||||||
|
keys: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"] as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI Configuration",
|
||||||
|
description: "Provider routing and model defaults.",
|
||||||
|
keys: ["OPENROUTER_DEFAULT_MODEL", "OLLAMA_BASE_URL"] as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function resolveProvider(settings: Record<string, { value: string | null }>) {
|
function resolveProvider(settings: Record<string, { value: string | null }>) {
|
||||||
if (settings["OPENROUTER_API_KEY"]?.value) return { provider: "OpenRouter", description: "Routes to many models via openrouter.ai" };
|
if (settings["OPENROUTER_API_KEY"]?.value) return { provider: "OpenRouter", description: "Routes to many models via openrouter.ai" };
|
||||||
if (settings["OPENAI_API_KEY"]?.value) return { provider: "OpenAI", description: "Direct OpenAI API (GPT-4 etc.)" };
|
if (settings["OPENAI_API_KEY"]?.value) return { provider: "OpenAI", description: "Direct OpenAI API (GPT-4 etc.)" };
|
||||||
@@ -26,21 +40,17 @@ export default async function AdminAiConfigPage() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">AI Configuration</h1>
|
<h1 className="text-2xl font-bold tracking-tight">AI Configuration</h1>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
Current AI provider settings. Override values in{" "}
|
Provider keys, routing, and default models for AI generation. DB values here take precedence over environment variables — clear a value to fall back to the env var.
|
||||||
<Link href="/admin/settings" className="text-primary underline-offset-4 hover:underline">
|
|
||||||
Site Settings
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Active Provider</CardTitle>
|
<CardTitle className="text-base">Active Provider (fallback, no default set below)</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -48,7 +58,7 @@ export default async function AdminAiConfigPage() {
|
|||||||
<span className="text-muted-foreground text-sm">{active.description}</span>
|
<span className="text-muted-foreground text-sm">{active.description}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-3">
|
<p className="text-xs text-muted-foreground mt-3">
|
||||||
Priority: OpenRouter → OpenAI → Anthropic → Ollama (first configured key wins).
|
Priority: OpenRouter → OpenAI → Anthropic → Ollama (first configured key wins) — only when a user has no personal preference, no BYOK key, and no default is set for that use case below.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -103,6 +113,21 @@ export default async function AdminAiConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{SETTING_GROUPS.map((group) => (
|
||||||
|
<AdminSettingsForm key={group.title} group={group} settings={settings} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
<AdminDefaultModelForm
|
||||||
|
initialValues={{
|
||||||
|
DEFAULT_TEXT_PROVIDER: settings["DEFAULT_TEXT_PROVIDER"]?.value ?? null,
|
||||||
|
DEFAULT_TEXT_MODEL: settings["DEFAULT_TEXT_MODEL"]?.value ?? null,
|
||||||
|
DEFAULT_VISION_PROVIDER: settings["DEFAULT_VISION_PROVIDER"]?.value ?? null,
|
||||||
|
DEFAULT_VISION_MODEL: settings["DEFAULT_VISION_MODEL"]?.value ?? null,
|
||||||
|
DEFAULT_MEAL_PLAN_PROVIDER: settings["DEFAULT_MEAL_PLAN_PROVIDER"]?.value ?? null,
|
||||||
|
DEFAULT_MEAL_PLAN_MODEL: settings["DEFAULT_MEAL_PLAN_MODEL"]?.value ?? null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-17
@@ -1,52 +1,101 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { db } from "@epicure/db";
|
import { db } from "@epicure/db";
|
||||||
import { users, recipes, userUsage, recipePhotos } from "@epicure/db";
|
import { users, recipes, userUsage, recipePhotos, reports, cookingHistory, webhooks, apiKeys } from "@epicure/db";
|
||||||
import { count, eq, sql } from "@epicure/db";
|
import { count, eq, gte, sql } from "@epicure/db";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Users, BookOpen, Sparkles, Image, History } from "lucide-react";
|
import { Users, BookOpen, Sparkles, Image, History, UserPlus, ChefHat, Flag, HardDrive, Webhook, KeyRound } from "lucide-react";
|
||||||
import { APP_VERSION, CHANGELOG } from "@/lib/changelog";
|
import { APP_VERSION, CHANGELOG } from "@/lib/changelog";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
||||||
|
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const [userCount, recipeCount, proUserCount, aiUsage, imageCount] = await Promise.all([
|
const [
|
||||||
|
userCount,
|
||||||
|
recipeCount,
|
||||||
|
proUserCount,
|
||||||
|
publicRecipeCount,
|
||||||
|
aiUsage,
|
||||||
|
imageCount,
|
||||||
|
storageUsage,
|
||||||
|
newUsers7d,
|
||||||
|
newRecipes7d,
|
||||||
|
cooked7d,
|
||||||
|
pendingReports,
|
||||||
|
webhookCount,
|
||||||
|
apiKeyCount,
|
||||||
|
] = await Promise.all([
|
||||||
db.select({ count: count() }).from(users).then((r) => r[0]),
|
db.select({ count: count() }).from(users).then((r) => r[0]),
|
||||||
db.select({ count: count() }).from(recipes).then((r) => r[0]),
|
db.select({ count: count() }).from(recipes).then((r) => r[0]),
|
||||||
db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]),
|
db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(recipes).where(eq(recipes.visibility, "public")).then((r) => r[0]),
|
||||||
db
|
db
|
||||||
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
|
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
|
||||||
.from(userUsage)
|
.from(userUsage)
|
||||||
.where(eq(userUsage.month, currentMonth))
|
.where(eq(userUsage.month, currentMonth))
|
||||||
.then((r) => r[0]),
|
.then((r) => r[0]),
|
||||||
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
|
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
|
||||||
|
db
|
||||||
|
.select({ total: sql<string>`coalesce(sum(${userUsage.storageUsedMb}), 0)` })
|
||||||
|
.from(userUsage)
|
||||||
|
.where(eq(userUsage.month, currentMonth))
|
||||||
|
.then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(users).where(gte(users.createdAt, sevenDaysAgo)).then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(recipes).where(gte(recipes.createdAt, sevenDaysAgo)).then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(cookingHistory).where(gte(cookingHistory.cookedAt, sevenDaysAgo)).then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(reports).where(eq(reports.status, "pending")).then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(webhooks).where(eq(webhooks.active, true)).then((r) => r[0]),
|
||||||
|
db.select({ count: count() }).from(apiKeys).then((r) => r[0]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users },
|
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users },
|
||||||
{ label: "Total Recipes", value: recipeCount?.count ?? 0, icon: BookOpen },
|
{ label: "New Users (7d)", value: newUsers7d?.count ?? 0, icon: UserPlus },
|
||||||
|
{ label: "Total Recipes", value: recipeCount?.count ?? 0, sub: `${publicRecipeCount?.count ?? 0} public`, icon: BookOpen },
|
||||||
|
{ label: "New Recipes (7d)", value: newRecipes7d?.count ?? 0, icon: BookOpen },
|
||||||
|
{ label: "Cooked (7d)", value: cooked7d?.count ?? 0, icon: ChefHat },
|
||||||
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
|
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
|
||||||
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
|
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
|
||||||
|
{ label: "Storage This Month", value: Number(storageUsage?.total ?? 0), unit: "MB", icon: HardDrive },
|
||||||
|
{
|
||||||
|
label: "Pending Reports",
|
||||||
|
value: pendingReports?.count ?? 0,
|
||||||
|
icon: Flag,
|
||||||
|
href: "/admin/reports",
|
||||||
|
highlight: (pendingReports?.count ?? 0) > 0,
|
||||||
|
},
|
||||||
|
{ label: "Active Webhooks", value: webhookCount?.count ?? 0, icon: Webhook },
|
||||||
|
{ label: "API Keys Issued", value: apiKeyCount?.count ?? 0, icon: KeyRound },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Overview</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Overview</h1>
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{stats.map(({ label, value, sub, icon: Icon }) => (
|
{stats.map(({ label, value, sub, unit, icon: Icon, href, highlight }) => {
|
||||||
<Card key={label}>
|
const card = (
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<Card className={highlight ? "border-destructive/50" : undefined}>
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
|
||||||
</CardHeader>
|
<Icon className={highlight ? "h-4 w-4 text-destructive" : "h-4 w-4 text-muted-foreground"} />
|
||||||
<CardContent>
|
</CardHeader>
|
||||||
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
|
<CardContent>
|
||||||
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
<div className={highlight ? "text-2xl font-bold text-destructive" : "text-2xl font-bold"}>
|
||||||
</CardContent>
|
{value.toLocaleString()}{unit ? ` ${unit}` : ""}
|
||||||
</Card>
|
</div>
|
||||||
))}
|
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div key={label}>
|
||||||
|
{href ? <Link href={href}>{card}</Link> : card}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings";
|
import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings";
|
||||||
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
|
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
|
||||||
import { AdminDefaultModelForm } from "@/components/admin/admin-default-model-form";
|
|
||||||
import { SignupsToggle } from "@/components/admin/signups-toggle";
|
import { SignupsToggle } from "@/components/admin/signups-toggle";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
const SETTING_GROUPS = [
|
const SETTING_GROUPS = [
|
||||||
{
|
|
||||||
title: "AI Provider Keys",
|
|
||||||
description: "Override the environment variable API keys at runtime. Values are encrypted at rest.",
|
|
||||||
keys: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"] as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "AI Configuration",
|
|
||||||
description: "Provider routing and model defaults.",
|
|
||||||
keys: ["OPENROUTER_DEFAULT_MODEL", "OLLAMA_BASE_URL"] as const,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Push Notifications (VAPID)",
|
title: "Push Notifications (VAPID)",
|
||||||
description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys",
|
description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys",
|
||||||
@@ -34,7 +23,8 @@ export default async function AdminSettingsPage() {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight">Site Settings</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Site Settings</h1>
|
||||||
<p className="text-muted-foreground text-sm mt-1">
|
<p className="text-muted-foreground text-sm mt-1">
|
||||||
Override .env values at runtime. DB values take precedence over environment variables.
|
Override .env values at runtime. DB values take precedence over environment variables.
|
||||||
Clear a value to fall back to the environment variable.
|
Clear a value to fall back to the environment variable. AI provider keys and model
|
||||||
|
defaults have moved to AI Config.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -43,17 +33,6 @@ export default async function AdminSettingsPage() {
|
|||||||
{SETTING_GROUPS.map((group) => (
|
{SETTING_GROUPS.map((group) => (
|
||||||
<AdminSettingsForm key={group.title} group={group} settings={settings} />
|
<AdminSettingsForm key={group.title} group={group} settings={settings} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AdminDefaultModelForm
|
|
||||||
initialValues={{
|
|
||||||
DEFAULT_TEXT_PROVIDER: settings["DEFAULT_TEXT_PROVIDER"]?.value ?? null,
|
|
||||||
DEFAULT_TEXT_MODEL: settings["DEFAULT_TEXT_MODEL"]?.value ?? null,
|
|
||||||
DEFAULT_VISION_PROVIDER: settings["DEFAULT_VISION_PROVIDER"]?.value ?? null,
|
|
||||||
DEFAULT_VISION_MODEL: settings["DEFAULT_VISION_MODEL"]?.value ?? null,
|
|
||||||
DEFAULT_MEAL_PLAN_PROVIDER: settings["DEFAULT_MEAL_PLAN_PROVIDER"]?.value ?? null,
|
|
||||||
DEFAULT_MEAL_PLAN_MODEL: settings["DEFAULT_MEAL_PLAN_MODEL"]?.value ?? null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
|
||||||
export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean }) {
|
export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean }) {
|
||||||
const [disabled, setDisabled] = useState(initialDisabled);
|
const [enabled, setEnabled] = useState(!initialDisabled);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
async function handleChange(checked: boolean) {
|
async function handleChange(checked: boolean) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
const previous = disabled;
|
const previous = enabled;
|
||||||
setDisabled(checked);
|
setEnabled(checked);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/v1/admin/settings", {
|
const res = await fetch("/api/v1/admin/settings", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ SIGNUPS_DISABLED: checked ? "true" : null }),
|
body: JSON.stringify({ SIGNUPS_DISABLED: checked ? null : "true" }),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Save failed");
|
if (!res.ok) throw new Error("Save failed");
|
||||||
toast.success(checked ? "Signups disabled" : "Signups enabled");
|
toast.success(checked ? "Signups enabled" : "Signups disabled");
|
||||||
} catch {
|
} catch {
|
||||||
setDisabled(previous);
|
setEnabled(previous);
|
||||||
toast.error("Failed to update");
|
toast.error("Failed to update");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -35,16 +35,16 @@ export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean })
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-lg">Signups</h2>
|
<h2 className="font-semibold text-lg">Signups</h2>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
When disabled, only people with a valid invite link can create an account.
|
When off, only people with a valid invite link can create an account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Label htmlFor="signups-disabled" className="text-sm">
|
<Label htmlFor="signups-enabled" className="text-sm">
|
||||||
{disabled ? "Disabled" : "Open"}
|
{enabled ? "Open" : "Disabled"}
|
||||||
</Label>
|
</Label>
|
||||||
<Switch
|
<Switch
|
||||||
id="signups-disabled"
|
id="signups-enabled"
|
||||||
checked={disabled}
|
checked={enabled}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
onCheckedChange={(checked) => { void handleChange(checked); }}
|
onCheckedChange={(checked) => { void handleChange(checked); }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||||
export const APP_VERSION = "0.33.1";
|
export const APP_VERSION = "0.34.0";
|
||||||
|
|
||||||
export type ChangelogEntry = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,17 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: ChangelogEntry[] = [
|
export const CHANGELOG: ChangelogEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.34.0",
|
||||||
|
date: "2026-07-14 18:05",
|
||||||
|
added: [
|
||||||
|
"Admin overview now shows growth (new users/recipes this week), engagement (recipes cooked this week), a moderation queue count, storage used, and platform-integration counts (webhooks, API keys) — not just totals.",
|
||||||
|
],
|
||||||
|
fixed: [
|
||||||
|
"The admin Signups toggle was inverted — switching it \"on\" used to disable signups. It now reads naturally: on means open.",
|
||||||
|
"AI provider keys and default-model settings have moved from Site Settings to AI Config, alongside the rest of the AI setup.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.33.1",
|
version: "0.33.1",
|
||||||
date: "2026-07-14 17:45",
|
date: "2026-07-14 17:45",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.33.1",
|
"version": "0.34.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.33.1",
|
"version": "0.34.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user