Merge remote-tracking branch 'public-gh/master' into paperclip-subissues
* public-gh/master: (51 commits) Use attachment-size limit for company logos Address Greptile company logo feedback Drop lockfile from PR branch Use asset-backed company logos fix: use appType "custom" for Vite dev server so worktree branding is applied docs: fix documentation drift — adapters, plugins, tech stack docs: update documentation for accuracy after plugin system launch chore: ignore superset artifacts Dark theme for CodeMirror code blocks in MDXEditor Remove duplicate @paperclipai/adapter-openclaw-gateway in server/package.json Fix code block styles with robust prose overrides Add Docker setup for untrusted PR review in isolated containers Fix org chart canvas height to fit viewport without scrolling Add doc-maintenance skill for periodic documentation accuracy audits Fix sidebar scrollbar: hide track background when not hovering Restyle markdown code blocks: dark background, smaller font, compact padding Add archive project button and filter archived projects from selectors fix: address review feedback — subscription cleanup, filter nullability, stale diagram fix: wire plugin event subscriptions from worker to host fix(ui): hide scrollbar track background when sidebar is not hovered ... # Conflicts: # packages/db/src/migrations/meta/0030_snapshot.json # packages/db/src/migrations/meta/_journal.json
This commit is contained in:
@@ -11,11 +11,19 @@ export const assetsApi = {
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", safeFile);
|
||||
if (namespace && namespace.trim().length > 0) {
|
||||
form.append("namespace", namespace.trim());
|
||||
}
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/assets/images`, form);
|
||||
},
|
||||
};
|
||||
|
||||
uploadCompanyLogo: async (companyId: string, file: File) => {
|
||||
const buffer = await file.arrayBuffer();
|
||||
const safeFile = new File([buffer], file.name, { type: file.type });
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", safeFile);
|
||||
return api.postForm<AssetImage>(`/companies/${companyId}/logo`, form);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,14 +14,18 @@ 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;
|
||||
}) =>
|
||||
api.post<Company>("/companies", data),
|
||||
update: (
|
||||
companyId: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
Company,
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor"
|
||||
"name" | "description" | "status" | "budgetMonthlyCents" | "requireBoardApprovalForNewAgents" | "brandColor" | "logoAssetId"
|
||||
>
|
||||
>,
|
||||
) => api.patch<Company>(`/companies/${companyId}`, data),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link } from "@/lib/router";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -11,13 +12,46 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { Fragment } from "react";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
|
||||
import { PluginLauncherOutlet, usePluginLaunchers } from "@/plugins/launchers";
|
||||
|
||||
type GlobalToolbarContext = { companyId: string | null; companyPrefix: string | null };
|
||||
|
||||
function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
||||
const { slots } = usePluginSlots({ slotTypes: ["globalToolbarButton"], companyId: context.companyId });
|
||||
const { launchers } = usePluginLaunchers({ placementZones: ["globalToolbarButton"], companyId: context.companyId, enabled: !!context.companyId });
|
||||
if (slots.length === 0 && launchers.length === 0) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 ml-auto shrink-0 pl-2">
|
||||
<PluginSlotOutlet slotTypes={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
||||
<PluginLauncherOutlet placementZones={["globalToolbarButton"]} context={context} className="flex items-center gap-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
|
||||
if (breadcrumbs.length === 0) return null;
|
||||
const globalToolbarSlotContext = useMemo(
|
||||
() => ({
|
||||
companyId: selectedCompanyId ?? null,
|
||||
companyPrefix: selectedCompany?.issuePrefix ?? null,
|
||||
}),
|
||||
[selectedCompanyId, selectedCompany?.issuePrefix],
|
||||
);
|
||||
|
||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const menuButton = isMobile && (
|
||||
<Button
|
||||
@@ -34,40 +68,46 @@ export function BreadcrumbBar() {
|
||||
// Single breadcrumb = page title (uppercase)
|
||||
if (breadcrumbs.length === 1) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
</h1>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple breadcrumbs = breadcrumb trail
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center min-w-0 overflow-hidden">
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
{isLast || !crumb.href ? (
|
||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={crumb.href}>{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem className={isLast ? "min-w-0" : "shrink-0"}>
|
||||
{isLast || !crumb.href ? (
|
||||
<BreadcrumbPage className="truncate">{crumb.label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link to={crumb.href}>{crumb.label}</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,11 +75,15 @@ export function CommandPalette() {
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
|
||||
const { data: projects = [] } = useQuery({
|
||||
const { data: allProjects = [] } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId && open,
|
||||
});
|
||||
const projects = useMemo(
|
||||
() => allProjects.filter((p) => !p.archivedAt),
|
||||
[allProjects],
|
||||
);
|
||||
|
||||
function go(path: string) {
|
||||
setOpen(false);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ function SortableCompanyItem({
|
||||
>
|
||||
<CompanyPatternIcon
|
||||
companyName={company.name}
|
||||
logoUrl={company.logoUrl}
|
||||
brandColor={company.brandColor}
|
||||
className={cn(
|
||||
isSelected
|
||||
|
||||
@@ -163,8 +163,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
||||
queryFn: () => projectsApi.list(companyId!),
|
||||
enabled: !!companyId,
|
||||
});
|
||||
const activeProjects = useMemo(
|
||||
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
|
||||
[projects, issue.projectId],
|
||||
);
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
projects: activeProjects,
|
||||
companyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
@@ -117,7 +117,7 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden prose-pre:whitespace-pre-wrap prose-pre:break-words prose-code:break-all",
|
||||
"paperclip-markdown prose prose-sm max-w-none break-words overflow-hidden",
|
||||
theme === "dark" && "prose-invert",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -346,8 +346,12 @@ export function NewIssueDialog() {
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
const activeProjects = useMemo(
|
||||
() => (projects ?? []).filter((p) => !p.archivedAt),
|
||||
[projects],
|
||||
);
|
||||
const { orderedProjects } = useProjectOrder({
|
||||
projects: projects ?? [],
|
||||
projects: activeProjects,
|
||||
companyId: effectiveCompanyId,
|
||||
userId: currentUserId,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
|
||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { DraftInput } from "./agent-config-primitives";
|
||||
import { InlineEditor } from "./InlineEditor";
|
||||
@@ -32,6 +32,8 @@ interface ProjectPropertiesProps {
|
||||
onUpdate?: (data: Record<string, unknown>) => void;
|
||||
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
|
||||
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
|
||||
onArchive?: (archived: boolean) => void;
|
||||
archivePending?: boolean;
|
||||
}
|
||||
|
||||
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
|
||||
@@ -148,7 +150,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) {
|
||||
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { enabled: showExperimentalWorkspaceUi } = useExperimentalWorkspacesEnabled();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -975,6 +977,45 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{onArchive && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||
Danger Zone
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.archivedAt
|
||||
? "Unarchive this project to restore it in the sidebar and project selectors."
|
||||
: "Archive this project to hide it from the sidebar and project selectors."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={archivePending}
|
||||
onClick={() => {
|
||||
const action = project.archivedAt ? "Unarchive" : "Archive";
|
||||
const confirmed = window.confirm(
|
||||
`${action} project "${project.name}"?`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
onArchive(!project.archivedAt);
|
||||
}}
|
||||
>
|
||||
{archivePending ? (
|
||||
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
|
||||
) : project.archivedAt ? (
|
||||
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
|
||||
) : (
|
||||
<><Archive className="h-3 w-3 mr-1" />Archive project</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,11 @@ 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;
|
||||
}) =>
|
||||
companiesApi.create(data),
|
||||
onSuccess: (company) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
@@ -94,7 +98,11 @@ 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;
|
||||
}) => {
|
||||
return createMutation.mutateAsync(data);
|
||||
},
|
||||
[createMutation],
|
||||
|
||||
125
ui/src/index.css
125
ui/src/index.css
@@ -178,9 +178,16 @@
|
||||
background: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
/* Auto-hide scrollbar: transparent by default, visible on container hover */
|
||||
/* Auto-hide scrollbar: fully invisible by default, visible on container hover */
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.205 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0 0) !important;
|
||||
@@ -405,30 +412,118 @@
|
||||
|
||||
.paperclip-mdxeditor-content code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.84em;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content pre {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border: 1px solid var(--border);
|
||||
margin: 0.4rem 0;
|
||||
padding: 0;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background: color-mix(in oklab, var(--accent) 50%, transparent);
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
|
||||
Matches the editor theme so rendered code looks consistent. */
|
||||
.prose pre {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: calc(var(--radius) - 3px);
|
||||
background-color: color-mix(in oklab, var(--accent) 50%, transparent);
|
||||
color: var(--foreground);
|
||||
/* Dark theme for CodeMirror code blocks inside the MDXEditor.
|
||||
Overrides the default cm6-theme-basic-light that MDXEditor bundles. */
|
||||
.paperclip-mdxeditor .cm-editor {
|
||||
background-color: #1e1e2e !important;
|
||||
color: #cdd6f4 !important;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
.paperclip-mdxeditor .cm-gutters {
|
||||
background-color: #181825 !important;
|
||||
color: #585b70 !important;
|
||||
border-right: 1px solid #313244 !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-activeLineGutter {
|
||||
background-color: #1e1e2e !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-activeLine {
|
||||
background-color: color-mix(in oklab, #cdd6f4 5%, transparent) !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-cursor,
|
||||
.paperclip-mdxeditor .cm-dropCursor {
|
||||
border-left-color: #cdd6f4 !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-selectionBackground {
|
||||
background-color: color-mix(in oklab, #89b4fa 25%, transparent) !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-focused .cm-selectionBackground {
|
||||
background-color: color-mix(in oklab, #89b4fa 30%, transparent) !important;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor .cm-content {
|
||||
caret-color: #cdd6f4;
|
||||
}
|
||||
|
||||
/* MDXEditor code block language selector – show on hover only */
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.25rem;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorToolbar_"] select,
|
||||
.paperclip-mdxeditor-content [class*="_codeBlockToolbar_"] select {
|
||||
background-color: #313244;
|
||||
color: #cdd6f4;
|
||||
border-color: #45475a;
|
||||
}
|
||||
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeMirrorToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:hover [class*="_codeBlockToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeMirrorToolbar_"],
|
||||
.paperclip-mdxeditor-content [class*="_codeMirrorWrapper_"]:focus-within [class*="_codeBlockToolbar_"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Rendered markdown code blocks & inline code (prose/MarkdownBody context).
|
||||
Dark theme code blocks with compact sizing.
|
||||
Override prose CSS variables so prose-invert can't revert to defaults. */
|
||||
.paperclip-markdown {
|
||||
--tw-prose-pre-bg: #1e1e2e;
|
||||
--tw-prose-pre-code: #cdd6f4;
|
||||
--tw-prose-invert-pre-bg: #1e1e2e;
|
||||
--tw-prose-invert-pre-code: #cdd6f4;
|
||||
}
|
||||
|
||||
.paperclip-markdown pre {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent) !important;
|
||||
border-radius: calc(var(--radius) - 3px) !important;
|
||||
background-color: #1e1e2e !important;
|
||||
color: #cdd6f4 !important;
|
||||
padding: 0.5rem 0.65rem !important;
|
||||
margin: 0.4rem 0 !important;
|
||||
font-size: 0.78em !important;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.paperclip-markdown code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.84em;
|
||||
font-size: 0.78em;
|
||||
}
|
||||
|
||||
.paperclip-markdown pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Remove backtick pseudo-elements from inline code (prose default adds them) */
|
||||
|
||||
@@ -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);
|
||||
@@ -128,6 +132,42 @@ export function CompanySettings() {
|
||||
}
|
||||
});
|
||||
|
||||
const syncLogoState = (nextLogoUrl: string | null) => {
|
||||
setLogoUrl(nextLogoUrl ?? "");
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
};
|
||||
|
||||
const logoUploadMutation = useMutation({
|
||||
mutationFn: (file: File) =>
|
||||
assetsApi
|
||||
.uploadCompanyLogo(selectedCompanyId!, file)
|
||||
.then((asset) => companiesApi.update(selectedCompanyId!, { logoAssetId: asset.assetId })),
|
||||
onSuccess: (company) => {
|
||||
syncLogoState(company.logoUrl);
|
||||
setLogoUploadError(null);
|
||||
}
|
||||
});
|
||||
|
||||
const clearLogoMutation = useMutation({
|
||||
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoAssetId: 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;
|
||||
setLogoUploadError(null);
|
||||
logoUploadMutation.mutate(file);
|
||||
}
|
||||
|
||||
function handleClearLogo() {
|
||||
clearLogoMutation.mutate();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInviteError(null);
|
||||
setInviteSnippet(null);
|
||||
@@ -224,11 +264,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 PNG, JPEG, WEBP, GIF, or SVG logo image."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,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."
|
||||
@@ -285,8 +367,8 @@ export function CompanySettings() {
|
||||
{generalMutation.isError && (
|
||||
<span className="text-xs text-destructive">
|
||||
{generalMutation.error instanceof Error
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
? generalMutation.error.message
|
||||
: "Failed to save"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -269,7 +269,7 @@ export function OrgChart() {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-[calc(100vh-4rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
className="w-full h-[calc(100dvh-6rem)] overflow-hidden relative bg-muted/20 border border-border rounded-lg"
|
||||
style={{ cursor: dragging ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
|
||||
@@ -274,6 +274,21 @@ export function ProjectDetail() {
|
||||
onSuccess: invalidateProject,
|
||||
});
|
||||
|
||||
const archiveProject = useMutation({
|
||||
mutationFn: (archived: boolean) =>
|
||||
projectsApi.update(
|
||||
projectLookupRef,
|
||||
{ archivedAt: archived ? new Date().toISOString() : null },
|
||||
resolvedCompanyId ?? lookupCompanyId,
|
||||
),
|
||||
onSuccess: (_, archived) => {
|
||||
invalidateProject();
|
||||
if (archived) {
|
||||
navigate("/projects");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const uploadImage = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
if (!resolvedCompanyId) throw new Error("No company selected");
|
||||
@@ -476,6 +491,8 @@ export function ProjectDetail() {
|
||||
onUpdate={(data) => updateProject.mutate(data)}
|
||||
onFieldUpdate={updateProjectField}
|
||||
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
|
||||
onArchive={(archived) => archiveProject.mutate(archived)}
|
||||
archivePending={archiveProject.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -22,11 +22,15 @@ export function Projects() {
|
||||
setBreadcrumbs([{ label: "Projects" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: projects, isLoading, error } = useQuery({
|
||||
const { data: allProjects, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.projects.list(selectedCompanyId!),
|
||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const projects = useMemo(
|
||||
() => (allProjects ?? []).filter((p) => !p.archivedAt),
|
||||
[allProjects],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||
@@ -47,7 +51,7 @@ export function Projects() {
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
{!isLoading && projects.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Hexagon}
|
||||
message="No projects yet."
|
||||
@@ -56,7 +60,7 @@ export function Projects() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
{projects.length > 0 && (
|
||||
<div className="border border-border">
|
||||
{projects.map((project) => (
|
||||
<EntityRow
|
||||
|
||||
@@ -110,6 +110,7 @@ const entityScopedZones = new Set<PluginLauncherPlacementZone>([
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
]);
|
||||
const focusableElementSelector = [
|
||||
"button:not([disabled])",
|
||||
@@ -195,6 +196,9 @@ function launcherTriggerClassName(placementZone: PluginLauncherPlacementZone): s
|
||||
case "sidebar":
|
||||
case "sidebarPanel":
|
||||
return "justify-start h-8 w-full";
|
||||
case "toolbarButton":
|
||||
case "globalToolbarButton":
|
||||
return "h-8";
|
||||
default:
|
||||
return "h-8";
|
||||
}
|
||||
@@ -732,7 +736,7 @@ function DefaultLauncherTrigger({
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={placementZone === "toolbarButton" ? "outline" : "ghost"}
|
||||
variant={placementZone === "toolbarButton" || placementZone === "globalToolbarButton" ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className={launcherTriggerClassName(placementZone)}
|
||||
onClick={onClick}
|
||||
|
||||
@@ -102,7 +102,7 @@ function buildRegistryKey(pluginKey: string, exportName: string): string {
|
||||
}
|
||||
|
||||
function requiresEntityType(slotType: PluginUiSlotType): boolean {
|
||||
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem";
|
||||
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton";
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
|
||||
Reference in New Issue
Block a user