Add support for company logos, including schema adjustments, validation, assets handling, and UI display enhancements.

This commit is contained in:
JonCSykes
2026-03-06 16:39:35 -05:00
parent b155415d7d
commit b19d0b6f3b
17 changed files with 6211 additions and 26 deletions

View File

@@ -14,14 +14,19 @@ export const companiesApi = {
list: () => api.get<Company[]>("/companies"),
get: (companyId: string) => api.get<Company>(`/companies/${companyId}`),
stats: () => api.get<CompanyStats>("/companies/stats"),
create: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
create: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) =>
api.post<Company>("/companies", data),
update: (
companyId: string,
data: Partial<
Pick<
Company,
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor"
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoUrl"
>
>,
) => api.patch<Company>(`/companies/${companyId}`, data),

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { cn } from "../lib/utils";
const BAYER_4X4 = [
@@ -10,6 +10,7 @@ const BAYER_4X4 = [
interface CompanyPatternIconProps {
companyName: string;
logoUrl?: string | null;
brandColor?: string | null;
className?: string;
}
@@ -159,8 +160,18 @@ function makeCompanyPatternDataUrl(seed: string, brandColor?: string | null, log
return canvas.toDataURL("image/png");
}
export function CompanyPatternIcon({ companyName, brandColor, className }: CompanyPatternIconProps) {
export function CompanyPatternIcon({
companyName,
logoUrl,
brandColor,
className,
}: CompanyPatternIconProps) {
const initial = companyName.trim().charAt(0).toUpperCase() || "?";
const [imageError, setImageError] = useState(false);
const logo = !imageError && typeof logoUrl === "string" && logoUrl.trim().length > 0 ? logoUrl : null;
useEffect(() => {
setImageError(false);
}, [logoUrl]);
const patternDataUrl = useMemo(
() => makeCompanyPatternDataUrl(companyName.trim().toLowerCase(), brandColor),
[companyName, brandColor],
@@ -173,7 +184,14 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
className,
)}
>
{patternDataUrl ? (
{logo ? (
<img
src={logo}
alt={`${companyName} logo`}
onError={() => setImageError(true)}
className="absolute inset-0 h-full w-full object-cover"
/>
) : patternDataUrl ? (
<img
src={patternDataUrl}
alt=""
@@ -184,9 +202,11 @@ export function CompanyPatternIcon({ companyName, brandColor, className }: Compa
) : (
<div className="absolute inset-0 bg-muted" />
)}
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
{!logo && (
<span className="relative z-10 drop-shadow-[0_1px_2px_rgba(0,0,0,0.65)]">
{initial}
</span>
)}
</div>
);
}

View File

@@ -121,6 +121,7 @@ function SortableCompanyItem({
>
<CompanyPatternIcon
companyName={company.name}
logoUrl={company.logoUrl}
brandColor={company.brandColor}
className={cn(
isSelected

View File

@@ -29,6 +29,7 @@ interface CompanyContextValue {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) => Promise<Company>;
}
@@ -86,7 +87,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
}, [queryClient]);
const createMutation = useMutation({
mutationFn: (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) =>
mutationFn: (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) =>
companiesApi.create(data),
onSuccess: (company) => {
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -95,7 +101,12 @@ export function CompanyProvider({ children }: { children: ReactNode }) {
});
const createCompany = useCallback(
async (data: { name: string; description?: string | null; budgetMonthlyCents?: number }) => {
async (data: {
name: string;
description?: string | null;
budgetMonthlyCents?: number;
logoUrl?: string | null;
}) => {
return createMutation.mutateAsync(data);
},
[createMutation],

View File

@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check } from "lucide-react";
@@ -34,6 +35,8 @@ export function CompanySettings() {
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
const [brandColor, setBrandColor] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
// Sync local state from selected company
useEffect(() => {
@@ -41,6 +44,7 @@ export function CompanySettings() {
setCompanyName(selectedCompany.name);
setDescription(selectedCompany.description ?? "");
setBrandColor(selectedCompany.brandColor ?? "");
setLogoUrl(selectedCompany.logoUrl ?? "");
}, [selectedCompany]);
const [inviteError, setInviteError] = useState<string | null>(null);
@@ -130,6 +134,46 @@ export function CompanySettings() {
}
});
const syncLogoState = (nextLogoUrl: string | null) => {
setLogoUrl(nextLogoUrl ?? "");
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
};
const logoUploadMutation = useMutation({
mutationFn: (file: File) =>
assetsApi
.uploadImage(selectedCompanyId!, file, "companies")
.then((asset) => companiesApi.update(selectedCompanyId!, { logoUrl: asset.contentPath })),
onSuccess: (company) => {
syncLogoState(company.logoUrl);
setLogoUploadError(null);
}
});
const clearLogoMutation = useMutation({
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoUrl: null }),
onSuccess: (company) => {
setLogoUploadError(null);
syncLogoState(company.logoUrl);
}
});
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
event.currentTarget.value = "";
if (!file) return;
if (file.size >= 100 * 1024) {
setLogoUploadError("Logo image must be smaller than 100 KB.");
return;
}
setLogoUploadError(null);
logoUploadMutation.mutate(file);
}
function handleClearLogo() {
clearLogoMutation.mutate();
}
useEffect(() => {
setInviteError(null);
setInviteSnippet(null);
@@ -226,11 +270,53 @@ export function CompanySettings() {
<div className="shrink-0">
<CompanyPatternIcon
companyName={companyName || selectedCompany.name}
logoUrl={logoUrl || null}
brandColor={brandColor || null}
className="rounded-[14px]"
/>
</div>
<div className="flex-1 space-y-2">
<div className="flex-1 space-y-3">
<Field
label="Logo"
hint="Upload a logo image to replace the generated icon. Maximum size: 100 KB."
>
<div className="space-y-2">
<input
type="file"
accept="image/*,image/svg+xml"
onChange={handleLogoFileChange}
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-2.5 file:py-1 file:text-xs"
/>
{logoUrl && (
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleClearLogo}
disabled={clearLogoMutation.isPending}
>
{clearLogoMutation.isPending ? "Removing..." : "Remove logo"}
</Button>
</div>
)}
{(logoUploadMutation.isError || logoUploadError) && (
<span className="text-xs text-destructive">
{logoUploadError ??
(logoUploadMutation.error instanceof Error
? logoUploadMutation.error.message
: "Logo upload failed")}
</span>
)}
{clearLogoMutation.isError && (
<span className="text-xs text-destructive">
{clearLogoMutation.error.message}
</span>
)}
{logoUploadMutation.isPending && (
<span className="text-xs text-muted-foreground">Uploading logo...</span>
)}
</div>
</Field>
<Field
label="Brand color"
hint="Sets the hue for the company icon. Leave empty for auto-generated color."
@@ -286,9 +372,7 @@ export function CompanySettings() {
)}
{generalMutation.isError && (
<span className="text-xs text-destructive">
{generalMutation.error instanceof Error
? generalMutation.error.message
: "Failed to save"}
{generalMutation.error.message}
</span>
)}
</div>