Add OrgAvatar component and frontend-utils package
Some checks failed
CI / ci (push) Has been cancelled
Some checks failed
CI / ci (push) Has been cancelled
- Create @reviq/frontend-utils package for frontend-specific utilities - Add OrgAvatar component with size variants (xs, sm, md, lg, xl) - Display org initials with deterministic colors when no logo available - Add getOrgInitials and getOrgColor utility functions - Update org-switcher and all org display pages to use OrgAvatar - Add noNonNullAssertion lint rule as error in biome.jsonc Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
15
packages/frontend-utils/eslint.config.js
Normal file
15
packages/frontend-utils/eslint.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { configs } from "@macalinao/eslint-config";
|
||||
|
||||
export default [
|
||||
...configs.fast,
|
||||
{
|
||||
ignores: ["**/*.test.ts"],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
26
packages/frontend-utils/package.json
Normal file
26
packages/frontend-utils/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@reviq/frontend-utils",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "tsc --build --clean && rm -rf dist/ node_modules/ .eslintcache",
|
||||
"lint": "eslint . --cache",
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@macalinao/eslint-config": "catalog:",
|
||||
"@macalinao/tsconfig": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
1
packages/frontend-utils/src/index.ts
Normal file
1
packages/frontend-utils/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { getOrgColor, getOrgInitials, type OrgLike } from "./org.js";
|
||||
68
packages/frontend-utils/src/org.test.ts
Normal file
68
packages/frontend-utils/src/org.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { getOrgColor, getOrgInitials } from "./org.js";
|
||||
|
||||
describe("getOrgInitials", () => {
|
||||
it("returns first letters of first two words for multi-word names", () => {
|
||||
expect(getOrgInitials({ displayName: "Acme Corporation" })).toBe("AC");
|
||||
expect(getOrgInitials({ displayName: "Big Tech Inc" })).toBe("BT");
|
||||
expect(getOrgInitials({ displayName: "The New York Times" })).toBe("TN");
|
||||
});
|
||||
|
||||
it("returns first two characters for single word names", () => {
|
||||
expect(getOrgInitials({ displayName: "Acme" })).toBe("AC");
|
||||
expect(getOrgInitials({ displayName: "Google" })).toBe("GO");
|
||||
});
|
||||
|
||||
it("handles short names", () => {
|
||||
expect(getOrgInitials({ displayName: "A" })).toBe("A");
|
||||
expect(getOrgInitials({ displayName: "AB" })).toBe("AB");
|
||||
});
|
||||
|
||||
it("handles null/undefined", () => {
|
||||
expect(getOrgInitials(null)).toBe("?");
|
||||
expect(getOrgInitials(undefined)).toBe("?");
|
||||
});
|
||||
|
||||
it("handles empty display name", () => {
|
||||
expect(getOrgInitials({ displayName: "" })).toBe("?");
|
||||
expect(getOrgInitials({ displayName: " " })).toBe("?");
|
||||
});
|
||||
|
||||
it("uppercases the result", () => {
|
||||
expect(getOrgInitials({ displayName: "acme corp" })).toBe("AC");
|
||||
expect(getOrgInitials({ displayName: "acme" })).toBe("AC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrgColor", () => {
|
||||
it("returns a color string", () => {
|
||||
const color = getOrgColor({ displayName: "Acme Corp" });
|
||||
expect(color).toMatch(/^from-\w+-\d+ to-\w+-\d+$/);
|
||||
});
|
||||
|
||||
it("returns the same color for the same org name", () => {
|
||||
const color1 = getOrgColor({ displayName: "Acme Corp" });
|
||||
const color2 = getOrgColor({ displayName: "Acme Corp" });
|
||||
expect(color1).toBe(color2);
|
||||
});
|
||||
|
||||
it("returns consistent color regardless of leading/trailing whitespace", () => {
|
||||
const color1 = getOrgColor({ displayName: "Acme Corp" });
|
||||
const color2 = getOrgColor({ displayName: " Acme Corp " });
|
||||
expect(color1).toBe(color2);
|
||||
});
|
||||
|
||||
it("handles null/undefined", () => {
|
||||
expect(getOrgColor(null)).toMatch(/^from-\w+-\d+ to-\w+-\d+$/);
|
||||
expect(getOrgColor(undefined)).toMatch(/^from-\w+-\d+ to-\w+-\d+$/);
|
||||
});
|
||||
|
||||
it("handles empty/whitespace display name", () => {
|
||||
expect(getOrgColor({ displayName: "" })).toMatch(
|
||||
/^from-\w+-\d+ to-\w+-\d+$/,
|
||||
);
|
||||
expect(getOrgColor({ displayName: " " })).toMatch(
|
||||
/^from-\w+-\d+ to-\w+-\d+$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
86
packages/frontend-utils/src/org.ts
Normal file
86
packages/frontend-utils/src/org.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Organization-related utility functions for frontend display
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimal org shape needed for avatar display
|
||||
*/
|
||||
export interface OrgLike {
|
||||
displayName: string;
|
||||
logoUrl?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate initials from an organization's display name.
|
||||
* - For names with 2+ words: first letter of first two words (e.g., "Acme Corp" -> "AC")
|
||||
* - For single word names: first 2 characters (e.g., "Acme" -> "AC")
|
||||
*
|
||||
* @example
|
||||
* getOrgInitials({ displayName: "Acme Corporation" }) // "AC"
|
||||
* getOrgInitials({ displayName: "Acme" }) // "AC"
|
||||
* getOrgInitials({ displayName: "A" }) // "A"
|
||||
*/
|
||||
export function getOrgInitials(org: OrgLike | null | undefined): string {
|
||||
if (!org?.displayName) {
|
||||
return "?";
|
||||
}
|
||||
|
||||
const name = org.displayName.trim();
|
||||
if (!name) {
|
||||
return "?";
|
||||
}
|
||||
|
||||
const words = name.split(/\s+/).filter(Boolean);
|
||||
const [first, second] = words;
|
||||
|
||||
if (first && second) {
|
||||
return (first.charAt(0) + second.charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Color palette for org avatars. These are Tailwind gradient classes.
|
||||
*/
|
||||
const ORG_COLORS = [
|
||||
"from-blue-500 to-blue-600",
|
||||
"from-emerald-500 to-emerald-600",
|
||||
"from-violet-500 to-violet-600",
|
||||
"from-amber-500 to-amber-600",
|
||||
"from-rose-500 to-rose-600",
|
||||
"from-cyan-500 to-cyan-600",
|
||||
"from-fuchsia-500 to-fuchsia-600",
|
||||
"from-lime-500 to-lime-600",
|
||||
] as const;
|
||||
|
||||
const DEFAULT_COLOR = ORG_COLORS[0];
|
||||
|
||||
/**
|
||||
* Get a deterministic color class for an organization based on its name.
|
||||
* The same org name will always return the same color.
|
||||
* Uses trimmed name for consistency with getOrgInitials.
|
||||
*
|
||||
* @example
|
||||
* getOrgColor({ displayName: "Acme Corp" }) // "from-blue-500 to-blue-600"
|
||||
*/
|
||||
export function getOrgColor(org: OrgLike | null | undefined): string {
|
||||
if (!org?.displayName) {
|
||||
return DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
const name = org.displayName.trim();
|
||||
if (!name) {
|
||||
return DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
// Simple hash based on character codes
|
||||
let hash = 0;
|
||||
for (const char of name) {
|
||||
hash = (hash << 5) - hash + char.charCodeAt(0);
|
||||
hash &= hash; // Convert to 32-bit integer
|
||||
}
|
||||
|
||||
const index = Math.abs(hash) % ORG_COLORS.length;
|
||||
return ORG_COLORS[index] ?? DEFAULT_COLOR;
|
||||
}
|
||||
6
packages/frontend-utils/tsconfig.json
Normal file
6
packages/frontend-utils/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@macalinao/tsconfig/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["bun"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user