feat: add core domain types for Photo Gallery SDK including media items, albums, and annotations
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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't have access to the gallery</h3>
|
||||
<p>Ask a workspace admin to grant you the “View Smart Gallery” 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user