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

@@ -16,6 +16,7 @@
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*", "@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { Check } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query"; import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte"; import { getContext } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js"; import { cn } from "$lib/utils.js";
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
})); }));
const orgs = $derived(orgsQuery.data ?? []); const orgs = $derived(orgsQuery.data ?? []);
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
function handleOrgSelect(slug: string) { function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}`)); goto(resolve("/dashboard/[slug]", { slug }));
} }
</script> </script>
@@ -30,8 +33,13 @@ function handleOrgSelect(slug: string) {
<button <button
{...props} {...props}
aria-label="Switch organization" aria-label="Switch organization"
class="group flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm transition-transform duration-200 hover:scale-105" class="group flex h-8 w-8 items-center justify-center transition-transform duration-200 hover:scale-105"
> >
{#if currentOrg}
<OrgAvatar org={currentOrg} size="md" />
{:else}
<!-- Default icon when no org is selected -->
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
<svg <svg
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110" class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -41,6 +49,8 @@ function handleOrgSelect(slug: string) {
> >
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" /> <path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div>
{/if}
</button> </button>
{/snippet} {/snippet}
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
class={cn(isActive && "bg-accent")} class={cn(isActive && "bg-accent")}
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if org.logoUrl} <OrgAvatar {org} size="xs" />
<img src={org.logoUrl} alt="" class="h-5 w-5 rounded" />
{:else}
<div class="flex h-5 w-5 items-center justify-center rounded bg-muted text-[10px] font-medium">
{org.displayName.charAt(0).toUpperCase()}
</div>
{/if}
<span class="flex-1 truncate">{org.displayName}</span> <span class="flex-1 truncate">{org.displayName}</span>
{#if isActive} {#if isActive}
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <Check class="h-4 w-4 text-primary" />
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if} {/if}
</div> </div>
</DropdownMenu.Item> </DropdownMenu.Item>

View File

@@ -1,2 +1,3 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte"; export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as OrgAvatar } from "./org-avatar.svelte";
export { default as RoleBadge } from "./role-badge.svelte"; export { default as RoleBadge } from "./role-badge.svelte";

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import {
getOrgColor,
getOrgInitials,
type OrgLike,
} from "@reviq/frontend-utils";
import { cn } from "$lib/utils.js";
interface Props {
org: OrgLike | null | undefined;
size?: "xs" | "sm" | "md" | "lg" | "xl";
class?: string;
}
let { org, size = "md", class: className }: Props = $props();
const initials = $derived(getOrgInitials(org));
const colorClass = $derived(getOrgColor(org));
const sizeClasses = {
xs: "h-5 w-5 text-[10px] rounded",
sm: "h-6 w-6 text-[10px] rounded",
md: "h-8 w-8 text-xs rounded-lg",
lg: "h-10 w-10 text-sm rounded-lg",
xl: "h-16 w-16 text-xl rounded-xl",
} as const;
</script>
{#if org?.logoUrl}
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class={cn(sizeClasses[size], "shrink-0 object-cover", className)}
/>
{:else}
<div
class={cn(
"flex shrink-0 items-center justify-center bg-gradient-to-br font-semibold text-white",
sizeClasses[size],
colorClass,
className,
)}
>
{initials}
</div>
{/if}

View File

@@ -2,7 +2,6 @@
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
Building2,
Calendar, Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
@@ -21,6 +20,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -121,17 +121,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
{#if invite.org.logoUrl} <OrgAvatar org={invite.org} size="xl" />
<img
src={invite.org.logoUrl}
alt="{invite.org.displayName} logo"
class="h-16 w-16 rounded-xl object-cover"
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-8 w-8 text-primary" />
</div>
{/if}
<div class="flex-1"> <div class="flex-1">
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle> <CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
<CardDescription class="mt-1"> <CardDescription class="mt-1">

View File

@@ -3,7 +3,6 @@ import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
Building,
Globe, Globe,
Loader2, Loader2,
Plus, Plus,
@@ -17,7 +16,7 @@ import { resolve } from "$app/paths";
import { page } from "$app/state"; import { page } from "$app/state";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { AdminLayout } from "$lib/components/layout"; import { AdminLayout } from "$lib/components/layout";
import { ConfirmDialog } from "$lib/components/org"; import { ConfirmDialog, OrgAvatar } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert"; import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
@@ -259,19 +258,7 @@ async function executeConfirmAction() {
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
{#if org.logoUrl} <OrgAvatar {org} size="xl" />
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class="h-16 w-16 rounded-lg object-cover"
/>
{:else}
<div
class="flex h-16 w-16 items-center justify-center rounded-lg bg-muted"
>
<Building class="h-8 w-8 text-muted-foreground" />
</div>
{/if}
<div class="flex-1"> <div class="flex-1">
<CardTitle class="text-2xl">{org.displayName}</CardTitle> <CardTitle class="text-2xl">{org.displayName}</CardTitle>
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">

View File

@@ -11,6 +11,7 @@ import { createQuery } from "@tanstack/svelte-query";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { OrgAvatar } from "$lib/components/org";
import { Badge } from "$lib/components/ui/badge"; import { Badge } from "$lib/components/ui/badge";
import { import {
Card, Card,
@@ -70,17 +71,7 @@ $effect(() => {
<CardHeader class="pb-2"> <CardHeader class="pb-2">
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{#if invite.org.logoUrl} <OrgAvatar org={invite.org} size="lg" />
<img
src={invite.org.logoUrl}
alt="{invite.org.displayName} logo"
class="h-10 w-10 rounded-lg object-cover"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20">
<Building2 class="h-5 w-5 text-primary" />
</div>
{/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<CardTitle class="truncate text-base"> <CardTitle class="truncate text-base">
{invite.org.displayName} {invite.org.displayName}
@@ -154,18 +145,7 @@ $effect(() => {
<Card class="h-full transition-colors group-hover:border-primary/50"> <Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-3"> <CardHeader class="pb-3">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<!-- Logo or placeholder --> <OrgAvatar {org} size="lg" />
{#if org.logoUrl}
<img
src={org.logoUrl}
alt="{org.displayName} logo"
class="h-10 w-10 rounded-lg object-cover"
/>
{:else}
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-5 w-5 text-primary" />
</div>
{/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<CardTitle class="truncate text-base"> <CardTitle class="truncate text-base">
{org.displayName} {org.displayName}

View File

@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { import {
AlertCircle, AlertCircle,
Building2,
ChevronRight, ChevronRight,
Globe, Globe,
Loader2, Loader2,
@@ -13,7 +12,7 @@ import { getContext } from "svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import { api } from "$lib/api/client"; import { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout"; import { DashboardLayout } from "$lib/components/layout";
import { RoleBadge } from "$lib/components/org"; import { OrgAvatar, RoleBadge } from "$lib/components/org";
import { Button } from "$lib/components/ui/button"; import { Button } from "$lib/components/ui/button";
import { import {
Card, Card,
@@ -98,17 +97,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Header with org info --> <!-- Header with org info -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{#if orgQuery.data?.logoUrl} <OrgAvatar org={orgQuery.data} size="xl" />
<img
src={orgQuery.data.logoUrl}
alt="{orgName} logo"
class="h-16 w-16 rounded-xl object-cover"
/>
{:else}
<div class="flex h-16 w-16 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/10">
<Building2 class="h-8 w-8 text-primary" />
</div>
{/if}
<div> <div>
<h1 class="text-2xl font-semibold">{orgName}</h1> <h1 class="text-2xl font-semibold">{orgName}</h1>
<p class="text-sm text-muted-foreground">{slug}</p> <p class="text-sm text-muted-foreground">{slug}</p>

View File

@@ -40,6 +40,13 @@
"indentStyle": "space", "indentStyle": "space",
"indentWidth": 2 "indentWidth": 2
}, },
"linter": {
"rules": {
"style": {
"noNonNullAssertion": "error"
}
}
},
"overrides": [ "overrides": [
{ {
// Svelte 5 runes require `let` for $props(), template variables/imports appear unused to Biome, // Svelte 5 runes require `let` for $props(), template variables/imports appear unused to Biome,

View File

@@ -78,6 +78,7 @@
"@orpc/contract": "^1.13.2", "@orpc/contract": "^1.13.2",
"@reviq/api-contract": "workspace:*", "@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*", "@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14", "@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3", "@tanstack/svelte-query-devtools": "^6.0.3",
@@ -180,6 +181,17 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
"packages/frontend-utils": {
"name": "@reviq/frontend-utils",
"version": "0.0.1",
"devDependencies": {
"@macalinao/eslint-config": "catalog:",
"@macalinao/tsconfig": "catalog:",
"@types/bun": "catalog:",
"eslint": "catalog:",
"typescript": "catalog:",
},
},
"packages/testing/virtual-authenticator": { "packages/testing/virtual-authenticator": {
"name": "@reviq/virtual-authenticator", "name": "@reviq/virtual-authenticator",
"version": "0.0.1", "version": "0.0.1",
@@ -425,6 +437,8 @@
"@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"], "@reviq/db-schema": ["@reviq/db-schema@workspace:packages/db-schema"],
"@reviq/frontend-utils": ["@reviq/frontend-utils@workspace:packages/frontend-utils"],
"@reviq/utils": ["@reviq/utils@workspace:packages/utils"], "@reviq/utils": ["@reviq/utils@workspace:packages/utils"],
"@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"], "@reviq/virtual-authenticator": ["@reviq/virtual-authenticator@workspace:packages/testing/virtual-authenticator"],

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"]
}
}