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