Add OrgAvatar component and frontend-utils package
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:
igm
2026-01-12 12:34:23 +08:00
parent 44a480179b
commit 61fdd3329f
16 changed files with 303 additions and 84 deletions

View File

@@ -0,0 +1,15 @@
import { configs } from "@macalinao/eslint-config";
export default [
...configs.fast,
{
ignores: ["**/*.test.ts"],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
},
},
},
];

View 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:"
}
}

View File

@@ -0,0 +1 @@
export { getOrgColor, getOrgInitials, type OrgLike } from "./org.js";

View 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+$/,
);
});
});

View 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;
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@macalinao/tsconfig/tsconfig.base.json",
"compilerOptions": {
"types": ["bun"]
}
}