diff --git a/drizzle/0018_container_images.sql b/drizzle/0018_container_images.sql new file mode 100644 index 0000000..58a70aa --- /dev/null +++ b/drizzle/0018_container_images.sql @@ -0,0 +1 @@ +ALTER TABLE "garden_containers" ADD COLUMN "images" jsonb DEFAULT '[]'::jsonb NOT NULL; diff --git a/src/app/garden/containers/[id]/page.tsx b/src/app/garden/containers/[id]/page.tsx index b7c3edc..b9ebdab 100644 --- a/src/app/garden/containers/[id]/page.tsx +++ b/src/app/garden/containers/[id]/page.tsx @@ -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 (
- +
); } diff --git a/src/app/garden/plants/[id]/page.tsx b/src/app/garden/plants/[id]/page.tsx index 097668f..46a49df 100644 --- a/src/app/garden/plants/[id]/page.tsx +++ b/src/app/garden/plants/[id]/page.tsx @@ -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} /> ); diff --git a/src/components/share-button.tsx b/src/components/share-button.tsx index 32a7361..a728bb4 100644 --- a/src/components/share-button.tsx +++ b/src/components/share-button.tsx @@ -19,15 +19,21 @@ export function ShareButton({ const [open, setOpen] = useState(false); const [shareUrl, setShareUrl] = useState(null); const [copied, setCopied] = useState(false); + const [error, setError] = useState(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 ( <> - +
+ + {error &&

{error}

} +
diff --git a/src/components/share-link-list.tsx b/src/components/share-link-list.tsx new file mode 100644 index 0000000..a465d06 --- /dev/null +++ b/src/components/share-link-list.tsx @@ -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 ( +
+

+ Active share links ({links.length}) +

+ {links.map((link) => ( +
+
+ + Created {new Date(link.createdAt).toLocaleDateString()} + + {link.expiresAt && ( + + Expires {new Date(link.expiresAt).toLocaleDateString()} + + )} +
+ +
+ ))} +
+ ); +} diff --git a/src/modules/_core/share.ts b/src/modules/_core/share.ts index 7072c22..bbc559a 100644 --- a/src/modules/_core/share.ts +++ b/src/modules/_core/share.ts @@ -100,6 +100,48 @@ export async function revokeShareLink(id: string): Promise { 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 { + 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 { const { household } = await getCurrentSession(); const now = new Date(); diff --git a/src/modules/garden/components/container-detail.tsx b/src/modules/garden/components/container-detail.tsx index b0d48a4..0dc69c3 100644 --- a/src/modules/garden/components/container-detail.tsx +++ b/src/modules/garden/components/container-detail.tsx @@ -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("info"); const [editing, setEditing] = useState(false); const [confirming, setConfirming] = useState(false); const [isPending, startTransition] = useTransition(); + const [galleryError, setGalleryError] = useState(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) { + 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 (
{container.coverImageUrl && ( @@ -39,29 +89,36 @@ export function ContainerDetail({ container }: Props) {

{container.type}

{container.locationNotes &&

{container.locationNotes}

}
-
- - - {confirming ? ( -
- - -
- ) : ( - - )} + {confirming ? ( +
+ + +
+ ) : ( + + )} +
+ @@ -79,51 +136,131 @@ export function ContainerDetail({ container }: Props) { )} - + + {/* Info */} + {tab === "info" && ( +
+
+

Plants ({container.plantCount})

+ + + Add plant + +
+ {container.plants.length === 0 ? ( +

No plants in this container yet.

+ ) : ( + + )} +
+ )} + + {/* Gallery */} + {tab === "gallery" && ( +
+ {container.images.length === 0 ? ( +

No photos yet.

+ ) : ( +
+ {container.images.map((url) => ( +
+ +
+ + +
+ {url === container.coverImageUrl && ( + + Cover + + )} +
+ ))} +
+ )} + + {galleryError &&

{galleryError}

} + +
+ {container.images.length < 10 && ( + + )} + + {container.images.length}/10 photos + +
+
+ )} ); } diff --git a/src/modules/garden/components/plant-detail.tsx b/src/modules/garden/components/plant-detail.tsx index 060d8ae..06f19ce 100644 --- a/src/modules/garden/components/plant-detail.tsx +++ b/src/modules/garden/components/plant-detail.tsx @@ -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("info"); const [confirming, setConfirming] = useState(false); const [isPending, startTransition] = useTransition(); @@ -123,29 +126,36 @@ export function PlantDetail({ plant, careLogs, careSchedules, calendars }: Props -
- - - Edit - - {confirming ? ( -
- + +
+ ) : ( + - -
- ) : ( - - )} + )} + + diff --git a/src/modules/garden/manifest.tsx b/src/modules/garden/manifest.tsx index efc65d3..7dd2918 100644 --- a/src/modules/garden/manifest.tsx +++ b/src/modules/garden/manifest.tsx @@ -114,9 +114,28 @@ const gardenManifest: ModuleManifest = { const d = data as ContainerShareData; return (
+ {d.coverImageUrl && ( + + )}

{d.name}

{d.type}

{d.locationNotes &&

{d.locationNotes}

} + {d.images.length > 1 && ( +
+ {d.images.slice(1).map((url) => ( + + ))} +
+ )} {d.plants.length > 0 && (

Plants

diff --git a/src/modules/garden/schema.ts b/src/modules/garden/schema.ts index 1a42210..a81a220 100644 --- a/src/modules/garden/schema.ts +++ b/src/modules/garden/schema.ts @@ -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(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/src/modules/garden/server/actions.ts b/src/modules/garden/server/actions.ts index da1329f..233dad7 100644 --- a/src/modules/garden/server/actions.ts +++ b/src/modules/garden/server/actions.ts @@ -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 }) diff --git a/src/modules/garden/server/queries.ts b/src/modules/garden/server/queries.ts index 9c18e60..65eabfd 100644 --- a/src/modules/garden/server/queries.ts +++ b/src/modules/garden/server/queries.ts @@ -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 { type: gardenContainers.type, locationNotes: gardenContainers.locationNotes, coverImageUrl: gardenContainers.coverImageUrl, + images: gardenContainers.images, createdAt: gardenContainers.createdAt, updatedAt: gardenContainers.updatedAt, plantCount: sql`count(${gardenPlants.id})::int`, @@ -51,6 +53,7 @@ export async function listContainers(): Promise { 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`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`, @@ -98,6 +102,7 @@ export async function getContainer(id: string): Promise