mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: custom logo not showing in sidebar and silent upload failures
The desktop sidebar never rendered the custom logo because only mobile views used the customLogo state. Added a logo section at the top of the desktop sidebar that displays the custom logo (or the default OtterLogo when none is set). The upload handler used raw fetch() without checking response.ok, so HTTP 4xx errors (e.g. file too large) were silently ignored and the UI falsely reported success. Now checks response status and surfaces the server error message. Closes #125
This commit is contained in:
@@ -47,6 +47,7 @@ export function AppLayout({ children, showToolPanel = true, onFiles }: AppLayout
|
||||
<Sidebar
|
||||
onSettingsClick={() => setSettingsOpen(true)}
|
||||
onHelpClick={() => setHelpOpen(true)}
|
||||
customLogo={customLogo}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
|
||||
import { FolderOpen, Grid3x3, HelpCircle, LayoutGrid, Settings, Workflow } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OtterLogo } from "../common/otter-logo";
|
||||
|
||||
interface SidebarItem {
|
||||
icon: LucideIcon;
|
||||
@@ -28,6 +29,8 @@ interface SidebarProps {
|
||||
onNavClick?: () => void;
|
||||
/** When true, renders in expanded mode (for mobile overlay). */
|
||||
expanded?: boolean;
|
||||
/** Whether a custom logo is set. */
|
||||
customLogo?: boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -35,6 +38,7 @@ export function Sidebar({
|
||||
onHelpClick,
|
||||
onNavClick,
|
||||
expanded = false,
|
||||
customLogo = false,
|
||||
}: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
@@ -98,6 +102,14 @@ export function Sidebar({
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col items-center w-16 bg-sidebar border-r border-border py-3 gap-1 shrink-0">
|
||||
<div className="mb-2 flex items-center justify-center">
|
||||
{customLogo ? (
|
||||
<img src="/api/v1/settings/logo" className="h-8 w-8 rounded object-contain" alt="Logo" />
|
||||
) : (
|
||||
<OtterLogo className="h-7 w-7 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-border w-10 mb-2" />
|
||||
<div className="flex flex-col gap-1 flex-1">
|
||||
{topItems.map((item) => renderItem(item, location.pathname === item.href))}
|
||||
</div>
|
||||
|
||||
@@ -387,11 +387,16 @@ function SystemSection() {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
await fetch("/api/v1/settings/logo", {
|
||||
const res = await fetch("/api/v1/settings/logo", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
setSaveMsg(body?.error || "Failed to upload logo.");
|
||||
return;
|
||||
}
|
||||
setSettings((prev) => ({ ...prev, customLogo: "true" }));
|
||||
} catch {
|
||||
setSaveMsg("Failed to upload logo.");
|
||||
|
||||
@@ -258,6 +258,125 @@ describe("POST /api/v1/settings/logo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Settings state verification (customLogo flag)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe("customLogo setting reflects actual state", () => {
|
||||
it("sets customLogo to true after successful upload", async () => {
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
const uploadRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(200);
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).toBe("true");
|
||||
});
|
||||
|
||||
it("does not set customLogo to true when upload is rejected (oversized)", async () => {
|
||||
// First ensure no logo exists
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const largePng = await sharp({
|
||||
create: { width: 500, height: 500, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "big.png", contentType: "image/png", content: largePng },
|
||||
]);
|
||||
|
||||
const uploadRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
expect(uploadRes.statusCode).toBe(400);
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).not.toBe("true");
|
||||
});
|
||||
|
||||
it("sets customLogo to false after deletion", async () => {
|
||||
// Upload first
|
||||
const png = await makeTestPng();
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "logo.png", contentType: "image/png", content: png },
|
||||
]);
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
// Delete
|
||||
await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
|
||||
const settingsRes = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/settings",
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const { settings } = JSON.parse(settingsRes.body);
|
||||
expect(settings.customLogo).toBe("false");
|
||||
});
|
||||
|
||||
it("returns 400 with clear error when no file is attached", async () => {
|
||||
const { body, contentType } = createMultipartPayload([]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/settings/logo",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
payload: body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toMatch(/no file/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GET /api/v1/settings/logo
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user