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