feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations

This commit is contained in:
2026-07-23 07:39:01 +05:30
parent 7bcd1a2a2d
commit 2613c767a6
107 changed files with 20699 additions and 7 deletions
+2
View File
@@ -17,6 +17,7 @@ import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { Messenger } from "./messenger";
import { Inbox } from "./inbox";
import { SmartGallery } from "./smart-gallery";
import "../../app/dashboard/dashboard.css";
export function Dashboard() {
@@ -49,6 +50,7 @@ export function Dashboard() {
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <Messenger />
: active === "inbox" ? <Inbox />
: active === "gallery" ? <SmartGallery theme={theme} />
: active === "team" ? <TeamManagement />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
+10 -1
View File
@@ -34,6 +34,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{ key: "messenger", label: "Messenger", icon: "send", subtitle: "Chat with your team and clients" },
{ key: "inbox", label: "Inbox", icon: "bell", subtitle: "Mentions, messages, alerts and mail — all in one" },
{ key: "gallery", label: "Smart Gallery", icon: "gallery", subtitle: "Every photo and video for your jobs — searchable, editable and shareable" },
],
},
{
@@ -79,7 +80,15 @@ export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
// brand-new user with no membership); every other item requires membership, and the
// items mapped here additionally require the given permission. Unmapped items are
// shown to any member. This is UX only — be-crm still enforces every action.
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox"]);
//
// `gallery` DECISION: it stays in ALWAYS_VISIBLE (nav row always shown to members) rather than
// being gated here by `media.view`. The real access gate lives INSIDE the view (SmartGallery reads
// `useGalleryFeatures().canView`), which uses the permissive "member with zero media.* perms → all
// enabled" fallback. Gating the nav row here would use the stricter sidebar rule (member without the
// perm → hidden) and so would hide the gallery from freshly-seeded members before an admin has
// configured any Media perms — regressing the demo and the common member. So: always-visible row,
// real gating in-view. (be-crm enforces data access regardless of what the nav shows.)
const ALWAYS_VISIBLE = new Set(["dashboard", "profile", "messenger", "inbox", "gallery"]);
const NAV_PERMISSION: Record<string, string | undefined> = {
team: "team.manage",
people: "team.manage",
@@ -0,0 +1,69 @@
"use client";
// ============================================================
// The actual @photo-gallery/sdk mount. Split out of smart-gallery.tsx so it can be
// loaded with next/dynamic({ ssr: false }) — the SDK is browser-only (matchMedia,
// IndexedDB, Leaflet, canvas) and pulls in heavy optional ML models on demand, so it
// must stay out of the dashboard's initial bundle and out of the server render.
// ============================================================
import { useMemo } from "react";
import { PhotoGallery, type ViewId } from "@photo-gallery/sdk";
import { createCrmAIProvider } from "@/lib/gallery-ai";
import {
GALLERY_THEME_TOKENS,
useGalleryFeatures,
useGalleryLockProvider,
useGalleryStorage,
useGalleryUser,
} from "@/lib/gallery-api";
import "@photo-gallery/sdk/styles.css";
import "leaflet/dist/leaflet.css";
export interface SmartGalleryMountProps {
/** The dashboard's current appearance — the gallery must never diverge from the host. */
theme: "dark" | "light";
}
export default function SmartGalleryMount({ theme }: SmartGalleryMountProps) {
const { adapter } = useGalleryStorage();
const currentUser = useGalleryUser();
// Server-backed Recently Deleted lock when the Shell is wired; `undefined` in demo mode, which
// leaves the SDK on its own device-local lock (see useGalleryLockProvider).
const lockProvider = useGalleryLockProvider();
// Feature toggles resolved from the caller's CRM permissions (Media group). Superadmins/owners and
// the demo see everything; see useGalleryFeatures for the safe permissive fallback.
const { features } = useGalleryFeatures();
// Sidebar rows + Collections sections the CRM never wants to surface. The SDK hides both the row and
// the matching Collections section for each id. Screenshots + Documents aren't part of a roofing CRM.
const hiddenViews: ViewId[] = ["screenshots", "sys:documents"];
// One provider per mount. Every model is dynamically imported inside it, so constructing
// it is cheap; the weight only arrives when a photo is actually analyzed.
const ai = useMemo(() => createCrmAIProvider(), []);
return (
<PhotoGallery
embedded
adapter={adapter}
ai={ai}
// The CRM owns light/dark, so the in-gallery Appearance switcher is suppressed and the
// theme is driven straight off the dashboard's own toggle.
theme={theme}
chrome={{ titlebar: false, themeSwitcher: false }}
themeTokens={GALLERY_THEME_TOKENS}
borderRadius={12}
currentUser={currentUser}
lockProvider={lockProvider}
title="Smart Gallery"
// The SDK's floating Info panel defaults to 64px from the top — the height of its
// OWN toolbar. Inside the dashboard it has to clear the 84px CRM topbar instead.
// `style` is applied after the token vars, so this wins over the SDK's inline default.
style={{ ["--apg-overlay-top" as string]: "96px" }}
hiddenViews={hiddenViews}
features={features}
/>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
// ============================================================
// Smart Gallery — the tenant's media library, powered by @photo-gallery/sdk embedded
// inside the dashboard shell. The SDK owns the gallery experience (grid, lightbox,
// photo + video editors, map, people, versions, comments, AI); this file owns the
// LynkedUp chrome around it: page head, demo-mode banner, sizing, and failure
// containment so a gallery fault can never take the dashboard down.
//
// Storage + identity come from `@/lib/gallery-api` (be-crm data door when the Shell is
// configured, device-local otherwise). Theming comes from GALLERY_THEME_TOKENS, which
// maps the SDK's tokens onto this dashboard's own CSS variables.
// ============================================================
import { Component, type ErrorInfo, type ReactNode } from "react";
import dynamic from "next/dynamic";
import { Icon } from "./ui";
import { useGalleryFeatures, useGalleryStorage } from "@/lib/gallery-api";
const SmartGalleryMount = dynamic(() => import("./smart-gallery-mount"), {
ssr: false,
loading: () => <GalleryPlaceholder label="Loading your library…" />,
});
export function SmartGallery({ theme }: { theme: "dark" | "light" }) {
const { live } = useGalleryStorage();
// Whole-view gate. `media.view` with the same permissive fallback the feature toggles use, so a
// superadmin/owner and the demo always pass, and a member is only blocked if they hold some Media
// perms but not `media.view`. The sidebar keeps the row visible (see sidebar.tsx §4) — the real
// gate is here.
const { canView } = useGalleryFeatures();
return (
<div className="view gal">
{/* Slim header — the big PageHead ate vertical space the gallery needs. The CRM topbar already
shows the "Smart Gallery" title; this compact row (~40px) just adds context and the icon. */}
<div className="gal-head">
<span className="gal-head-ic">
<Icon name="gallery" size={16} />
</span>
<h1 className="gal-head-title">Smart Gallery</h1>
<span className="gal-head-sub">Every photo and video for your jobs searchable, editable and shareable.</span>
</div>
{!live && (
<div className="gal-banner">
<Icon name="info" size={14} />
<span>Demo mode stored on this device only. It goes live once the Shell + be-crm are connected.</span>
</div>
)}
{canView ? (
<div className="gal-shell">
<GalleryBoundary>
<SmartGalleryMount theme={theme} />
</GalleryBoundary>
</div>
) : (
<div className="gal-shell">
<div className="gal-placeholder">
<span className="gal-placeholder-ic">
<Icon name="lock" size={28} />
</span>
<h3>You don&apos;t have access to the gallery</h3>
<p>Ask a workspace admin to grant you the &ldquo;View Smart Gallery&rdquo; permission.</p>
</div>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
function GalleryPlaceholder({ label }: { label: string }) {
return (
<div className="gal-placeholder">
<span className="gal-placeholder-ic">
<Icon name="gallery" size={30} />
</span>
<p>{label}</p>
</div>
);
}
interface BoundaryState {
error: Error | null;
}
/**
* The gallery is a large third-party surface with canvas, workers and optional ML models. A render
* fault inside it must degrade to a message rather than blanking the whole dashboard, so it gets its
* own error boundary. (Error boundaries still require a class component in React 19.)
*/
class GalleryBoundary extends Component<{ children: ReactNode }, BoundaryState> {
state: BoundaryState = { error: null };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error("[smart-gallery] render failed", error, info.componentStack);
}
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="gal-placeholder gal-placeholder-error">
<span className="gal-placeholder-ic">
<Icon name="alert" size={30} />
</span>
<h3>The gallery could not be displayed</h3>
<p>{error.message || "An unexpected error occurred."}</p>
<button className="ds-btn v-outline s-sm" onClick={() => this.setState({ error: null })}>
<Icon name="refresh" size={14} /> Try again
</button>
</div>
);
}
}
+6 -1
View File
@@ -24,7 +24,8 @@ import {
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
UsersRound, type LucideIcon,
UsersRound, Image as ImageIcon, Images, File, FolderOpen, Download,
Video, Play, LayoutGrid, ZoomIn, type LucideIcon,
} from "lucide-react";
/* ---------------------------------------------------------- */
@@ -50,6 +51,10 @@ const ICONS: Record<string, LucideIcon> = {
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
team: UsersRound, dots: MoreHorizontal,
// media / gallery (also used by messenger.tsx, which already asks for image/file)
image: ImageIcon, gallery: Images, file: File, folder: FolderOpen,
download: Download, video: Video, play: Play, grid: LayoutGrid,
filter: Filter, zoom: ZoomIn, sparkle: Sparkles, "map-pin": MapPin,
};
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {