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
+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 })