feat: share links visible on plants/containers; container image gallery

- Fix ShareButton silently swallowing errors from createShareLink; now
  shows inline error text so failures are visible to the user
- Add getShareLinksForEntity server action and EntityShareLink type to
  _core/share.ts
- Add ShareLinkList component — renders active share links per entity
  with per-row Revoke; renders nothing when empty
- Wire ShareLinkList into plant and container detail pages (loaded
  server-side in parallel with the entity fetch)
- Add images jsonb column to garden_containers schema + migration 0018
- Add addContainerImage / removeContainerImage / setContainerPrimaryImage
  server actions mirroring the plant image pattern (10-image cap, first
  upload auto-sets cover)
- Update ContainerDetailDto, listContainers, getContainer to include images
- Rewrite ContainerDetail with Info/Gallery tabs; Gallery tab mirrors
  plant gallery (3-col grid, star/X overlays, upload button, counter)
- Update ContainerShareData and container renderSharedView to show cover
  image hero and secondary image grid on public share pages
This commit is contained in:
ginnoir
2026-06-02 20:15:29 -05:00
parent 076e35aced
commit c6a34f7471
13 changed files with 463 additions and 103 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "garden_containers" ADD COLUMN "images" jsonb DEFAULT '[]'::jsonb NOT NULL;
+6 -2
View File
@@ -1,14 +1,18 @@
import { notFound } from "next/navigation";
import { getContainer } from "@/modules/garden/server/queries";
import { ContainerDetail } from "@/modules/garden/components/container-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function ContainerPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const container = await getContainer(id);
const [container, shareLinks] = await Promise.all([
getContainer(id),
getShareLinksForEntity("garden.container", id),
]);
if (!container) notFound();
return (
<div className="page-content">
<ContainerDetail container={container} />
<ContainerDetail container={container} shareLinks={shareLinks} />
</div>
);
}
+4 -1
View File
@@ -2,14 +2,16 @@ import { notFound } from "next/navigation";
import { listCalendars } from "@/modules/garden/server/calendar-bridge";
import { getCareLogs, getCareSchedules, getPlant } from "@/modules/garden/server/queries";
import { PlantDetail } from "@/modules/garden/components/plant-detail";
import { getShareLinksForEntity } from "@/modules/_core/share";
export default async function PlantPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [plant, careLogs, careSchedules, calendars] = await Promise.all([
const [plant, careLogs, careSchedules, calendars, shareLinks] = await Promise.all([
getPlant(id),
getCareLogs(id),
getCareSchedules(id),
listCalendars(),
getShareLinksForEntity("garden.plant", id),
]);
if (!plant) notFound();
@@ -20,6 +22,7 @@ export default async function PlantPage({ params }: { params: Promise<{ id: stri
careLogs={careLogs}
careSchedules={careSchedules}
calendars={calendars}
shareLinks={shareLinks}
/>
</div>
);
+18 -9
View File
@@ -19,15 +19,21 @@ export function ShareButton({
const [open, setOpen] = useState(false);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
function share() {
setError(null);
startTransition(async () => {
const result = await createShareLink(entityType, entityId, {
capabilities: { read: true, write: canWrite },
});
setShareUrl(result.url);
setOpen(true);
try {
const result = await createShareLink(entityType, entityId, {
capabilities: { read: true, write: canWrite },
});
setShareUrl(result.url);
setOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create share link");
}
});
}
@@ -41,10 +47,13 @@ export function ShareButton({
return (
<>
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
Share
</Button>
<div className="flex flex-col items-end gap-1">
<Button variant="outline" onClick={share} disabled={isPending}>
<Link />
Share
</Button>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { revokeShareLink } from "@/modules/_core/share";
import type { EntityShareLink } from "@/modules/_core/share";
type Props = {
links: EntityShareLink[];
};
export function ShareLinkList({ links }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
if (links.length === 0) return null;
function handleRevoke(id: string) {
startTransition(async () => {
await revokeShareLink(id);
router.refresh();
});
}
return (
<div className="flex flex-col gap-1 pt-1 border-t border-[var(--ink-faint)]">
<p className="text-xs font-semibold uppercase text-[var(--ink-mute)] tracking-wide">
Active share links ({links.length})
</p>
{links.map((link) => (
<div key={link.id} className="flex items-center justify-between gap-2 text-sm py-0.5">
<div className="flex flex-col">
<span className="text-[var(--ink-mute)]">
Created {new Date(link.createdAt).toLocaleDateString()}
</span>
{link.expiresAt && (
<span className="text-xs text-[var(--ink-mute)]">
Expires {new Date(link.expiresAt).toLocaleDateString()}
</span>
)}
</div>
<button
className="btn btn-ghost btn-sm text-red-500 hover:text-red-600"
onClick={() => handleRevoke(link.id)}
disabled={isPending}
>
Revoke
</button>
</div>
))}
</div>
);
}
+42
View File
@@ -100,6 +100,48 @@ export async function revokeShareLink(id: string): Promise<void> {
export type ActiveShareLink = typeof shareLinks.$inferSelect;
export type EntityShareLink = {
id: string;
createdAt: string;
expiresAt: string | null;
capabilities: ShareLinkCapabilities;
};
export async function getShareLinksForEntity(
entityType: string,
entityId: string,
): Promise<EntityShareLink[]> {
const { household } = await getCurrentSession();
const now = new Date();
const rows = await db
.select({
id: shareLinks.id,
createdAt: shareLinks.createdAt,
expiresAt: shareLinks.expiresAt,
capabilities: shareLinks.capabilities,
})
.from(shareLinks)
.where(
and(
eq(shareLinks.householdId, household.id),
eq(shareLinks.entityType, entityType),
eq(shareLinks.entityId, entityId),
isNull(shareLinks.revokedAt),
),
)
.orderBy(shareLinks.createdAt);
return rows
.filter((r) => !r.expiresAt || r.expiresAt > now)
.map((r) => ({
id: r.id,
createdAt: r.createdAt.toISOString(),
expiresAt: r.expiresAt?.toISOString() ?? null,
capabilities: r.capabilities,
}));
}
export async function getActiveShareLinks(): Promise<ActiveShareLink[]> {
const { household } = await getCurrentSession();
const now = new Date();
@@ -3,16 +3,31 @@
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ShareButton } from "@/components/share-button";
import { deleteContainer } from "../server/actions";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import {
addContainerImage,
deleteContainer,
removeContainerImage,
setContainerPrimaryImage,
} from "../server/actions";
import { ContainerForm } from "./container-form";
import type { ContainerDetailDto } from "../server/queries";
type Props = { container: ContainerDetailDto };
type Tab = "info" | "gallery";
export function ContainerDetail({ container }: Props) {
type Props = {
container: ContainerDetailDto;
shareLinks: EntityShareLink[];
};
export function ContainerDetail({ container, shareLinks }: Props) {
const [tab, setTab] = useState<Tab>("info");
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
const [galleryError, setGalleryError] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const router = useRouter();
function handleDelete() {
@@ -23,6 +38,41 @@ export function ContainerDetail({ container }: Props) {
});
}
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setGalleryError(null);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/uploads", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload failed");
const data = (await res.json()) as { url: string };
await addContainerImage({ id: container.id, url: data.url });
router.refresh();
} catch {
setGalleryError("Image upload failed.");
} finally {
setUploadingImage(false);
e.target.value = "";
}
}
function handleRemoveImage(url: string) {
startTransition(async () => {
await removeContainerImage({ id: container.id, url });
router.refresh();
});
}
function handleSetPrimary(url: string) {
startTransition(async () => {
await setContainerPrimaryImage({ id: container.id, url });
router.refresh();
});
}
return (
<div className="flex flex-col gap-6">
{container.coverImageUrl && (
@@ -39,29 +89,36 @@ export function ContainerDetail({ container }: Props) {
<p className="text-sm text-[var(--ink-mute)] capitalize mt-1">{container.type}</p>
{container.locationNotes && <p className="text-sm mt-2">{container.locationNotes}</p>}
</div>
<div className="flex gap-2 shrink-0">
<ShareButton entityType="garden.container" entityId={container.id} />
<button className="btn btn-ghost btn-sm" onClick={() => setEditing(true)}>
Edit
</button>
{confirming ? (
<div className="flex gap-1">
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex gap-2">
<ShareButton entityType="garden.container" entityId={container.id} />
<button className="btn btn-ghost btn-sm" onClick={() => setEditing((v) => !v)}>
Edit
</button>
)}
{confirming ? (
<div className="flex gap-1">
<button
className="btn btn-danger btn-sm"
onClick={handleDelete}
disabled={isPending}
>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
</div>
<ShareLinkList links={shareLinks} />
</div>
</div>
@@ -79,51 +136,131 @@ export function ContainerDetail({ container }: Props) {
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
{/* Tabs */}
<div className="flex gap-6 border-b border-[var(--ink-faint)]">
{(["info", "gallery"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-2 text-sm font-medium capitalize transition-colors ${
tab === t
? "border-b-2 border-[var(--ink)] text-[var(--ink)]"
: "text-[var(--ink-mute)] hover:text-[var(--ink)]"
}`}
>
+ Add plant
</a>
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
<img
src={p.primaryImageUrl}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
)}
</div>
<span
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
>
{p.healthStatus}
</span>
</div>
</a>
))}
</div>
)}
{t}
</button>
))}
</div>
{/* Info */}
{tab === "info" && (
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Plants ({container.plantCount})</h2>
<a
href={`/garden/plants/new?containerId=${container.id}`}
className="btn btn-ghost btn-sm"
>
+ Add plant
</a>
</div>
{container.plants.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No plants in this container yet.</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{container.plants.map((p) => (
<a
key={p.id}
href={`/garden/plants/${p.id}`}
className="card p-3 hover:bg-[var(--surface-2)] transition-colors"
>
<div className="flex items-center gap-3">
{p.primaryImageUrl && (
<img
src={p.primaryImageUrl}
alt=""
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
)}
<div>
<p className="font-medium text-sm">{p.name}</p>
{p.scientificName && (
<p className="text-xs text-[var(--ink-mute)] italic">{p.scientificName}</p>
)}
</div>
<span
className={`ml-auto text-xs badge ${p.healthStatus === "healthy" ? "badge-success" : "badge-warning"}`}
>
{p.healthStatus}
</span>
</div>
</a>
))}
</div>
)}
</div>
)}
{/* Gallery */}
{tab === "gallery" && (
<div className="flex flex-col gap-4">
{container.images.length === 0 ? (
<p className="text-sm text-[var(--ink-mute)]">No photos yet.</p>
) : (
<div className="grid grid-cols-3 gap-2">
{container.images.map((url) => (
<div key={url} className="relative group">
<img src={url} alt="" className="w-full aspect-square object-cover rounded-lg" />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 rounded-lg flex items-center justify-center gap-3 transition-opacity">
<button
onClick={() => handleSetPrimary(url)}
disabled={isPending}
title="Set as cover"
className={`text-lg leading-none ${url === container.coverImageUrl ? "text-yellow-400" : "text-white"}`}
>
</button>
<button
onClick={() => handleRemoveImage(url)}
disabled={isPending}
title="Remove"
className="text-white text-lg leading-none"
>
</button>
</div>
{url === container.coverImageUrl && (
<span className="absolute top-1 left-1 text-xs px-1 bg-black/60 text-yellow-300 rounded">
Cover
</span>
)}
</div>
))}
</div>
)}
{galleryError && <p className="text-sm text-red-500">{galleryError}</p>}
<div className="flex items-center gap-3">
{container.images.length < 10 && (
<label className="btn btn-ghost btn-sm cursor-pointer">
{uploadingImage ? "Uploading…" : "Upload photo"}
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleImageUpload}
disabled={uploadingImage}
/>
</label>
)}
<span className="text-xs text-[var(--ink-mute)]">
{container.images.length}/10 photos
</span>
</div>
</div>
)}
</div>
);
}
+33 -23
View File
@@ -7,6 +7,8 @@ import { deletePlant, addPlantImage, removePlantImage, setPrimaryImage } from ".
import type { CalendarDto } from "../server/calendar-bridge";
import type { CareLogDto, CareScheduleDto, PlantDetailDto } from "../server/queries";
import { ShareButton } from "@/components/share-button";
import { ShareLinkList } from "@/components/share-link-list";
import type { EntityShareLink } from "@/modules/_core/share";
import { CareHistoryList } from "./care-history-list";
import { CareLogForm } from "./care-log-form";
import { CareScheduleEditor } from "./care-schedule-editor";
@@ -18,6 +20,7 @@ type Props = {
careLogs: CareLogDto[];
careSchedules: CareScheduleDto[];
calendars: CalendarDto[];
shareLinks: EntityShareLink[];
};
function InfoRow({
@@ -44,7 +47,7 @@ function healthBadgeClass(status: string): string {
return "badge-warning";
}
export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props) {
export function PlantDetail({ plant, careLogs, careSchedules, calendars, shareLinks }: Props) {
const [tab, setTab] = useState<Tab>("info");
const [confirming, setConfirming] = useState(false);
const [isPending, startTransition] = useTransition();
@@ -123,29 +126,36 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props
</div>
</div>
<div className="flex gap-2 shrink-0">
<ShareButton entityType="garden.plant" entityId={plant.id} />
<Link href={`/garden/plants/${plant.id}/edit`} className="btn btn-ghost btn-sm">
Edit
</Link>
{confirming ? (
<div className="flex gap-1">
<button className="btn btn-danger btn-sm" onClick={handleDelete} disabled={isPending}>
Confirm
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex gap-2">
<ShareButton entityType="garden.plant" entityId={plant.id} />
<Link href={`/garden/plants/${plant.id}/edit`} className="btn btn-ghost btn-sm">
Edit
</Link>
{confirming ? (
<div className="flex gap-1">
<button
className="btn btn-danger btn-sm"
onClick={handleDelete}
disabled={isPending}
>
Confirm
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setConfirming(false)}
disabled={isPending}
>
Cancel
</button>
</div>
) : (
<button className="btn btn-ghost btn-sm" onClick={() => setConfirming(true)}>
Delete
</button>
)}
)}
</div>
<ShareLinkList links={shareLinks} />
</div>
</div>
+19
View File
@@ -114,9 +114,28 @@ const gardenManifest: ModuleManifest = {
const d = data as ContainerShareData;
return (
<div className="flex flex-col gap-3">
{d.coverImageUrl && (
<img
src={d.coverImageUrl}
alt=""
className="w-full max-h-48 object-cover rounded-lg"
/>
)}
<h2 className="text-xl font-bold">{d.name}</h2>
<p className="text-sm capitalize text-[var(--ink-mute)]">{d.type}</p>
{d.locationNotes && <p className="text-sm">{d.locationNotes}</p>}
{d.images.length > 1 && (
<div className="grid grid-cols-3 gap-2 mt-1">
{d.images.slice(1).map((url) => (
<img
key={url}
src={url}
alt=""
className="w-full aspect-square object-cover rounded"
/>
))}
</div>
)}
{d.plants.length > 0 && (
<div>
<p className="text-sm font-medium mb-1">Plants</p>
+1
View File
@@ -23,6 +23,7 @@ export const gardenContainers = pgTable(
type: text("type").notNull().default("other"),
locationNotes: text("location_notes"),
coverImageUrl: text("cover_image_url"),
images: jsonb("images").notNull().default([]).$type<string[]>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
+74
View File
@@ -93,6 +93,80 @@ export async function deleteContainer(input: { id: string }) {
revalidatePath("/garden");
}
export async function addContainerImage(input: { id: string; url: string }) {
const parsed = z.object({ id: z.string().uuid(), url: z.string().min(1) }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
const [row] = await db
.select({ images: gardenContainers.images, coverImageUrl: gardenContainers.coverImageUrl })
.from(gardenContainers)
.where(eq(gardenContainers.id, parsed.id))
.limit(1);
if (!row) throw new Error("Container not found");
if (row.images.length >= 10) throw new Error("Maximum 10 images allowed");
const newImages = [...row.images, parsed.url];
await db
.update(gardenContainers)
.set({
images: newImages,
coverImageUrl: row.images.length === 0 ? parsed.url : row.coverImageUrl,
updatedAt: new Date(),
})
.where(eq(gardenContainers.id, parsed.id));
revalidatePath(`/garden/containers/${parsed.id}`);
}
export async function removeContainerImage(input: { id: string; url: string }) {
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
const [row] = await db
.select({ images: gardenContainers.images, coverImageUrl: gardenContainers.coverImageUrl })
.from(gardenContainers)
.where(eq(gardenContainers.id, parsed.id))
.limit(1);
if (!row) throw new Error("Container not found");
const newImages = row.images.filter((u) => u !== parsed.url);
const wasPrimary = row.coverImageUrl === parsed.url;
const newCover = wasPrimary ? (newImages[0] ?? null) : row.coverImageUrl;
await db
.update(gardenContainers)
.set({ images: newImages, coverImageUrl: newCover, updatedAt: new Date() })
.where(eq(gardenContainers.id, parsed.id));
revalidatePath(`/garden/containers/${parsed.id}`);
}
export async function setContainerPrimaryImage(input: { id: string; url: string }) {
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
const [row] = await db
.select({ images: gardenContainers.images })
.from(gardenContainers)
.where(eq(gardenContainers.id, parsed.id))
.limit(1);
if (!row) throw new Error("Container not found");
if (!row.images.includes(parsed.url)) throw new Error("Image not in container gallery");
await db
.update(gardenContainers)
.set({ coverImageUrl: parsed.url, updatedAt: new Date() })
.where(eq(gardenContainers.id, parsed.id));
revalidatePath(`/garden/containers/${parsed.id}`);
}
async function assertCanAccessContainer(id: string, householdId: string) {
const [row] = await db
.select({ id: gardenContainers.id })
+5
View File
@@ -10,6 +10,7 @@ export type ContainerDto = {
type: string;
locationNotes: string | null;
coverImageUrl: string | null;
images: string[];
plantCount: number;
createdAt: string;
updatedAt: string;
@@ -39,6 +40,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`count(${gardenPlants.id})::int`,
@@ -51,6 +53,7 @@ export async function listContainers(): Promise<ContainerDto[]> {
return rows.map((r) => ({
...r,
images: r.images ?? [],
plantCount: r.plantCount ?? 0,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
@@ -68,6 +71,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
@@ -98,6 +102,7 @@ export async function getContainer(id: string): Promise<ContainerDetailDto | nul
type: container.type,
locationNotes: container.locationNotes,
coverImageUrl: container.coverImageUrl,
images: container.images ?? [],
plantCount: container.plantCount ?? 0,
createdAt: container.createdAt.toISOString(),
updatedAt: container.updatedAt.toISOString(),
@@ -8,6 +8,7 @@ export type ContainerShareData = {
type: string;
locationNotes: string | null;
coverImageUrl: string | null;
images: string[];
plants: { id: string; name: string; scientificName: string | null }[];
};
@@ -74,6 +75,7 @@ export async function loadContainerForShare(id: string): Promise<ContainerShareD
type: container.type,
locationNotes: container.locationNotes,
coverImageUrl: container.coverImageUrl,
images: container.images ?? [],
plants,
};
}