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:
@@ -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",
|
||||||
|
|||||||
@@ -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,17 +33,24 @@ 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"
|
||||||
>
|
>
|
||||||
<svg
|
{#if currentOrg}
|
||||||
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
<OrgAvatar org={currentOrg} size="md" />
|
||||||
viewBox="0 0 24 24"
|
{:else}
|
||||||
fill="none"
|
<!-- Default icon when no org is selected -->
|
||||||
stroke="currentColor"
|
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-b from-[#303035] to-[#26262c] shadow-sm">
|
||||||
stroke-width="2.5"
|
<svg
|
||||||
>
|
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
|
||||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
viewBox="0 0 24 24"
|
||||||
</svg>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
>
|
||||||
|
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
14
bun.lock
14
bun.lock
@@ -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"],
|
||||||
|
|||||||
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