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:
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user