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",
"@reviq/api-contract": "workspace:*",
"@reviq/common": "workspace:*",
"@reviq/frontend-utils": "workspace:*",
"@simplewebauthn/browser": "^13.2.2",
"@tanstack/svelte-query": "^6.0.14",
"@tanstack/svelte-query-devtools": "^6.0.3",

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import { Check } from "@lucide/svelte";
import { createQuery } from "@tanstack/svelte-query";
import { getContext } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu";
import { cn } from "$lib/utils.js";
@@ -18,9 +20,10 @@ const orgsQuery = createQuery(() => ({
}));
const orgs = $derived(orgsQuery.data ?? []);
const currentOrg = $derived(orgs.find((org) => org.slug === currentSlug));
function handleOrgSelect(slug: string) {
goto(resolve(`/dashboard/${slug}`));
goto(resolve("/dashboard/[slug]", { slug }));
}
</script>
@@ -30,17 +33,24 @@ function handleOrgSelect(slug: string) {
<button
{...props}
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
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
viewBox="0 0 24 24"
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>
{#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
class="h-4 w-4 text-white transition-transform duration-200 group-hover:scale-110"
viewBox="0 0 24 24"
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>
{/snippet}
</DropdownMenu.Trigger>
@@ -59,18 +69,10 @@ function handleOrgSelect(slug: string) {
class={cn(isActive && "bg-accent")}
>
<div class="flex items-center gap-2">
{#if org.logoUrl}
<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}
<OrgAvatar {org} size="xs" />
<span class="flex-1 truncate">{org.displayName}</span>
{#if isActive}
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<Check class="h-4 w-4 text-primary" />
{/if}
</div>
</DropdownMenu.Item>

View File

@@ -1,2 +1,3 @@
export { default as ConfirmDialog } from "./confirm-dialog.svelte";
export { default as OrgAvatar } from "./org-avatar.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 {
AlertCircle,
ArrowLeft,
Building2,
Calendar,
CheckCircle2,
Clock,
@@ -21,6 +20,7 @@ import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
import { OrgAvatar } from "$lib/components/org";
import { Alert, AlertDescription } from "$lib/components/ui/alert";
import { Button } from "$lib/components/ui/button";
import {
@@ -121,17 +121,7 @@ function isExpiringSoon(expiresAt: Date): boolean {
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if invite.org.logoUrl}
<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}
<OrgAvatar org={invite.org} size="xl" />
<div class="flex-1">
<CardTitle class="text-xl">{invite.org.displayName}</CardTitle>
<CardDescription class="mt-1">

View File

@@ -3,7 +3,6 @@ import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Building,
Globe,
Loader2,
Plus,
@@ -17,7 +16,7 @@ import { resolve } from "$app/paths";
import { page } from "$app/state";
import { api } from "$lib/api/client";
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 { Button } from "$lib/components/ui/button";
import {
@@ -259,19 +258,7 @@ async function executeConfirmAction() {
<Card>
<CardHeader>
<div class="flex items-start gap-4">
{#if org.logoUrl}
<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}
<OrgAvatar {org} size="xl" />
<div class="flex-1">
<CardTitle class="text-2xl">{org.displayName}</CardTitle>
<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 { api } from "$lib/api/client";
import { DashboardLayout } from "$lib/components/layout";
import { OrgAvatar } from "$lib/components/org";
import { Badge } from "$lib/components/ui/badge";
import {
Card,
@@ -70,17 +71,7 @@ $effect(() => {
<CardHeader class="pb-2">
<div class="flex items-start justify-between gap-2">
<div class="flex items-center gap-3">
{#if invite.org.logoUrl}
<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}
<OrgAvatar org={invite.org} size="lg" />
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{invite.org.displayName}
@@ -154,18 +145,7 @@ $effect(() => {
<Card class="h-full transition-colors group-hover:border-primary/50">
<CardHeader class="pb-3">
<div class="flex items-start gap-3">
<!-- Logo or placeholder -->
{#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}
<OrgAvatar {org} size="lg" />
<div class="min-w-0 flex-1">
<CardTitle class="truncate text-base">
{org.displayName}

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import {
AlertCircle,
Building2,
ChevronRight,
Globe,
Loader2,
@@ -13,7 +12,7 @@ import { getContext } from "svelte";
import { resolve } from "$app/paths";
import { api } from "$lib/api/client";
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 {
Card,
@@ -98,17 +97,7 @@ const orgName = $derived(orgQuery.data?.displayName ?? slug);
<!-- Header with org info -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-4">
{#if orgQuery.data?.logoUrl}
<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}
<OrgAvatar org={orgQuery.data} size="xl" />
<div>
<h1 class="text-2xl font-semibold">{orgName}</h1>
<p class="text-sm text-muted-foreground">{slug}</p>